Compare commits

...
Author SHA1 Message Date
moloch-- c23067a0e0 Wait for AI RPC event streams to subscribe 2026-04-02 09:17:33 -07:00
moloch-- 3aefc39c08 Stabilize AI RPC event stream tests 2026-04-02 08:50:19 -07:00
moloch-- abd8c83df8 Stabilize Windows WireGuard reconnects 2026-04-02 08:22:51 -07:00
moloch-- dc13a5bb93 Add connecting animation 2026-03-31 13:33:19 -07:00
moloch-- 1f2946eb87 Revert changes to netstack 2026-03-31 09:45:40 -07:00
moloch-- 5eeda4c02b console now keeps the long-lived background WG streams on one connection 2026-03-31 09:36:05 -07:00
moloch-- 55a3386313 Improve reliabilty of multiplayer/wg setup 2026-03-30 20:02:55 -07:00
moloch-- b34589a002 Merge branch 'master' of github.com:BishopFox/sliver 2026-03-30 15:56:54 -07:00
moloch-- 4486b17b49 Tweak release trigger 2026-03-30 15:56:45 -07:00
Joe 377ee5ca7c Merge pull request #2229 from BishopFox/docs/multiplayer-wg
Update docs for wg+grpc/mtls multiplayer
2026-03-30 12:13:43 -07:00
moloch-- 4765f40854 v1.7.4 2026-03-30 11:51:15 -07:00
moloch-- e239d85821 v1.7.4 2026-03-30 11:48:34 -07:00
Joe dec419b284 Merge pull request #2232 from BishopFox/fix/linux-ver
Implement Detailed Linux Version Info
2026-03-30 09:52:39 -07:00
moloch-- 6d51a23cc9 Implement linux detailed version info 2026-03-29 19:11:48 -07:00
moloch-- 3d6eec47be Implement linux detailed version info 2026-03-29 19:11:40 -07:00
moloch-- 464fb7008c Tweak ai.yaml config schema 2026-03-29 18:41:06 -07:00
moloch-- ffa5f89f5f Tweak styling of system prompt 2026-03-29 09:00:15 -07:00
moloch-- f19cbdc957 Give agent encoder/decoder tools 2026-03-29 08:43:14 -07:00
moloch-- 83bfc8d8e6 Add default prompt to ai.yaml config 2026-03-27 21:36:58 -07:00
moloch-- 365bb3596e Add aliases and extensions to agent tools 2026-03-27 20:22:15 -07:00
moloch-- 9ee96c2ce6 Add ctx window usage to tui 2026-03-27 19:43:42 -07:00
moloch-- e776e266d0 Stack toast 2026-03-27 18:46:08 -07:00
moloch-- d080c8a646 Implement more tools for the ai to use 2026-03-27 18:15:46 -07:00
moloch-- cb6402245e Implement more tools for the ai to use 2026-03-27 18:15:37 -07:00
moloch-- e4357277ec Add web search to oai models 2026-03-27 17:33:15 -07:00
moloch-- 5dc2ab1490 Render reasoning text as md 2026-03-27 17:13:39 -07:00
moloch-- 9f034a9956 Implement better reasoning ui 2026-03-27 17:10:24 -07:00
moloch-- f700535997 Implement selectable text 2026-03-27 16:59:08 -07:00
moloch-- 31bca034d8 Remove layout text 2026-03-27 16:48:08 -07:00
moloch-- fac4ad5fb7 Add mouse support to ai tui 2026-03-27 16:40:33 -07:00
moloch-- c128d33514 Add toast notifications to tui 2026-03-27 13:01:34 -07:00
moloch-- 4a37262bcf Implement scroll wheel support for conversation pane 2026-03-27 11:56:58 -07:00
moloch-- a2295112bd Clean up various styling in ai tui 2026-03-27 11:47:39 -07:00
moloch-- 834dff0f6e Add lic header 2026-03-27 08:38:41 -07:00
Joe f4a0431fdd Merge pull request #2231 from BishopFox/fix/pivots-dos
Fix/pivots dos
2026-03-26 21:40:23 -07:00
moloch-- d072b328c0 Fix pivot handler test helper collision 2026-03-26 19:44:18 -07:00
moloch-- 435bbcbd7e Merge master 2026-03-26 18:51:05 -07:00
moloch-- 84dc28e584 Fix nil deref in pivots 2026-03-26 18:50:13 -07:00
Joe 639592cb02 Merge pull request #2230 from BishopFox/fix/tunnel-dos
Fix/tunnel dos
2026-03-26 18:03:16 -07:00
moloch-- 8c7a1a6b40 Fix dos/panic in tunnel closed 2026-03-26 17:40:57 -07:00
moloch-- 06274c4061 Fix dos/panic in tunnel closed 2026-03-26 17:40:46 -07:00
moloch-- 593925b4a5 Update docs for wg+grpc/mtls multiplayer 2026-03-26 17:30:33 -07:00
Joe 255477e572 Merge pull request #2228 from BishopFox/fix/mcp
Fix/mcp
2026-03-26 17:21:49 -07:00
moloch-- 0acc6911d8 Tweak ai mode init 2026-03-26 16:59:27 -07:00
moloch-- 4ca026906b Merge master 2026-03-26 15:54:00 -07:00
moloch-- 97a249e28e Implement http auth for mcp 2026-03-26 15:32:26 -07:00
moloch-- 9f331687d3 wip on agentic actions 2026-03-24 19:06:47 -07:00
Joe 14d68d6fcd Merge pull request #2225 from BishopFox/ai/tui2
Ai/tui
2026-03-22 08:36:46 -07:00
moloch-- 05c5334fea Switch to openai sdk 2026-03-21 09:23:49 -07:00
moloch-- d277a14c57 Add examples dir 2026-03-21 08:29:27 -07:00
moloch-- a7b5dbe6ee Tweak colors for user blocks 2026-03-20 20:29:28 -07:00
moloch-- c188dc1e69 Make messages copy-able 2026-03-20 20:19:02 -07:00
moloch-- 9fc9d1762e Merge branch 'master' into ai/tui2 2026-03-20 20:00:05 -07:00
Joe 4ef86448d0 Merge pull request #2224 from BishopFox/clean-makefile
Cleanup makefile vars
2026-03-20 08:22:50 -07:00
moloch-- 02117a6072 Cleanup makefile vars 2026-03-20 07:51:48 -07:00
moloch-- c272c8abac Update mcp dep 2026-03-19 09:26:17 -07:00
moloch-- cdf2412b39 Update mcp dep 2026-03-19 09:25:56 -07:00
moloch-- 40f52791d3 Update gprc dep 2026-03-19 08:55:25 -07:00
moloch-- a5d8bd16b9 Merge branch 'master' of github.com:BishopFox/sliver 2026-03-19 08:53:40 -07:00
moloch-- bf113f2151 npm update 2026-03-19 08:53:29 -07:00
moloch-- 4cd010afb9 wip on copy/paste 2026-03-19 08:52:46 -07:00
Joe 04b0bdd199 Merge pull request #2222 from skoveit/fix/alias-save-beacon-hostname
fix: populate hostname from beacon when using --save flag
2026-03-16 19:05:11 -07:00
Joe 9682db93b3 Merge branch 'master' into fix/alias-save-beacon-hostname 2026-03-16 18:47:06 -07:00
Joe 1d7ffb4ac2 Merge pull request #2221 from BishopFox/ai/tui
Ai/tui
2026-03-16 18:46:53 -07:00
0xOral c5e7cd6354 fix: use beacon.GetHostname() instead of direct field access 2026-03-17 03:16:19 +02:00
0xOral b20ba7284b fix: use beacon hostname when saving output with --save flag 2026-03-17 02:59:00 +02:00
moloch-- 49498210f8 Fix WireGuard generate test IP collisions 2026-03-16 17:20:33 -07:00
moloch-- 14e9121302 Go 1.25.8 2026-03-15 20:25:02 -07:00
moloch-- 8bd0257dd9 Go 1.25.8 2026-03-15 20:14:18 -07:00
Joe 93fdfb3add Merge branch 'master' into ai/tui 2026-03-15 20:03:27 -07:00
moloch-- e02b03cc70 Go 1.25.7 2026-03-15 20:03:01 -07:00
Joe 6831fe50a6 Merge pull request #2220 from skoveit/feat/multi-protocol-beacons
feat: multi-protocol implant builds via TUI
2026-03-15 20:02:36 -07:00
moloch-- 405108866d Update gh action workflows to 1.26.1 2026-03-15 15:34:34 -07:00
moloch-- 4d551b71e7 Fix huh form rendering 2026-03-15 14:11:33 -07:00
moloch-- a7edd9838d Sync prompt to stdout 2026-03-15 13:50:04 -07:00
moloch-- 9ef54ca6b1 Add mouse support to ai tui 2026-03-15 12:48:43 -07:00
moloch-- 4622c50288 Add ai chat sync 2026-03-15 12:26:03 -07:00
moloch-- b1ee0355af isolate multiplayer wg from c2 wg 2026-03-15 11:51:09 -07:00
moloch-- 01d9b213f2 fix grpc/mtls+wg for multiplayer 2026-03-15 11:11:27 -07:00
moloch-- f56886bce5 Merge grpc/mtls+wg branch 2026-03-15 10:55:50 -07:00
moloch-- 88176dfd00 Improve docs command 2026-03-15 10:31:41 -07:00
moloch-- 45dc3ea09b Improve doc file selector 2026-03-15 10:25:38 -07:00
moloch-- 682b3c2409 Upgrade to charm v2 libs 2026-03-15 09:47:29 -07:00
moloch-- 3a93f312b4 Add optional Wireguard multiplayer 2026-03-15 09:38:54 -07:00
moloch-- 2ca95f7a7c Add docs command 2026-03-15 09:21:24 -07:00
moloch-- d2319432ab Create docs go pkg 2026-03-15 08:53:52 -07:00
moloch-- 253f1a8806 Create docs go pkg 2026-03-15 08:52:05 -07:00
moloch-- 2c025d5970 Add new conversation modal 2026-03-15 07:56:11 -07:00
moloch-- f0034683f9 Tweak tui borders 2026-03-15 07:16:45 -07:00
moloch-- 8e8398b654 Tweak border 2026-03-14 23:20:07 -07:00
moloch-- aa02293f05 Fix borders 2026-03-14 22:34:58 -07:00
moloch-- f967a32308 Add scrollbar 2026-03-14 22:22:46 -07:00
moloch-- 99ec812455 Improve scroll of conversation pane, move context info to modal 2026-03-14 22:08:28 -07:00
moloch-- eeef542eeb Make conversation scrollable 2026-03-14 21:48:54 -07:00
moloch-- 6d7a10b392 Switch to fantasy to call models 2026-03-14 21:22:01 -07:00
Joe ca40c0a1e7 Merge pull request #2219 from BishopFox/fix/windows-ci
Fix Windows CI
2026-03-14 20:48:12 -07:00
moloch-- 4a5ad6ea92 Redesign delete modal 2026-03-14 20:13:02 -07:00
moloch-- 266dabf6c3 Redesign modals 2026-03-14 20:03:49 -07:00
moloch-- a4c8968539 Resize tui on window resize 2026-03-14 18:54:09 -07:00
moloch-- 613c030ea3 Add markdown rendering 2026-03-14 15:38:54 -07:00
moloch-- 9f6bfb6714 Working animation 2026-03-14 15:27:35 -07:00
0xOral a764814a8e ux: refine reconfig command warnings and safety prompts 2026-03-14 23:16:30 +02:00
0xOral 0c9ce138a0 allow runtime protocol switching 2026-03-14 22:54:25 +02:00
0xOral 6c1dcf6a06 add flags to include multiple transport protocols at build time 2026-03-14 22:53:33 +02:00
moloch-- fffe93819d Improve server-side ai grpc handlers 2026-03-14 13:31:32 -07:00
moloch-- 635ee421e3 Add animation 2026-03-14 12:54:20 -07:00
moloch-- 3ce6d69474 Add server-side ai-config form 2026-03-14 12:00:28 -07:00
moloch-- cd523c7f77 Wire tui to ai grpc 2026-03-14 11:43:21 -07:00
moloch-- 1f1b759914 Merge branch 'codex/add-bubbletea-ai-tui-layout' into ai/tui 2026-03-14 11:15:14 -07:00
moloch-- b78915fcbd Basic db layout 2026-03-14 11:14:54 -07:00
moloch-- fa0e13a5cc Basic tui layout 2026-03-14 11:14:03 -07:00
moloch-- 924bcddfd2 Fix gogo test on Windows 2026-03-14 10:25:34 -07:00
moloch-- affbf61452 Fix unit test on windows 2026-03-14 09:57:52 -07:00
moloch-- 7c6b2f70e4 Add windows ci runner 2026-03-14 08:42:57 -07:00
moloch-- c46f2938de Fix cross-platform issues with running ci on windows 2026-03-14 08:37:31 -07:00
moloch-- b355fe70d6 Add Windows makefile 2026-03-13 16:14:11 -07:00
moloch-- 10b1079286 Tweak agents.md 2026-03-13 15:29:45 -07:00
Joe 9c042e6977 Merge pull request #2217 from BishopFox/fix/cert-revoke
Improve operator cert revocation
2026-03-13 12:04:26 -07:00
Joe 62583cac8f Merge branch 'master' into fix/cert-revoke 2026-03-13 11:02:34 -07:00
moloch 95764945b8 Improve operator cert revocation 2026-03-13 10:59:00 -07:00
Joe 26abda1c15 Merge pull request #2216 from skoveit/fix/timeout-int64p-mismatch
fix: timeout flag ignored in aliases and extensions due to type mismatch
2026-03-13 07:38:21 -07:00
moloch 01dd3cac72 Go mod tidy 2026-03-13 07:02:06 -07:00
0xOral f8faa1f767 fix: normalize all command timeout flags to Int64P across client codebase 2026-03-13 02:49:01 +02:00
0xOral a9e48e84cd fix: extension timeout flag ignored due to IntP/Int64P mismatch 2026-03-13 02:30:55 +02:00
0xOral 9d6704d925 fix: alias -t flag always capped at 30s due to IntP/Int64P type mismatch (#2183) 2026-03-13 02:04:35 +02:00
Joe ce5157e2e9 Merge pull request #2213 from skoveit/fix/wininet-handle-leak
fix(wininet): Fix handle leak in WinINet driver
2026-03-11 08:29:42 -07:00
0xOral 7d19051df8 fix(wininet): close connection and request handles after each request 2026-03-11 01:32:27 +02:00
Joe a0d3bd579f Merge pull request #2211 from skoveit/feat/configurable-portfwd-keepalive
feat: make TCP keepalive period configurable for portfwd and rportfwd
2026-03-08 10:36:42 -07:00
0xOral 82c309632a default keepalive value 30s for both rportfwd and portfwd 2026-03-08 17:27:27 +02:00
0xOral 5c0b8ec460 disable keepalive with --keepalive -1 value 2026-03-08 17:25:06 +02:00
0xOral 7791a86eb4 configurable keepalive: portfwd --keepalive 2026-03-08 17:20:35 +02:00
Joe 7ac4db3fa0 Merge pull request #2207 from BishopFox/fix/beacon-getsystem
Fix for getsystem in beacon mode
2026-03-05 18:31:39 -07:00
moloch-- 512bc822ef Implement fix for getsystem in beacon mode 2026-03-05 08:22:42 -08:00
Joe f2bb9c3733 Merge pull request #2205 from BishopFox/feature/lazy-lg-msg
Lazy spool large messages to disk
2026-03-04 15:40:56 -07:00
moloch-- 0bd4f100fd Lazy spool large messages to disk 2026-03-04 14:21:07 -08:00
Joe 3230322fd6 Merge pull request #2201 from BishopFox/feature/exec-shellcode
Add support for wasm-donut options in windows `execute-shellcode`
2026-03-02 08:23:49 -07:00
1804 changed files with 359577 additions and 159232 deletions

No files matched your search

+18 -7
View File
@@ -2,8 +2,8 @@ name: Release
on:
push:
branches:
- master
tags:
- "v*.*.*"
permissions:
contents: write
@@ -21,11 +21,22 @@ jobs:
with:
fetch-depth: 0
- name: Find Semver Tag
- name: Validate Release Tag
id: tag
run: |
git fetch --prune --tags -f
tag=$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
git fetch --prune --tags -f origin
tag="${GITHUB_REF_NAME}"
if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag $tag is not a release semver tag."
exit 0
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/master"; then
echo "Tag $tag does not point to a commit on origin/master."
exit 0
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
release:
@@ -41,10 +52,10 @@ jobs:
with:
fetch-depth: 0
- name: Go 1.25
- name: Go 1.25.8
uses: actions/setup-go@v6
with:
go-version: "1.25.6"
go-version: "1.25.8"
- name: Install Minisign
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps:
# Setup go environment
- name: Go 1.25
- name: Go 1.25.8
uses: actions/setup-go@v6
with:
go-version: "1.25.6"
go-version: "1.25.8"
- name: Checkout repository
uses: actions/checkout@v6
+53 -8
View File
@@ -6,11 +6,13 @@ on:
paths:
- "go.mod"
- "go.sum"
- ".github/workflows/unit-tests.yml"
- "Makefile"
- "**/*.go"
- "**/*.c"
- "**/*.h"
- "**/*.sh"
- "**/*.ps1"
types:
- opened
- reopened
@@ -18,15 +20,15 @@ on:
- ready_for_review
jobs:
linux-windows-build-test:
name: Linux & Windows Test
linux-build-test:
name: Linux Build & Test
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.25
- name: Go 1.25.8
uses: actions/setup-go@v6
with:
go-version: "1.25.6"
go-version: "1.25.8"
- name: OS Packages
run: |
@@ -64,16 +66,59 @@ jobs:
- name: Test Linux
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh ${{ steps.go-tests-flags.outputs.args }}
windows-build-test:
name: Windows Build & Test
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.25.8
uses: actions/setup-go@v6
with:
go-version: "1.25.8"
- name: Install make
run: choco install make -y --no-progress
- name: Check Out Code
uses: actions/checkout@v6
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
- name: Make Windows (amd64)
run: make windows-amd64
- name: Determine go-tests flags
id: go-tests-flags
shell: pwsh
run: |
$baseSha = "${{ github.event.pull_request.base.sha }}"
$headSha = "${{ github.sha }}"
$changedFiles = git diff --name-only $baseSha $headSha
if ($changedFiles | Select-String -Pattern '^(implant/|server/gogo/|server/generate/)') {
"args=" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
} else {
"args=--skip-generate" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
}
- name: Test Windows
shell: pwsh
run: |
make clean
make
./go-tests.ps1 ${{ steps.go-tests-flags.outputs.args }}
macos-build-test:
name: MacOS Test
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.25
- name: Go 1.25.8
uses: actions/setup-go@v6
with:
go-version: "1.25.6"
go-version: "1.25.8"
- name: Check Out Code
uses: actions/checkout@v6
@@ -108,10 +153,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.25
- name: Go 1.25.8
uses: actions/setup-go@v6
with:
go-version: "1.25.6"
go-version: "1.25.8"
- name: Check Out Code
uses: actions/checkout@v6
+5
View File
@@ -1,3 +1,8 @@
/crush
/examples
/.vscode
/.vscode/*
/.devcontainer
/.idea
+1 -1
View File
@@ -4,7 +4,7 @@
- `make` builds server + client using vendored modules and runs `go-assets.sh`.
- `make client` builds a CGO-free client; `make debug` keeps symbols.
- `make pb` regenerates protobuf stubs from `protobuf/`.
- `./go-tests.sh` runs the curated Go test suite with build tags.
- `./go-tests.sh` runs the Go test suite, this is the only way you should run unit tests.
- Docs site: `cd docs/sliver-docs && npm install && npm run dev` for local dev; `npm run build` for a static build.
## Coding Style & Naming Conventions
+273 -143
View File
@@ -3,7 +3,7 @@
#
GO ?= go
ARTIFACT_SUFFIX ?=
ARTIFACT_SUFFIX ?=
ENV =
TAGS ?= -tags go_sqlite
CGO_ENABLED = 0
@@ -12,19 +12,6 @@ ifneq (,$(findstring cgo_sqlite,$(TAGS)))
CGO_ENABLED = 1
endif
#
# Prerequisites
#
# https://stackoverflow.com/questions/5618615/check-if-a-program-exists-from-a-makefile
EXECUTABLES = uname sed git date cut $(GO)
K := $(foreach exec,$(EXECUTABLES),\
$(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH")))
#
# Build Information
#
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
MIN_SUPPORTED_GO_MINOR_VERSION = 25
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MIN_SUPPORTED_GO_MAJOR_VERSION).$(MIN_SUPPORTED_GO_MINOR_VERSION)
@@ -34,151 +21,294 @@ ARMORY_PUBLIC_KEY ?= RWSBpxpRWDrD7Fe+VvRE3c2VEDC2NK80rlNCj+BX0gz44Xw07r6KQD9L
ARMORY_REPO_URL ?= https://api.github.com/repos/sliverarmory/armory/releases
CLIENT_ASSETS_PKG = github.com/bishopfox/sliver/client/assets
SLIVER_UPDATE_PKG = github.com/bishopfox/sliver/client/command/update
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
ifneq ($(OS),Windows_NT)
#
# Prerequisites
#
# https://stackoverflow.com/questions/5618615/check-if-a-program-exists-from-a-makefile
EXECUTABLES = uname sed git date cut $(GO)
K := $(foreach exec,$(EXECUTABLES),\
$(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH")))
#
# Build Information
#
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
LDFLAGS = -ldflags "-s -w \
-X $(SLIVER_UPDATE_PKG).SliverPublicKey=$(SLIVER_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
# Debug builds shouldn't be stripped (-s -w flags)
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
SED_INPLACE := sed -i
STATIC_TARGET := linux
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
# Debug builds shouldn't be stripped (-s -w flags)
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
SED_INPLACE := sed -i
STATIC_TARGET := linux
UNAME_S := $(shell uname -s)
UNAME_P := $(shell uname -p)
# Programs required for generating protobuf/grpc files
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
ifeq ($(MAKECMDGOALS), pb)
K := $(foreach exec,$(PB_COMPILERS),\
$(if $(shell which $(exec)),some string,$(error "Missing protobuf util $(exec) in PATH")))
endif
# *** Darwin ***
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
STATIC_TARGET := macos
endif
# If no target is specified, determine GOARCH
ifeq ($(UNAME_P),arm)
ifeq ($(MAKECMDGOALS), )
ifeq ($(origin GOARCH), undefined)
ENV += GOARCH=arm64
endif
endif
endif
ifeq ($(MAKECMDGOALS), linux)
# Redefine LDFLAGS to add the static part
LDFLAGS = -ldflags "-s -w \
-extldflags '-static' \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
# *** Darwin ***
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
STATIC_TARGET := macos
endif
# If no target is specified, determine GOARCH
ifeq ($(UNAME_P),arm)
ifeq ($(MAKECMDGOALS), )
ifeq ($(origin GOARCH), undefined)
ENV += GOARCH=arm64
endif
endif
endif
ifeq ($(MAKECMDGOALS), linux)
# Redefine LDFLAGS to add the static part
LDFLAGS = -ldflags "-s -w \
-extldflags '-static' \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
#
# Targets
#
.PHONY: default
default: clean validate-go-version
env -u GOOS -u GOARCH $(MAKE) GOOS= GOARCH= .downloaded_assets
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
# Allows you to build a CGO-free client for any target e.g. `GOOS=windows GOARCH=arm64 make client`
# NOTE: WireGuard is not supported on all platforms, but most 64-bit GOOS/GOARCH combinations should work.
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client ./client
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX).exe ./client
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-386$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=windows GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-386$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe ./client
GOOS=freebsd GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=freebsd GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX) ./client
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-amd64$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-amd64$(ARTIFACT_SUFFIX) ./server
.PHONY: pb
pb:
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor $(TAGS),server $(LDFLAGS_DEBUG) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor $(TAGS),client $(LDFLAGS_DEBUG) -o sliver-client$(ARTIFACT_SUFFIX) ./client
validate-go-version:
@if [ $(GO_MAJOR_VERSION) -gt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
exit 0 ;\
elif [ $(GO_MAJOR_VERSION) -lt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
elif [ $(GO_MINOR_VERSION) -lt $(MIN_SUPPORTED_GO_MINOR_VERSION) ] ; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
fi
.PHONY: clean-all
clean-all: clean
rm -rf ./server/assets/fs/darwin/amd64
rm -rf ./server/assets/fs/darwin/arm64
rm -rf ./server/assets/fs/windows/amd64
rm -rf ./server/assets/fs/linux/amd64
rm -f ./server/assets/fs/*.zip
rm -f ./.downloaded_assets
.PHONY: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
#
# Targets
# >>> WINDOWS <<<
#
.PHONY: default
default: clean validate-go-version
env -u GOOS -u GOARCH $(MAKE) GOOS= GOARCH= .downloaded_assets
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
else
# Allows you to build a CGO-free client for any target e.g. `GOOS=windows GOARCH=arm64 make client`
# NOTE: WireGuard is not supported on all platforms, but most 64-bit GOOS/GOARCH combinations should work.
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client ./client
SHELL := cmd.exe
.SHELLFLAGS := /C
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
GO_VERSION := $(patsubst go%,%,$(strip $(shell $(GO) env GOVERSION)))
GO_MAJOR_VERSION := $(word 1,$(subst ., ,$(GO_VERSION)))
GO_MINOR_VERSION := $(word 2,$(subst ., ,$(GO_VERSION)))
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
LDFLAGS = -ldflags "-s -w -X $(SLIVER_UPDATE_PKG).SliverPublicKey=$(SLIVER_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX).exe ./client
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-386$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=windows GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-386$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe ./client
GOOS=freebsd GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=freebsd GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX) ./client
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-amd64$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-amd64$(ARTIFACT_SUFFIX) ./server
.PHONY: pb
pb:
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor $(TAGS),server $(LDFLAGS_DEBUG) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor $(TAGS),client $(LDFLAGS_DEBUG) -o sliver-client$(ARTIFACT_SUFFIX) ./client
validate-go-version:
@if [ $(GO_MAJOR_VERSION) -gt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
exit 0 ;\
elif [ $(GO_MAJOR_VERSION) -lt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
elif [ $(GO_MINOR_VERSION) -lt $(MIN_SUPPORTED_GO_MINOR_VERSION) ] ; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
fi
.PHONY: clean-all
clean-all: clean
rm -rf ./server/assets/fs/darwin/amd64
rm -rf ./server/assets/fs/darwin/arm64
rm -rf ./server/assets/fs/windows/amd64
rm -rf ./server/assets/fs/linux/amd64
rm -f ./server/assets/fs/*.zip
rm -f ./.downloaded_assets
.PHONY: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
COMMA := ,
ifeq ($(MAKECMDGOALS), linux)
LDFLAGS = -ldflags "-s -w -extldflags '-static' -X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
define windows_exec
$(strip $(foreach envvar,$(1),set "$(envvar)" && ))$(2)
endef
define windows_go_build
$(call windows_exec,$(ENV) GOOS=$(1) GOARCH=$(2) CGO_ENABLED=$(3),"$(GO)" build $(4) $(TAGS)$(COMMA)$(5) $(6) -o $(7) ./$(5))
endef
.PHONY: default
default: clean validate-go-version
$(call windows_exec,$(ENV) GOOS= GOARCH=,"$(MAKE)" GOOS= GOARCH= .downloaded_assets)
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client)
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,arm64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,linux,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
$(call windows_go_build,linux,arm64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,windows,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX).exe)
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_macos-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_macos-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,386,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-386$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,windows,386,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-386$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,freebsd,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,freebsd,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX))
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_linux-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_linux-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_darwin-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_darwin-amd64$(ARTIFACT_SUFFIX))
.PHONY: pb
pb: validate-pb-compilers
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor,server,$(LDFLAGS_DEBUG),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor,client,$(LDFLAGS_DEBUG),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: validate-pb-compilers
validate-pb-compilers:
@for %%P in ($(PB_COMPILERS)) do @where.exe %%P >NUL || (echo Missing protobuf util %%P in PATH & exit /b 1)
validate-go-version:
@if $(GO_MAJOR_VERSION) GTR $(MIN_SUPPORTED_GO_MAJOR_VERSION) (exit /b 0) else if $(GO_MAJOR_VERSION) LSS $(MIN_SUPPORTED_GO_MAJOR_VERSION) (echo $(GO_VERSION_VALIDATION_ERR_MSG) & exit /b 1) else if $(GO_MINOR_VERSION) LSS $(MIN_SUPPORTED_GO_MINOR_VERSION) (echo $(GO_VERSION_VALIDATION_ERR_MSG) & exit /b 1)
.PHONY: clean-all
clean-all: clean
-rmdir /S /Q server\assets\fs\darwin\amd64
-rmdir /S /Q server\assets\fs\darwin\arm64
-rmdir /S /Q server\assets\fs\windows\amd64
-rmdir /S /Q server\assets\fs\linux\amd64
-del /Q /F server\assets\fs\*.zip 2>NUL
-del /Q /F .downloaded_assets 2>NUL
.PHONY: clean
clean:
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
.downloaded_assets:
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
@type NUL > .downloaded_assets
endif
+17 -7
View File
@@ -34,15 +34,25 @@ const (
ConfigDirName = "configs"
)
// ClientWGConfig - Optional WireGuard wrapper configuration for multiplayer.
type ClientWGConfig struct {
ServerPubKey string `json:"server_pub_key"`
ClientPrivateKey string `json:"client_private_key"`
ClientPubKey string `json:"client_pub_key"`
ClientIP string `json:"client_ip"`
ServerIP string `json:"server_ip"`
}
// ClientConfig - Client JSON config
type ClientConfig struct {
Operator string `json:"operator"` // This value is actually ignored for the most part (cert CN is used instead)
LHost string `json:"lhost"`
LPort int `json:"lport"`
Token string `json:"token"`
CACertificate string `json:"ca_certificate"`
PrivateKey string `json:"private_key"`
Certificate string `json:"certificate"`
Operator string `json:"operator"` // This value is actually ignored for the most part (cert CN is used instead)
LHost string `json:"lhost"`
LPort int `json:"lport"`
Token string `json:"token"`
CACertificate string `json:"ca_certificate"`
PrivateKey string `json:"private_key"`
Certificate string `json:"certificate"`
WG *ClientWGConfig `json:"wg,omitempty"`
}
// GetConfigDir - Returns the path to the config dir
+1
View File
@@ -52,6 +52,7 @@ func init() {
rootCmd.TraverseChildren = true
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
rootCmd.PersistentFlags().Bool(disableWGFlag, false, "connect to multiplayer directly even if the operator config includes a WireGuard wrapper")
// Create the console client, without any RPC or commands bound to it yet.
// This created before anything so that multiple commands can make use of
+177
View File
@@ -0,0 +1,177 @@
package cli
import (
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
bspinner "charm.land/bubbles/v2/spinner"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
const (
minConnectSpinnerDuration = 900 * time.Millisecond
minConnectStatusDuration = 350 * time.Millisecond
)
type connectResult struct {
rpc rpcpb.SliverRPCClient
conn *grpc.ClientConn
err error
}
func connectWithSpinner(out io.Writer, target string, connect func(transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error)) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusCh := make(chan string, 8)
resultCh := make(chan connectResult, 1)
go func() {
rpc, conn, err := connect(func(status string) {
sendStatus(statusCh, status)
})
resultCh <- connectResult{rpc: rpc, conn: conn, err: err}
}()
currentStatus := ""
currentStatusSince := time.Time{}
queuedStatus := ""
lastWidth := 0
spinner := bspinner.New(bspinner.WithSpinner(bspinner.Line))
ticker := time.NewTicker(spinner.Spinner.FPS)
defer ticker.Stop()
startedAt := time.Now()
var pendingResult *connectResult
setStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" || status == currentStatus {
return
}
currentStatus = status
currentStatusSince = time.Now()
}
stageStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
if currentStatus == "" || time.Since(currentStatusSince) >= minConnectStatusDuration {
queuedStatus = ""
setStatus(status)
return
}
queuedStatus = status
}
flushQueuedStatus := func() {
if queuedStatus == "" || time.Since(currentStatusSince) < minConnectStatusDuration {
return
}
nextStatus := queuedStatus
queuedStatus = ""
setStatus(nextStatus)
}
canReturnPendingResult := func() bool {
if pendingResult == nil {
return false
}
if pendingResult.err != nil {
return true
}
if time.Since(startedAt) < minConnectSpinnerDuration {
return false
}
if queuedStatus != "" {
return false
}
if !currentStatusSince.IsZero() && time.Since(currentStatusSince) < minConnectStatusDuration {
return false
}
return true
}
render := func() {
line := fmt.Sprintf("%s %s", spinner.View(), formatConnectSpinnerMessage(target, currentStatus))
lastWidth = writeSpinnerLine(out, line, lastWidth)
}
render()
for {
select {
case status := <-statusCh:
stageStatus(status)
spinner, _ = spinner.Update(spinner.Tick())
render()
case result := <-resultCh:
pendingResult = &result
case <-ticker.C:
spinner, _ = spinner.Update(spinner.Tick())
flushQueuedStatus()
render()
if canReturnPendingResult() {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
if pendingResult != nil && pendingResult.err != nil {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
}
func sendStatus(statusCh chan string, status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
statusCh <- status
}
func formatConnectSpinnerMessage(target string, status string) string {
target = strings.TrimSpace(target)
status = strings.TrimSpace(status)
if target == "" {
if status == "" {
return "Connecting ..."
}
return fmt.Sprintf("Connecting (%s) ...", status)
}
if status == "" {
return fmt.Sprintf("Connecting to %s ...", target)
}
return fmt.Sprintf("Connecting to %s (%s) ...", target, status)
}
func writeSpinnerLine(out io.Writer, line string, lastWidth int) int {
width := utf8.RuneCountInString(line)
padding := ""
if lastWidth > width {
padding = strings.Repeat(" ", lastWidth-width)
}
fmt.Fprintf(out, "\r%s%s", line, padding)
if width > lastWidth {
return width
}
return lastWidth
}
func clearSpinnerLine(out io.Writer, lastWidth int) {
if lastWidth <= 0 {
return
}
fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", lastWidth))
}
+132
View File
@@ -0,0 +1,132 @@
package cli
import (
"bytes"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
func TestFormatConnectSpinnerMessage(t *testing.T) {
tests := []struct {
name string
target string
status string
want string
}{
{
name: "target only",
target: "127.0.0.1:31337",
want: "Connecting to 127.0.0.1:31337 ...",
},
{
name: "target with status",
target: "127.0.0.1:31337",
status: "wireguard",
want: "Connecting to 127.0.0.1:31337 (wireguard) ...",
},
{
name: "status without target",
status: "grpc/mtls",
want: "Connecting (grpc/mtls) ...",
},
{
name: "trim whitespace",
target: " 127.0.0.1:31337 ",
status: " grpc/mtls over wireguard ",
want: "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatConnectSpinnerMessage(test.target, test.status); got != test.want {
t.Fatalf("expected %q, got %q", test.want, got)
}
})
}
}
func TestConnectWithSpinnerOutput(t *testing.T) {
var out bytes.Buffer
spinnerDelay := 2 * (100 * time.Millisecond)
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
time.Sleep(spinnerDelay)
statusFn("grpc/mtls over wireguard")
time.Sleep(spinnerDelay)
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected wireguard status in output, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected grpc over wireguard status in output, got %q", got)
}
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames in output, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("grpc/mtls")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337 (grpc/mtls) ...") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames for fast success, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
statusFn("grpc/mtls over wireguard")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected fast success output to include wireguard status, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected fast success output to include grpc over wireguard status, got %q", got)
}
if strings.Contains(got, "Connected to 127.0.0.1:31337") {
t.Fatalf("did not expect past-tense success output, got %q", got)
}
}
+14 -7
View File
@@ -20,6 +20,7 @@ package cli
import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command"
@@ -44,6 +45,9 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *cobra.Command, args []string) error) {
pre = func(cmd *cobra.Command, _ []string) error {
if err := applyMultiplayerConnectMode(cmd); err != nil {
return err
}
configs := assets.GetConfigs()
if len(configs) == 0 {
@@ -61,17 +65,20 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
return nil
}
// Don't clobber output when simply running an implant command from system shell.
if run {
fmt.Printf("Connecting to %s:%d ...\n", config.LHost, config.LPort)
}
target := fmt.Sprintf("%s:%d", config.LHost, config.LPort)
var rpc rpcpb.SliverRPCClient
var ln *grpc.ClientConn
rpc, ln, err = transport.MTLSConnect(config)
// Don't clobber output when simply running an implant command from system shell.
if run {
rpc, ln, err = connectWithSpinner(os.Stdout, target, func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
return transport.MTLSConnectWithStatus(config, statusFn)
})
} else {
rpc, ln, err = transport.MTLSConnect(config)
}
if err != nil {
fmt.Printf("Connection to server failed %s", err)
fmt.Printf("Connection to server failed %s\n", err)
return nil
}
return console.StartClient(con, rpc, ln, &console.ConnectionDetails{ConfigKey: configKey, Config: config}, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
+5 -1
View File
@@ -43,6 +43,10 @@ func mcpCmd(con *console.SliverClient) *cobra.Command {
}
func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
if err := applyMultiplayerConnectMode(cmd); err != nil {
return err
}
configPath, _ := cmd.Flags().GetString("config")
config, err := loadMCPClientConfig(configPath)
if err != nil {
@@ -55,7 +59,7 @@ func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
fmt.Printf("Connection to server failed %s\n", err)
return nil
}
defer ln.Close()
defer transport.CloseGRPCConnection(ln)
con.Rpc = rpc
go handleConnectionLost(ln)
+29
View File
@@ -0,0 +1,29 @@
package cli
import (
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
const (
disableWGFlag = "disable-wg"
)
func applyMultiplayerConnectMode(cmd *cobra.Command) error {
if cmd == nil {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
return nil
}
disableWG, err := cmd.Flags().GetBool(disableWGFlag)
if err != nil {
return err
}
mode := transport.MultiplayerConnectAuto
if disableWG {
mode = transport.MultiplayerConnectDisableWG
}
transport.SetMultiplayerConnectMode(mode)
return nil
}
+1
View File
@@ -12,6 +12,7 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
## Sub-packages
- `ai/` – Implements the 'ai' Bubble Tea layout preview command for the Sliver client console.
- `alias/` – Implements the 'alias' command group for the Sliver client console.
- `armory/` – Implements the 'armory' command group for the Sliver client console.
- `backdoor/` – Implements the 'backdoor' command group for the Sliver client console.
+5
View File
@@ -0,0 +1,5 @@
# client/command/ai
Implements the `ai` command for the Sliver client console.
The TUI loads server-backed AI conversation threads over gRPC, submits prompts to the server, and refreshes when `AIConversationEvent` updates arrive with assistant replies or failure messages.
+242
View File
@@ -0,0 +1,242 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/termio"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var openAIProgramTTY = tea.OpenTTY
// AICmd launches the AI conversation TUI.
func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
if con == nil || con.Rpc == nil {
if con != nil {
con.PrintErrorf("Connect to a server before using `ai`.\n")
}
return
}
listenerID, listener := con.CreateEventListener()
defer con.RemoveEventListener(listenerID)
restoreEventNotifications := con.SuppressEventNotifications()
defer restoreEventNotifications()
model := newAIModel(con, buildAIContext(con), listener)
model.showExperimentalWarningModal()
width, height := 100, 30
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
width, height = w, h
}
opts := []tea.ProgramOption{
tea.WithWindowSize(width, height),
tea.WithColorProfile(colorprofile.TrueColor),
}
ttyOpts, cleanup := configureAIProgramTTY()
if cleanup != nil {
defer cleanup()
}
opts = append(opts, ttyOpts...)
program := tea.NewProgram(model, opts...)
if _, err := program.Run(); err != nil {
con.PrintErrorf("AI TUI error: %s\n", err)
}
}
// Console logging can swap stdout/stderr to pipes so session output can be tee'd
// to disk. Bubble Tea needs a real TTY output to enable terminal features like
// mouse reporting, so bind the AI TUI back to the interactive terminal when
// stdout is no longer the controlling TTY.
func configureAIProgramTTY() ([]tea.ProgramOption, func()) {
stdinTTY := isAITTY(os.Stdin)
stdoutTTY := isAITTY(os.Stdout)
switch {
case !stdinTTY || stdoutTTY:
return nil, nil
case isAITTY(termio.InteractiveInput()) && isAITTY(termio.InteractiveOutput()):
return []tea.ProgramOption{
tea.WithInput(termio.InteractiveInput()),
tea.WithOutput(termio.InteractiveOutput()),
}, nil
default:
inTTY, outTTY, err := openAIProgramTTY()
if err != nil {
return nil, nil
}
return []tea.ProgramOption{
tea.WithInput(inTTY),
tea.WithOutput(outTTY),
}, func() {
_ = inTTY.Close()
if outTTY != inTTY {
_ = outTTY.Close()
}
}
}
}
func isAITTY(file *os.File) bool {
return file != nil && term.IsTerminal(int(file.Fd()))
}
type aiContext struct {
target aiTargetSummary
connection aiConnectionSummary
status string
}
type aiTargetSummary struct {
SessionID string
BeaconID string
Label string
Host string
OS string
Arch string
C2 string
Mode string
Details []string
}
type aiConnectionSummary struct {
Profile string
Server string
Operator string
State string
}
func defaultAITargetSummary() aiTargetSummary {
return aiTargetSummary{
Label: "No active target",
Host: "Select a session or beacon with `use` or ctrl+s",
Mode: "offline preview",
C2: "n/a",
OS: "unknown",
Arch: "unknown",
}
}
func aiSessionTargetSummary(session *clientpb.Session) aiTargetSummary {
if session == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
SessionID: session.ID,
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
Host: fallback(session.Hostname, "<unknown host>"),
OS: fallback(session.OS, "unknown"),
Arch: fallback(session.Arch, "unknown"),
C2: fallback(session.ActiveC2, "unknown"),
Mode: "interactive session",
Details: []string{
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
fmt.Sprintf("PID: %d", session.PID),
fmt.Sprintf("Remote: %s", fallback(session.RemoteAddress, "<unknown>")),
},
}
}
func aiBeaconTargetSummary(beacon *clientpb.Beacon) aiTargetSummary {
if beacon == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
BeaconID: beacon.ID,
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
Host: fallback(beacon.Hostname, "<unknown host>"),
OS: fallback(beacon.OS, "unknown"),
Arch: fallback(beacon.Arch, "unknown"),
C2: fallback(beacon.ActiveC2, "unknown"),
Mode: "asynchronous beacon",
Details: []string{
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
fmt.Sprintf("PID: %d", beacon.PID),
fmt.Sprintf("Remote: %s", fallback(beacon.RemoteAddress, "<unknown>")),
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
fmt.Sprintf("Next checkin: %s", formatUnix(beacon.NextCheckin)),
},
}
}
func buildAIContext(con *console.SliverClient) aiContext {
ctx := aiContext{
target: defaultAITargetSummary(),
connection: aiConnectionSummary{
Profile: "<disconnected>",
Server: "<unknown>",
Operator: "<unknown>",
State: "idle",
},
status: "Loading AI conversations from the server...",
}
if con != nil {
if details, state, ok := con.CurrentConnection(); ok {
ctx.connection.State = strings.ToLower(state.String())
if details != nil && details.Config != nil {
ctx.connection.Profile = fallback(details.ConfigKey, "<profile unavailable>")
ctx.connection.Server = fmt.Sprintf("%s:%d", details.Config.LHost, details.Config.LPort)
ctx.connection.Operator = fallback(details.Config.Operator, "<unknown>")
}
}
session, beacon := con.ActiveTarget.Get()
switch {
case session != nil:
ctx.target = aiSessionTargetSummary(session)
case beacon != nil:
ctx.target = aiBeaconTargetSummary(beacon)
}
}
return ctx
}
func fallback(value, def string) string {
value = strings.TrimSpace(value)
if value == "" {
return def
}
return value
}
func formatUnix(ts int64) string {
if ts <= 0 {
return "<unknown>"
}
return time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05")
}
+49
View File
@@ -0,0 +1,49 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
// Commands returns the ai command.
func Commands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newAICommand(consts.SliverCoreHelpGroup, con)}
}
// ServerCommands returns the ai command for the top-level client REPL.
func ServerCommands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newAICommand(consts.GenericHelpGroup, con)}
}
func newAICommand(groupID string, con *console.SliverClient) *cobra.Command {
return &cobra.Command{
Use: consts.AIStr,
Short: "Open the Sliver AI conversation TUI",
Long: help.GetHelpFor([]string{consts.AIStr}),
Args: cobra.NoArgs,
GroupID: groupID,
Run: func(cmd *cobra.Command, args []string) {
AICmd(cmd, con, args)
},
}
}
+591
View File
@@ -0,0 +1,591 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"context"
"net"
"sync"
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/proto"
)
func TestLoadAIStateCmdLoadsConversationOverRPC(t *testing.T) {
server := &aiRPCServer{
providersResp: &clientpb.AIProviderConfigs{
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
Config: &clientpb.AIConfigSummary{
Provider: "openai",
Model: "gpt-test",
Valid: true,
},
},
conversationsResp: &clientpb.AIConversations{
Conversations: []*clientpb.AIConversation{
{ID: "conv-1", Provider: "openai", Model: "gpt-test", Title: "First"},
{ID: "conv-2", Provider: "openai", Model: "gpt-test", Title: "Second"},
},
},
conversationByID: map[string]*clientpb.AIConversation{
"conv-1": {
ID: "conv-1",
Provider: "openai",
Model: "gpt-test",
Title: "First",
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Hello"},
},
},
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")
msg := cmd()
loaded, ok := msg.(aiLoadedMsg)
if !ok {
t.Fatalf("expected aiLoadedMsg, got %T", msg)
}
if loaded.selectedID != "conv-1" {
t.Fatalf("unexpected selected conversation id: got=%q want=%q", loaded.selectedID, "conv-1")
}
if loaded.conversation == nil || loaded.conversation.GetID() != "conv-1" {
t.Fatalf("unexpected loaded conversation: %+v", loaded.conversation)
}
if len(loaded.conversations) != 2 {
t.Fatalf("unexpected conversation count: got=%d want=%d", len(loaded.conversations), 2)
}
server.mu.Lock()
defer server.mu.Unlock()
if server.getProvidersCalls != 1 {
t.Fatalf("expected GetAIProviders to be called once, got %d", server.getProvidersCalls)
}
if server.getConversationsCalls != 1 {
t.Fatalf("expected GetAIConversations to be called once, got %d", server.getConversationsCalls)
}
if len(server.getConversationReqs) != 1 {
t.Fatalf("expected GetAIConversation to be called once, got %d", len(server.getConversationReqs))
}
if server.getConversationReqs[0].GetID() != "conv-1" || !server.getConversationReqs[0].GetIncludeMessages() {
t.Fatalf("unexpected GetAIConversation request: %+v", server.getConversationReqs[0])
}
}
func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
server := &aiRPCServer{
providersResp: &clientpb.AIProviderConfigs{
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
Config: &clientpb.AIConfigSummary{
Provider: "openai",
Model: "gpt-test",
SystemPrompt: "Stay concise.",
Valid: true,
},
},
conversationsResp: &clientpb.AIConversations{},
saveConversationResp: &clientpb.AIConversation{
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
conversationByID: map[string]*clientpb.AIConversation{
"conv-created": {
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")()
loaded, ok := msg.(aiLoadedMsg)
if !ok {
t.Fatalf("expected aiLoadedMsg, got %T", msg)
}
if loaded.status != "Created a new AI conversation." {
t.Fatalf("unexpected status: %q", loaded.status)
}
if loaded.selectedID != "conv-created" {
t.Fatalf("unexpected selected conversation id: %q", loaded.selectedID)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 1 {
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
}
request := server.saveConversationReqs[0]
if request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetTitle() != "New conversation" {
t.Fatalf("unexpected SaveAIConversation request: %+v", request)
}
if request.GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected SaveAIConversation system prompt: %q", request.GetSystemPrompt())
}
}
func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
server := &aiRPCServer{
saveConversationResp: &clientpb.AIConversation{
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "Explain the workflow.",
},
saveMessageResp: &clientpb.AIConversationMessage{
ID: "msg-1",
ConversationID: "conv-created",
Role: "user",
Content: "Explain the workflow.\n\nWith details.",
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{SessionID: "session-1"},
nil,
"openai",
"gpt-test",
"Stay concise.",
"Explain the workflow.\n\nWith details.",
)()
submitted, ok := msg.(aiPromptSubmittedMsg)
if !ok {
t.Fatalf("expected aiPromptSubmittedMsg, got %T", msg)
}
if submitted.conversationID != "conv-created" {
t.Fatalf("unexpected conversation id: %q", submitted.conversationID)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 1 {
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
}
if server.saveConversationReqs[0].GetTitle() != "Explain the workflow." {
t.Fatalf("unexpected conversation title: %q", server.saveConversationReqs[0].GetTitle())
}
if server.saveConversationReqs[0].GetTargetSessionID() != "session-1" {
t.Fatalf("expected target session to be forwarded, got %+v", server.saveConversationReqs[0])
}
if server.saveConversationReqs[0].GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected conversation system prompt: %q", server.saveConversationReqs[0].GetSystemPrompt())
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
request := server.saveMessageReqs[0]
if request.GetConversationID() != "conv-created" || request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetRole() != "user" {
t.Fatalf("unexpected SaveAIConversationMessage request: %+v", request)
}
if request.GetContent() != "Explain the workflow.\n\nWith details." {
t.Fatalf("unexpected prompt content: %q", request.GetContent())
}
}
func TestSubmitPromptCmdUsesExistingConversationSettings(t *testing.T) {
server := &aiRPCServer{
saveMessageResp: &clientpb.AIConversationMessage{
ID: "msg-1",
ConversationID: "conv-1",
Role: "user",
Content: "hello",
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{},
&clientpb.AIConversation{ID: "conv-1", Provider: "anthropic", Model: "claude-test", Title: "Thread"},
"openai",
"gpt-test",
"Stay concise.",
"hello",
)()
if _, ok := msg.(aiPromptSubmittedMsg); !ok {
t.Fatalf("expected aiPromptSubmittedMsg, got %T", msg)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 0 {
t.Fatalf("did not expect SaveAIConversation to be called, got %d calls", len(server.saveConversationReqs))
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
request := server.saveMessageReqs[0]
if request.GetProvider() != "anthropic" || request.GetModel() != "claude-test" {
t.Fatalf("unexpected conversation settings in message request: %+v", request)
}
}
func TestDeleteConversationCmdSendsDeleteRequest(t *testing.T) {
server := &aiRPCServer{}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := deleteConversationCmd(
&console.SliverClient{Rpc: rpcClient},
"conv-1",
"conv-2",
`Deleted "Thread".`,
)()
deleted, ok := msg.(aiConversationDeletedMsg)
if !ok {
t.Fatalf("expected aiConversationDeletedMsg, got %T", msg)
}
if deleted.conversationID != "conv-1" || deleted.selectedID != "conv-2" {
t.Fatalf("unexpected delete message: %+v", deleted)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.deleteConversationReqs) != 1 {
t.Fatalf("expected DeleteAIConversation to be called once, got %d", len(server.deleteConversationReqs))
}
if server.deleteConversationReqs[0].GetID() != "conv-1" {
t.Fatalf("unexpected DeleteAIConversation request: %+v", server.deleteConversationReqs[0])
}
}
func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
eventPayload := &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-1",
OperatorName: "alice",
},
}
data, err := proto.Marshal(eventPayload)
if err != nil {
t.Fatalf("marshal conversation event: %v", err)
}
listener := make(chan *clientpb.Event, 2)
listener <- &clientpb.Event{EventType: consts.WatchtowerEvent}
listener <- &clientpb.Event{EventType: consts.AIConversationEvent, Data: data}
msg := waitForAIConversationEventCmd(listener)()
aiEvent, ok := msg.(aiConversationEventMsg)
if !ok {
t.Fatalf("expected aiConversationEventMsg, got %T", msg)
}
if aiEvent.event == nil || aiEvent.event.GetConversation() == nil || aiEvent.event.GetConversation().GetID() != "conv-1" {
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.event)
}
}
func TestWaitForAIConversationEventCmdReturnsToastMessages(t *testing.T) {
listener := make(chan *clientpb.Event, 1)
listener <- &clientpb.Event{
EventType: consts.ClientToastEvent,
Data: []byte("Session alpha connected"),
Err: "info",
}
msg := waitForAIConversationEventCmd(listener)()
toast, ok := msg.(aiToastMsg)
if !ok {
t.Fatalf("expected aiToastMsg, got %T", msg)
}
if toast.level != "info" || toast.message != "Session alpha connected" {
t.Fatalf("unexpected toast payload: %+v", toast)
}
}
func TestWaitForAIConversationEventCmdReturnsClosedMessage(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
msg := waitForAIConversationEventCmd(listener)()
if _, ok := msg.(aiListenerClosedMsg); !ok {
t.Fatalf("expected aiListenerClosedMsg, got %T", msg)
}
}
func TestAIModelAppliesSharedConversationEventsWithoutReload(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
model := newAIModel(nil, aiContext{
connection: aiConnectionSummary{Operator: "alice"},
}, listener)
model.loading = false
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "First"}}
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "bob",
Title: "Shared thread",
},
}})
nextModel := updated.(*aiModel)
if nextModel.loading {
t.Fatal("did not expect shared conversation event to trigger a reload")
}
if conversationIndexByID(nextModel.conversations, "conv-2") < 0 {
t.Fatalf("expected shared conversation to be merged locally, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected shared conversation event to continue listening for events")
}
}
func TestAIModelAppliesDeleteEventsWhileAwaitingResponse(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
model := newAIModel(nil, aiContext{
connection: aiConnectionSummary{Operator: "alice"},
}, listener)
model.loading = false
model.awaitingResponse = true
model.currentConversation = &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "alice",
UpdatedAt: 100,
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "still waiting"},
},
}
model.conversations = []*clientpb.AIConversation{
{ID: "conv-2", Title: "Current"},
{ID: "conv-1", Title: "Fallback"},
}
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
ConversationID: "conv-2",
Conversation: &clientpb.AIConversation{
ID: "conv-2",
},
}})
nextModel := updated.(*aiModel)
if nextModel.loading {
t.Fatal("did not expect delete tombstone to trigger a reload")
}
if nextModel.currentConversation != nil {
t.Fatalf("expected deleted conversation to be cleared, got %+v", nextModel.currentConversation)
}
if conversationIndexByID(nextModel.conversations, "conv-2") >= 0 {
t.Fatalf("expected deleted conversation to be removed, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected delete tombstone to continue listening for events")
}
}
type aiRPCServer struct {
rpcpb.UnimplementedSliverRPCServer
mu sync.Mutex
providersResp *clientpb.AIProviderConfigs
conversationsResp *clientpb.AIConversations
conversationByID map[string]*clientpb.AIConversation
saveConversationResp *clientpb.AIConversation
saveMessageResp *clientpb.AIConversationMessage
sessionsResp *clientpb.Sessions
beaconsResp *clientpb.Beacons
getProvidersCalls int
getConversationsCalls int
getSessionsCalls int
getBeaconsCalls int
getConversationReqs []*clientpb.AIConversationReq
deleteConversationReqs []*clientpb.AIConversationReq
saveConversationReqs []*clientpb.AIConversation
saveMessageReqs []*clientpb.AIConversationMessage
getConversationStart chan struct{}
getConversationWait <-chan struct{}
saveMessageStarted chan struct{}
saveMessageRelease <-chan struct{}
}
func (s *aiRPCServer) GetAIProviders(context.Context, *commonpb.Empty) (*clientpb.AIProviderConfigs, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getProvidersCalls++
if s.providersResp == nil {
return &clientpb.AIProviderConfigs{}, nil
}
return proto.Clone(s.providersResp).(*clientpb.AIProviderConfigs), nil
}
func (s *aiRPCServer) GetAIConversations(context.Context, *commonpb.Empty) (*clientpb.AIConversations, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getConversationsCalls++
if s.conversationsResp == nil {
return &clientpb.AIConversations{}, nil
}
return proto.Clone(s.conversationsResp).(*clientpb.AIConversations), nil
}
func (s *aiRPCServer) GetSessions(context.Context, *commonpb.Empty) (*clientpb.Sessions, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getSessionsCalls++
if s.sessionsResp == nil {
return &clientpb.Sessions{}, nil
}
return proto.Clone(s.sessionsResp).(*clientpb.Sessions), nil
}
func (s *aiRPCServer) GetBeacons(context.Context, *commonpb.Empty) (*clientpb.Beacons, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getBeaconsCalls++
if s.beaconsResp == nil {
return &clientpb.Beacons{}, nil
}
return proto.Clone(s.beaconsResp).(*clientpb.Beacons), nil
}
func (s *aiRPCServer) GetAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*clientpb.AIConversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getConversationReqs = append(s.getConversationReqs, proto.Clone(req).(*clientpb.AIConversationReq))
if s.getConversationStart != nil {
select {
case s.getConversationStart <- struct{}{}:
default:
}
}
if s.getConversationWait != nil {
s.mu.Unlock()
<-s.getConversationWait
s.mu.Lock()
}
if s.conversationByID == nil {
return &clientpb.AIConversation{}, nil
}
conversation := s.conversationByID[req.GetID()]
if conversation == nil {
return &clientpb.AIConversation{}, nil
}
return proto.Clone(conversation).(*clientpb.AIConversation), nil
}
func (s *aiRPCServer) SaveAIConversation(_ context.Context, req *clientpb.AIConversation) (*clientpb.AIConversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saveConversationReqs = append(s.saveConversationReqs, proto.Clone(req).(*clientpb.AIConversation))
if s.saveConversationResp == nil {
return proto.Clone(req).(*clientpb.AIConversation), nil
}
return proto.Clone(s.saveConversationResp).(*clientpb.AIConversation), nil
}
func (s *aiRPCServer) DeleteAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*commonpb.Empty, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.deleteConversationReqs = append(s.deleteConversationReqs, proto.Clone(req).(*clientpb.AIConversationReq))
return &commonpb.Empty{}, nil
}
func (s *aiRPCServer) SaveAIConversationMessage(_ context.Context, req *clientpb.AIConversationMessage) (*clientpb.AIConversationMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saveMessageReqs = append(s.saveMessageReqs, proto.Clone(req).(*clientpb.AIConversationMessage))
if s.saveMessageStarted != nil {
select {
case s.saveMessageStarted <- struct{}{}:
default:
}
}
if s.saveMessageRelease != nil {
s.mu.Unlock()
<-s.saveMessageRelease
s.mu.Lock()
}
if s.saveMessageResp == nil {
return proto.Clone(req).(*clientpb.AIConversationMessage), nil
}
return proto.Clone(s.saveMessageResp).(*clientpb.AIConversationMessage), nil
}
func newAITestRPCClient(t *testing.T, srv rpcpb.SliverRPCServer) (rpcpb.SliverRPCClient, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
grpcServer := grpc.NewServer()
rpcpb.RegisterSliverRPCServer(grpcServer, srv)
go func() {
_ = grpcServer.Serve(listener)
}()
dialer := func(context.Context, string) (net.Conn, error) { return listener.Dial() }
conn, err := grpc.DialContext(context.Background(), "bufnet",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
grpcServer.Stop()
_ = listener.Close()
t.Fatalf("dial bufconn: %v", err)
}
cleanup := func() {
_ = conn.Close()
grpcServer.Stop()
_ = listener.Close()
}
return rpcpb.NewSliverRPCClient(conn), cleanup
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -250,7 +250,7 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
f.StringP("process-arguments", "A", "", "arguments to pass to the hosting process")
f.Uint32P("ppid", "P", 0, "parent process ID to use when creating the hosting process (Windows only)")
f.BoolP("save", "s", false, "Save output to disk")
f.IntP("timeout", "t", defaultTimeout, "command timeout in seconds")
f.Int64P("timeout", "t", defaultTimeout, "command timeout in seconds")
addAliasCmd.Flags().AddFlagSet(f)
// Setup completions for alias arguments
@@ -400,7 +400,13 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
var outFilePath *os.File
if save, _ := cmd.Flags().GetBool("save"); save {
outFile := filepath.Base(fmt.Sprintf("%s_%s*.log", filepath.Base(cmd.Name()), filepath.Base(session.GetHostname())))
hostname := ""
if session != nil {
hostname = session.GetHostname()
} else if beacon != nil {
hostname = beacon.GetHostname()
}
outFile := filepath.Base(fmt.Sprintf("%s_%s*.log", filepath.Base(cmd.Name()), filepath.Base(hostname)))
outFilePath, err = os.CreateTemp("", outFile)
if err != nil {
con.PrintErrorf("%s\n", err)
+18
View File
@@ -1,5 +1,23 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
+18
View File
@@ -1,5 +1,23 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"os"
+1 -1
View File
@@ -28,7 +28,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
},
}
flags.Bind("beacons", true, beaconsCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("beacons", false, beaconsCmd, func(f *pflag.FlagSet) {
f.StringP("kill", "k", "", "kill the designated beacon")
+31
View File
@@ -0,0 +1,31 @@
package docs
import (
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
// Commands returns the docs command.
func Commands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newDocsCommand(consts.SliverCoreHelpGroup, con)}
}
// ServerCommands returns the docs command for the top-level client REPL.
func ServerCommands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newDocsCommand(consts.GenericHelpGroup, con)}
}
func newDocsCommand(groupID string, con *console.SliverClient) *cobra.Command {
return &cobra.Command{
Use: consts.DocsStr,
Short: "Browse the embedded Sliver docs in a TUI",
Long: help.GetHelpFor([]string{consts.DocsStr}),
Args: cobra.NoArgs,
GroupID: groupID,
Run: func(cmd *cobra.Command, args []string) {
DocsCmd(cmd, con, args)
},
}
}
+36
View File
@@ -0,0 +1,36 @@
package docs
import (
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/client/console"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// DocsCmd launches the embedded docs browser TUI.
func DocsCmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
entries, err := loadDocEntries()
if err != nil {
if con != nil {
con.PrintErrorf("Failed to load embedded docs: %s\n", err)
}
return
}
model := newDocsModel(entries)
width, height := 100, 32
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
width, height = w, h
}
program := tea.NewProgram(
model,
tea.WithWindowSize(width, height),
tea.WithColorProfile(colorprofile.TrueColor),
)
if _, err := program.Run(); err != nil && con != nil {
con.PrintErrorf("Docs TUI error: %s\n", err)
}
}
File diff suppressed because it is too large. Load diff
+199
View File
@@ -0,0 +1,199 @@
package docs
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
func sampleDocs() []docEntry {
return []docEntry{
{
Name: "Getting Started",
Content: "# Getting Started\n\n- first step\n- second step",
Description: "Getting Started",
},
{
Name: "Operators",
Content: "# Operators\n\nTeam workflows",
Description: "Team workflows",
},
{
Name: "Networking",
Content: "# Networking\n\nTransport details",
Description: "Transport details",
},
}
}
func TestRenderMarkdownWithGlowRendersBulletLists(t *testing.T) {
rendered, err := renderMarkdownWithGlow(48, "# Title\n\n- first item")
if err != nil {
t.Fatalf("renderMarkdownWithGlow(): %v", err)
}
plain := ansi.Strip(rendered)
if !strings.Contains(plain, "Title") {
t.Fatalf("expected heading text, got %q", plain)
}
if !strings.Contains(plain, "• first item") {
t.Fatalf("expected glow-style bullet rendering, got %q", plain)
}
}
func TestSummarizeMarkdownPrefersFirstMeaningfulLine(t *testing.T) {
summary := summarizeMarkdown("# Title\n\n- Bullet\n\nParagraph")
if summary != "Title" {
t.Fatalf("expected first heading summary, got %q", summary)
}
}
func TestSummarizeMarkdownStripsMarkdownAndUnicodeSymbols(t *testing.T) {
summary := summarizeMarkdown("⚠️ **IMPORTANT:** This content was NOT created by the Sliver authors.")
if summary != "IMPORTANT: This content was NOT created by the Sliver authors." {
t.Fatalf("expected sanitized browser summary, got %q", summary)
}
}
func TestDocsModelDefaultsToGettingStarted(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 32)
if model.currentDocName != "Getting Started" {
t.Fatalf("expected Getting Started to be selected, got %q", model.currentDocName)
}
plain := ansi.Strip(model.viewer.View())
if !strings.Contains(plain, "first step") {
t.Fatalf("expected initial viewer content to render selected doc, got %q", plain)
}
}
func TestDocsModelSlashFocusesBrowserAndStartsFiltering(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 32)
model.focus = docsFocusViewer
updated, cmd := model.Update(tea.KeyPressMsg{Text: "/", Code: '/'})
if cmd == nil {
t.Fatal("expected slash to focus the search input")
}
updatedModel := updated.(*docsModel)
if updatedModel.focus != docsFocusBrowser {
t.Fatalf("expected slash to move focus to browser, got %v", updatedModel.focus)
}
if updatedModel.filterState != docsFilterStateFiltering {
t.Fatalf("expected slash to enter filtering state, got %v", updatedModel.filterState)
}
}
func TestDocsModelFilteringUpdatesVisibleEntries(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 32)
model.startFiltering()
model.browserFilter.SetValue("oper")
model.updateFilteredEntries(false)
visible := model.visibleEntries()
if len(visible) != 1 || visible[0].Name != "Operators" {
t.Fatalf("expected Operators to be the only filtered result, got %+v", visible)
}
if model.currentDocName != "Operators" {
t.Fatalf("expected filtering to sync selection to Operators, got %q", model.currentDocName)
}
}
func TestDocsModelEnterMovesFocusToViewer(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 32)
updated, _ := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
updatedModel := updated.(*docsModel)
if updatedModel.focus != docsFocusViewer {
t.Fatalf("expected enter to focus viewer, got %v", updatedModel.focus)
}
}
func TestDocsModelEscClearsAppliedFilter(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 32)
model.startFiltering()
model.browserFilter.SetValue("net")
model.finishFiltering(false)
updated, _ := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
updatedModel := updated.(*docsModel)
if updatedModel.filterState != docsFilterStateUnfiltered {
t.Fatalf("expected esc to clear the filter, got %v", updatedModel.filterState)
}
if len(updatedModel.visibleEntries()) != len(sampleDocs()) {
t.Fatalf("expected filter clear to restore all docs, got %d visible", len(updatedModel.visibleEntries()))
}
}
func TestDocsModelBrowserBodyPinsHelpToBottom(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 28)
body := ansi.Strip(model.renderBrowserBody(model.browserWidth, model.browserHeight))
lines := strings.Split(body, "\n")
if len(lines) != model.browserHeight {
t.Fatalf("expected browser body height %d, got %d lines", model.browserHeight, len(lines))
}
if !strings.Contains(lines[len(lines)-1], "q quit") {
t.Fatalf("expected help controls on the last browser line, got %q", lines[len(lines)-1])
}
}
func TestDocsWindowPollSchedulesResizeAndKeepsPolling(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(100, 28)
updated, cmd := model.Update(docsWindowPollMsg{width: 120, height: 34})
if cmd == nil {
t.Fatal("expected window poll to schedule resize handling")
}
updatedModel := updated.(*docsModel)
if updatedModel.width != 100 || updatedModel.height != 28 {
t.Fatalf("expected poll tick to leave dimensions unchanged until WindowSizeMsg, got %dx%d", updatedModel.width, updatedModel.height)
}
msg := cmd()
batch, ok := msg.(tea.BatchMsg)
if !ok {
t.Fatalf("expected poll tick to return tea.BatchMsg, got %T", msg)
}
if len(batch) != 2 {
t.Fatalf("expected poll tick batch size 2, got %d", len(batch))
}
var sawWindowSize bool
var sawPollTick bool
for _, subcmd := range batch {
if subcmd == nil {
continue
}
submsg := subcmd()
switch msg := submsg.(type) {
case tea.WindowSizeMsg:
sawWindowSize = true
if msg.Width != 120 || msg.Height != 34 {
t.Fatalf("expected resize 120x34, got %dx%d", msg.Width, msg.Height)
}
case docsWindowPollMsg:
sawPollTick = true
}
}
if !sawWindowSize {
t.Fatal("expected poll tick to emit WindowSizeMsg")
}
if !sawPollTick {
t.Fatal("expected poll tick to schedule another poll")
}
}
+14 -8
View File
@@ -3,6 +3,8 @@ package extensions
import (
"encoding/json"
"testing"
"github.com/bishopfox/sliver/util"
)
/*
@@ -114,6 +116,8 @@ const (
)
func TestParseExtensionManifest(t *testing.T) {
expectedPath := util.ResolvePath("foo/test1.dll")
extManifest, err := ParseExtensionManifest([]byte(sample1))
if err != nil {
t.Fatalf("Error parsing extension manifest: %s", err)
@@ -150,8 +154,8 @@ func TestParseExtensionManifest(t *testing.T) {
if extCmd.Files[0].Arch != "amd64" {
t.Errorf("Expected Arch 'amd64', got '%s'", extCmd.Files[0].Arch)
}
if extCmd.Files[0].Path != "/foo/test1.dll" {
t.Errorf("Expected path '/foo/test1.dll', got '%s'", extCmd.Files[0].Path)
if extCmd.Files[0].Path != expectedPath {
t.Errorf("Expected path '%s', got '%s'", expectedPath, extCmd.Files[0].Path)
}
}
@@ -178,14 +182,16 @@ func TestParseExtensionManifest(t *testing.T) {
if extManifest2.Files[0].Arch != "amd64" {
t.Errorf("Expected Arch 'amd64', got '%s'", extManifest2.Files[0].Arch)
}
if extManifest2.Files[0].Path != "/foo/test1.dll" {
t.Errorf("Expected path '/foo/test1.dll', got '%s'", extManifest2.Files[0].Path)
if extManifest2.Files[0].Path != expectedPath {
t.Errorf("Expected path '%s', got '%s'", expectedPath, extManifest2.Files[0].Path)
}
}
}
func TestParseMultipleCmdManifest(t *testing.T) {
expectedPath := util.ResolvePath("ex.dll")
mextManifest, err := ParseExtensionManifest([]byte(multicmd))
if err != nil {
t.Errorf("error parsing manifest: %s", err)
@@ -206,11 +212,11 @@ func TestParseMultipleCmdManifest(t *testing.T) {
if mextManifest.ExtCommand[1].Entrypoint != "Test2" {
t.Errorf("expected entrypoint Test2, got %s", mextManifest.ExtCommand[1].Entrypoint)
}
if mextManifest.ExtCommand[0].Files[0].Path != "/ex.dll" { //path cleaning adds a root path here? I am not sure if this should be a bug or not... works fine in prod
t.Errorf("expected path ex.dll, got %s", mextManifest.ExtCommand[0].Files[0].Path)
if mextManifest.ExtCommand[0].Files[0].Path != expectedPath { // path normalization is platform-specific
t.Errorf("expected path %s, got %s", expectedPath, mextManifest.ExtCommand[0].Files[0].Path)
}
if mextManifest.ExtCommand[1].Files[0].Path != "/ex.dll" { //path cleaning adds a root path here? I am not sure if this should be a bug or not... works fine in prod
t.Errorf("expected path ex.dll, got %s", mextManifest.ExtCommand[0].Files[0].Path)
if mextManifest.ExtCommand[1].Files[0].Path != expectedPath { // path normalization is platform-specific
t.Errorf("expected path %s, got %s", expectedPath, mextManifest.ExtCommand[1].Files[0].Path)
}
//maybe some more? args?
}
+1 -1
View File
@@ -377,7 +377,7 @@ func ExtensionRegisterCommand(extCmd *ExtCommand, cmd *cobra.Command, con *conso
// Flags
f := pflag.NewFlagSet(extCmd.CommandName, pflag.ContinueOnError)
f.BoolP("save", "s", false, "Save output to disk")
f.IntP("timeout", "t", defaultTimeout, "command timeout in seconds")
f.Int64P("timeout", "t", defaultTimeout, "command timeout in seconds")
extensionCmd.Flags().AddFlagSet(f)
extensionCmd.Flags().ParseErrorsWhitelist.UnknownFlags = true
+8 -1
View File
@@ -36,7 +36,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
GroupID: consts.PayloadsHelpGroup,
}
flags.Bind("generate", true, generateCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
// Session flags and completions.
@@ -340,6 +340,13 @@ func coreImplantFlags(name string, cmd *cobra.Command) {
f.Uint32P("key-exchange", "X", DefaultWGKeyExPort, "wg key-exchange port")
f.Uint32P("tcp-comms", "T", DefaultWGNPort, "wg c2 comms port")
f.Bool("include-mtls", false, "force include mtls transport")
f.Bool("include-http", false, "force include http transport")
f.Bool("include-wg", false, "force include wireguard transport")
f.Bool("include-dns", false, "force include dns transport")
f.Bool("include-named-pipe", false, "force include named-pipe transport")
f.Bool("include-tcp-pivot", false, "force include tcp-pivot transport")
f.Bool("all-protocols", false, "force include all transport protocols")
f.BoolP("run-at-load", "R", false, "run the implant entrypoint from DllMain/Constructor (shared library only)")
f.BoolP("netgo", "q", false, "force the use of netgo")
+24
View File
@@ -483,6 +483,23 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
c2Profile = consts.DefaultC2Profile
}
allProtocols, _ := cmd.Flags().GetBool("all-protocols")
includeMTLS, _ := cmd.Flags().GetBool("include-mtls")
includeHTTP, _ := cmd.Flags().GetBool("include-http")
includeWG, _ := cmd.Flags().GetBool("include-wg")
includeDNS, _ := cmd.Flags().GetBool("include-dns")
includeNamedPipe, _ := cmd.Flags().GetBool("include-named-pipe")
includeTCP, _ := cmd.Flags().GetBool("include-tcp-pivot")
if allProtocols {
includeMTLS = true
includeHTTP = true
includeWG = true
includeDNS = true
includeNamedPipe = true
includeTCP = true
}
// exports if its a shared library
config := &clientpb.ImplantConfig{
@@ -527,6 +544,13 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
DebugFile: debugFile,
HTTPC2ConfigName: c2Profile,
ShellcodeConfig: shellcodeConfig,
IncludeMTLS: includeMTLS,
IncludeHTTP: includeHTTP,
IncludeWG: includeWG,
IncludeDNS: includeDNS,
IncludeNamePipe: includeNamedPipe,
IncludeTCP: includeTCP,
}
return name, config
+26
View File
@@ -56,6 +56,9 @@ var (
consts.LsStr: lsHelp,
consts.CdStr: cdHelp,
consts.PwdStr: pwdHelp,
consts.AIStr: aiHelp,
consts.AIConfigStr: aiConfigHelp,
consts.DocsStr: docsHelp,
consts.CatStr: catHelp,
consts.EditStr: editHelp,
consts.HexEditStr: hexEditHelp,
@@ -167,6 +170,29 @@ _=/usr/bin/env
infoHelp = `[[.Bold]]Command:[[.Normal]] info <sliver name/session>
[[.Bold]]About:[[.Normal]] Get information about a Sliver by name, or for the active Sliver.`
aiHelp = `[[.Bold]]Command:[[.Normal]] ai
[[.Bold]]About:[[.Normal]] Launch the Sliver AI Bubble Tea TUI. The AI workspace now loads server-side conversation threads over gRPC, keeps them synced through the shared event stream, and renders conversation transcripts as markdown in the main pane.
[[.Bold]]Example:[[.Normal]]
use my-session
ai
`
aiConfigHelp = `[[.Bold]]Command:[[.Normal]] ai-config
[[.Bold]]About:[[.Normal]] Open a server-side form to update the AI block in server.yaml. The form stores the default provider, model, thinking level, and provider-specific credentials such as the API key and optional base URL.
[[.Bold]]Example:[[.Normal]]
ai-config
`
docsHelp = `[[.Bold]]Command:[[.Normal]] docs
[[.Bold]]About:[[.Normal]] Browse the embedded Sliver documentation in a Bubble Tea TUI. The left pane is a searchable docs browser, and the right pane renders markdown with Glow styling.
[[.Bold]]Keys:[[.Normal]] Use / to filter docs, enter to focus the selected document, tab to switch panes, and q to quit.
[[.Bold]]Example:[[.Normal]]
docs
`
useHelp = `[[.Bold]]Command:[[.Normal]] use [sliver name/session]
[[.Bold]]About:[[.Normal]] Switch the active Sliver, a valid name must be provided (see sessions).`
+2 -2
View File
@@ -43,12 +43,12 @@ func Commands(con *console.SliverClient) []*cobra.Command {
GroupID: consts.NetworkHelpGroup,
}
flags.Bind("jobs", true, jobsCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("jobs", false, jobsCmd, func(f *pflag.FlagSet) {
f.Int32P("kill", "k", -1, "kill a background job")
f.BoolP("kill-all", "K", false, "kill all jobs")
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.BindFlagCompletions(jobsCmd, func(comp *carapace.ActionMap) {
(*comp)["kill"] = JobsIDCompleter(con)
+1 -1
View File
@@ -24,7 +24,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
GroupID: consts.SliverHelpGroup,
}
flags.Bind("loot", true, lootCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("loot", false, lootCmd, func(f *pflag.FlagSet) {
f.StringP("filter", "f", "", "filter based on loot type")
+1
View File
@@ -66,6 +66,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.Bind("", false, consoleCmd, func(f *pflag.FlagSet) {
f.String("transport", string(defaultConfig.Transport), "mcp transport (http or sse)")
f.String("url", "", "mcp server URL (defaults to local server settings)")
f.String("auth-token", "", "MCP auth token (defaults to the local mcp.yaml token)")
})
flags.BindFlagCompletions(consoleCmd, func(comp *carapace.ActionMap) {
(*comp)["transport"] = carapace.ActionValues("http", "sse")
+29 -4
View File
@@ -6,12 +6,14 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/bishopfox/sliver/client/console"
clientmcp "github.com/bishopfox/sliver/client/mcp"
"github.com/bishopfox/sliver/client/version"
mcpclient "github.com/mark3labs/mcp-go/client"
mcptransport "github.com/mark3labs/mcp-go/client/transport"
mcpapi "github.com/mark3labs/mcp-go/mcp"
"github.com/spf13/cobra"
)
@@ -36,7 +38,23 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
}
}
client, err := newConsoleClient(transport, targetURL)
authToken, _ := cmd.Flags().GetString("auth-token")
if authToken == "" {
status := clientmcp.GetStatus()
if status.AuthToken != "" {
authToken = status.AuthToken
} else {
authInfo, loadErr := clientmcp.LoadAuthInfo()
if loadErr == nil {
authToken = authInfo.Token
} else if !errors.Is(loadErr, os.ErrNotExist) {
con.PrintErrorf("%s\n", loadErr)
return
}
}
}
client, err := newConsoleClient(transport, targetURL, authToken)
if err != nil {
con.PrintErrorf("%s\n", err)
return
@@ -68,12 +86,19 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
runConsoleREPL(ctx, client, cmd.InOrStdin(), cmd.OutOrStdout())
}
func newConsoleClient(transport clientmcp.Transport, targetURL string) (*mcpclient.Client, error) {
func newConsoleClient(transport clientmcp.Transport, targetURL string, authToken string) (*mcpclient.Client, error) {
if strings.TrimSpace(authToken) == "" {
return nil, fmt.Errorf("mcp auth token is required (use --auth-token or start the local MCP server first)")
}
headers := map[string]string{
clientmcp.AuthHeaderName(): authToken,
}
switch transport {
case clientmcp.TransportHTTP:
return mcpclient.NewStreamableHttpClient(targetURL)
return mcpclient.NewStreamableHttpClient(targetURL, mcptransport.WithHTTPHeaders(headers))
case clientmcp.TransportSSE:
return mcpclient.NewSSEMCPClient(targetURL)
return mcpclient.NewSSEMCPClient(targetURL, mcpclient.WithHeaders(headers))
default:
return nil, fmt.Errorf("unsupported transport %q", transport)
}
+6
View File
@@ -26,6 +26,12 @@ func McpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
} else {
con.PrintErrorf("Endpoint: %s\n", err)
}
if status.AuthHeader != "" {
con.Printf("Auth Header: %s\n", status.AuthHeader)
}
if status.AuthToken != "" {
con.Printf("Auth Token: %s\n", status.AuthToken)
}
if status.Running && !status.StartedAt.IsZero() {
uptime := time.Since(status.StartedAt).Truncate(time.Second)
+7
View File
@@ -43,4 +43,11 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if err == nil {
con.PrintInfof("Endpoint: %s\n", endpoint)
}
status := slivermcp.GetStatus()
if status.AuthHeader != "" {
con.PrintInfof("Auth Header: %s\n", status.AuthHeader)
}
if status.AuthToken != "" {
con.PrintInfof("Auth Token: %s\n", status.AuthToken)
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
pivotsCmd.AddCommand(pivotDetailsCmd)
flags.Bind("", false, pivotDetailsCmd, func(f *pflag.FlagSet) {
f.IntP("id", "i", 0, "id of the pivot listener to get details for")
f.Uint32P("id", "i", 0, "id of the pivot listener to get details for")
})
flags.BindFlagCompletions(pivotDetailsCmd, func(comp *carapace.ActionMap) {
(*comp)["id"] = PivotIDCompleter(con)
+1
View File
@@ -39,6 +39,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.Bind("", false, addCmd, func(f *pflag.FlagSet) {
f.StringP("remote", "r", "", "remote target host:port (e.g., 10.0.0.1:445)")
f.StringP("bind", "b", "127.0.0.1:8080", "bind port forward to interface")
f.Int32P("keepalive", "k", 0, "keepalive period in seconds (default 30s, -1 to disable)")
})
flags.BindFlagCompletions(addCmd, func(comp *carapace.ActionMap) {
(*comp)["bind"] = completers.ClientInterfacesCompleter()
+11 -1
View File
@@ -68,13 +68,23 @@ func PortfwdAddCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
bindAddr = fmt.Sprintf("127.0.0.1:%s", bindAddr)
}
keepAlive, _ := cmd.Flags().GetInt32("keepalive")
var keepAlivePeriod time.Duration
if keepAlive > 0 {
keepAlivePeriod = time.Duration(keepAlive) * time.Second
} else if keepAlive < 0 {
keepAlivePeriod = -1 * time.Second
} else {
keepAlivePeriod = 30 * time.Second
}
tcpProxy := &tcpproxy.Proxy{}
channelProxy := &core.ChannelProxy{
Rpc: con.Rpc,
Session: session,
RemoteAddr: remoteAddr,
BindAddr: bindAddr,
KeepAlivePeriod: 60 * time.Second,
KeepAlivePeriod: keepAlivePeriod,
DialTimeout: 30 * time.Second,
}
tcpProxy.AddRoute(bindAddr, channelProxy)
+1 -1
View File
@@ -74,7 +74,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
GetSystemCmd(cmd, con, args)
},
GroupID: consts.PrivilegesHelpGroup,
Annotations: flags.RestrictTargets(consts.WindowsCmdsFilter),
Annotations: flags.RestrictTargets(consts.WindowsCmdsFilter, consts.SessionCmdsFilter),
}
flags.Bind("", false, getSystemCmd, func(f *pflag.FlagSet) {
f.StringP("process", "p", "spoolsv.exe", "SYSTEM process to inject into")
+8
View File
@@ -37,6 +37,10 @@ func GetSystemCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
if session == nil && beacon == nil {
return
}
if session == nil {
con.PrintErrorf("Command is only supported for sessions.\n")
return
}
targetOS := getOS(session, beacon)
if targetOS != "windows" {
con.PrintErrorf("Command only supported on Windows.\n")
@@ -45,6 +49,10 @@ func GetSystemCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
process, _ := cmd.Flags().GetString("process")
config := con.GetActiveSessionConfig()
if config == nil {
con.PrintErrorf("Failed to derive active session config.\n")
return
}
/* If the HTTP C2 Config name is not defined, then put in the default value
This will have no effect on implants that do not use HTTP C2
+1 -1
View File
@@ -52,7 +52,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.BoolP("loot", "X", false, "save output as loot")
f.StringP("loot-name", "N", "", "name to assign when adding the memory dump to the loot store (optional)")
f.Int32P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.BindFlagCompletions(procdumpCmd, func(comp *carapace.ActionMap) {
(*comp)["save"] = carapace.ActionFiles()
+2 -2
View File
@@ -55,7 +55,7 @@ func ProcdumpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
return
}
timeout, _ := cmd.Flags().GetInt32("timeout")
timeout, _ := cmd.Flags().GetInt64("timeout")
if timeout < 1 {
con.PrintErrorf("Invalid timeout argument\n")
@@ -68,7 +68,7 @@ func ProcdumpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
dump, err := con.Rpc.ProcessDump(context.Background(), &sliverpb.ProcessDumpReq{
Request: con.ActiveTarget.Request(cmd),
Pid: int32(pid),
Timeout: timeout,
Timeout: int32(timeout),
})
ctrl <- true
<-ctrl
+13 -4
View File
@@ -24,6 +24,7 @@ import (
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/spf13/cobra"
@@ -94,16 +95,24 @@ func ReconfigCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if activeC2 != "" {
currentURI, err := url.Parse(activeC2)
if err == nil {
// must be the same protocol
if newURI.Scheme != currentURI.Scheme {
con.PrintErrorf("Cannot switch protocol from %s to %s (protocol must match compiled implant)\n",
confirm := false
con.PrintWarnf("Switching protocol from %s to %s. This will only work if the protocol was included in the compiled implant. If it was NOT included, YOU WILL LOSE THE BEACON CONNECTION PERMANENTLY.\n",
currentURI.Scheme, newURI.Scheme)
return
_ = forms.Confirm("Do you want to continue?", &confirm)
if !confirm {
return
}
}
// for now only support the same Host/C2 Server (becuase the implant crypto keys problem)
// Warning: Switching to a different host typically fails because the implant and server must share the same crypto keys.
if newURI.Hostname() != currentURI.Hostname() {
con.PrintWarnf("Switching to a different host (%s -> %s). This only works if both servers share the same crypto keys.\n",
currentURI.Hostname(), newURI.Hostname())
confirm := false
_ = forms.Confirm("Do you want to continue?", &confirm)
if !confirm {
return
}
}
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
Annotations: flags.RestrictTargets(consts.WindowsCmdsFilter),
}
flags.Bind("registry", true, registryCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
registryReadCmd := &cobra.Command{
+1
View File
@@ -39,6 +39,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.Bind("", false, rportfwdAddCmd, func(f *pflag.FlagSet) {
f.StringP("remote", "r", "", "remote address <ip>:<port> connection is forwarded to")
f.StringP("bind", "b", "", "bind address <ip>:<port> for implants to listen on")
f.Int32P("keepalive", "k", 0, "keepalive period in seconds (default 30s, -1 to disable)")
})
flags.BindFlagCompletions(rportfwdAddCmd, func(comp *carapace.ActionMap) {
(*comp)["remote"] = completers.ClientInterfacesCompleter()
+3
View File
@@ -53,10 +53,13 @@ func StartRportFwdListenerCmd(cmd *cobra.Command, con *console.SliverClient, arg
if portNumberOnlyRegexp.MatchString(forwardAddress) {
forwardAddress = fmt.Sprintf("127.0.0.1:%s", forwardAddress)
}
keepAlive, _ := cmd.Flags().GetInt32("keepalive")
rportfwdListener, err := con.Rpc.StartRportFwdListener(context.Background(), &sliverpb.RportFwdStartListenerReq{
Request: con.ActiveTarget.Request(cmd),
BindAddress: bindAddress,
ForwardAddress: forwardAddress,
KeepAlive: keepAlive,
})
if err != nil {
con.PrintWarnf("%s\n", err)
+4
View File
@@ -22,6 +22,7 @@ import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/command/ai"
"github.com/bishopfox/sliver/client/command/aka"
"github.com/bishopfox/sliver/client/command/alias"
"github.com/bishopfox/sliver/client/command/armory"
@@ -32,6 +33,7 @@ import (
"github.com/bishopfox/sliver/client/command/clean"
"github.com/bishopfox/sliver/client/command/crack"
"github.com/bishopfox/sliver/client/command/creds"
docscmd "github.com/bishopfox/sliver/client/command/docs"
"github.com/bishopfox/sliver/client/command/exit"
"github.com/bishopfox/sliver/client/command/extensions"
"github.com/bishopfox/sliver/client/command/generate"
@@ -98,6 +100,8 @@ func ServerCommands(con *client.SliverClient, serverCmds func() []*cobra.Command
// Core
bind(consts.GenericHelpGroup,
exit.Command,
ai.ServerCommands,
docscmd.ServerCommands,
serverctx.Commands,
licenses.Commands,
settings.Commands,
+11 -11
View File
@@ -13,15 +13,15 @@ import (
"sync"
"time"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/console"
clienttheme "github.com/bishopfox/sliver/client/theme"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"google.golang.org/grpc/connectivity"
)
@@ -158,7 +158,7 @@ func serverSwitch(_ *cobra.Command, con *console.SliverClient) {
}
if err := con.SetConnection(rpc, conn, &console.ConnectionDetails{ConfigKey: inst.ConfigKey, Config: inst.Config}); err != nil {
_ = conn.Close()
_ = transport.CloseGRPCConnection(conn)
con.PrintErrorf("Switch failed: %s\n", err)
return
}
@@ -180,7 +180,7 @@ func serverSwitch(_ *cobra.Command, con *console.SliverClient) {
}
if err := con.SetConnection(rpc, conn, &console.ConnectionDetails{ConfigKey: result.ConfigKey, Config: cfg}); err != nil {
_ = conn.Close()
_ = transport.CloseGRPCConnection(conn)
con.PrintErrorf("Switch failed: %s\n", err)
return
}
@@ -347,7 +347,6 @@ type switchTUIModel struct {
result switchResult
width int
height int
theme *huh.Theme
styles switchStyles
activeID string
counts map[string]int
@@ -367,7 +366,7 @@ type switchStyles struct {
func runSwitchTUI(instances []*consoleInstance, activeID string, configKeys []string, counts map[string]int) (switchResult, error) {
m := newSwitchTUIModel(instances, activeID, configKeys, counts)
prog := tea.NewProgram(m, tea.WithAltScreen())
prog := tea.NewProgram(m)
finalModel, err := prog.Run()
if err != nil {
return switchResult{}, err
@@ -465,7 +464,6 @@ func newSwitchTUIModel(instances []*consoleInstance, activeID string, configKeys
tab: tabExisting,
existing: existingForm,
newConn: newForm,
theme: huhTheme,
styles: styles,
activeID: activeID,
counts: counts,
@@ -491,7 +489,7 @@ func (m switchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.newConn = modelB.(*huh.Form)
return m, tea.Batch(cmdA, cmdB)
case tea.KeyMsg:
case tea.KeyPressMsg:
switch msg.String() {
case "tab":
if m.tab == tabExisting {
@@ -548,7 +546,7 @@ func (m switchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m switchTUIModel) View() string {
func (m switchTUIModel) View() tea.View {
header := m.renderHeader()
body := ""
@@ -562,7 +560,9 @@ func (m switchTUIModel) View() string {
footer := m.renderFooter()
content := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
return m.styles.frame.Render(content)
view := tea.NewView(m.styles.frame.Render(content))
view.AltScreen = true
return view
}
func (m switchTUIModel) renderHeader() string {
+1 -1
View File
@@ -27,7 +27,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
GroupID: consts.SliverHelpGroup,
}
flags.Bind("sessions", true, sessionsCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("sessions", false, sessionsCmd, func(f *pflag.FlagSet) {
f.StringP("interact", "i", "", "interact with a session")
+5 -1
View File
@@ -22,16 +22,19 @@ import (
"fmt"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/ai"
"github.com/bishopfox/sliver/client/command/aka"
"github.com/bishopfox/sliver/client/command/alias"
"github.com/bishopfox/sliver/client/command/backdoor"
"github.com/bishopfox/sliver/client/command/cursed"
"github.com/bishopfox/sliver/client/command/dllhijack"
docscmd "github.com/bishopfox/sliver/client/command/docs"
"github.com/bishopfox/sliver/client/command/edit"
"github.com/bishopfox/sliver/client/command/environment"
"github.com/bishopfox/sliver/client/command/exec"
"github.com/bishopfox/sliver/client/command/extensions"
"github.com/bishopfox/sliver/client/command/filesystem"
"github.com/bishopfox/sliver/client/command/hexedit"
"github.com/bishopfox/sliver/client/command/info"
"github.com/bishopfox/sliver/client/command/kill"
"github.com/bishopfox/sliver/client/command/network"
@@ -49,7 +52,6 @@ import (
"github.com/bishopfox/sliver/client/command/tasks"
"github.com/bishopfox/sliver/client/command/wasm"
"github.com/bishopfox/sliver/client/command/wireguard"
"github.com/bishopfox/sliver/client/command/hexedit"
client "github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/reeflective/console"
@@ -78,6 +80,8 @@ func SliverCommands(con *client.SliverClient) console.Commands {
// [ Core ]
bind(consts.SliverCoreHelpGroup,
ai.Commands,
docscmd.Commands,
reconfig.Commands,
// sessions.Commands,
sessions.SliverCommands,
+1 -1
View File
@@ -23,7 +23,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
Annotations: flags.RestrictTargets(consts.BeaconCmdsFilter),
}
flags.Bind("tasks", true, tasksCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.BoolP("overflow", "O", false, "overflow terminal width (display truncated rows)")
f.IntP("skip-pages", "S", 0, "skip the first n page(s)")
f.StringP("filter", "f", "", "filter based on task type (case-insensitive prefix matching)")
+1 -1
View File
@@ -28,7 +28,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("save", "s", "", "save downloaded files to specific directory (default downloads dir)")
f.BoolP("insecure", "I", false, "skip tls certificate validation")
f.Bool("force", false, "skip version comparison and download the latest release")
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.BindFlagCompletions(updateCmd, func(comp *carapace.ActionMap) {
(*comp)["proxy"] = completers.LocalProxyCompleter()
+1 -1
View File
@@ -60,7 +60,7 @@ const (
func UpdateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
VerboseVersionsCmd(cmd, con, args)
timeoutF, _ := cmd.Flags().GetInt("timeout")
timeoutF, _ := cmd.Flags().GetInt64("timeout")
timeout := time.Duration(timeoutF) * time.Second
insecure, _ := cmd.Flags().GetBool("insecure")
+86 -18
View File
@@ -11,6 +11,7 @@ import (
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/core"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
@@ -45,6 +46,9 @@ func (con *SliverClient) SetConnection(rpc rpcpb.SliverRPCClient, grpcConn *grpc
con.Rpc = rpc
con.grpcConn = grpcConn
con.connDetails = details
con.backgroundRPC = nil
con.backgroundConn = nil
con.backgroundDedicated = false
if con.Rpc == nil || con.grpcConn == nil {
return nil
@@ -57,31 +61,49 @@ func (con *SliverClient) SetConnection(rpc rpcpb.SliverRPCClient, grpcConn *grpc
con.BeaconTaskCallbacksMutex.Unlock()
con.ActiveTarget.Set(nil, nil)
if details != nil && details.Config != nil && details.Config.WG != nil {
con.backgroundDedicated = true
commandRPC, commandConn, err := transport.MTLSConnect(details.Config)
if err != nil {
log.Printf("Dedicated command WireGuard connection unavailable, async console streams disabled to preserve command reliability: %v", err)
} else {
con.backgroundRPC = con.Rpc
con.backgroundConn = con.grpcConn
con.Rpc = commandRPC
con.grpcConn = commandConn
}
}
ctx, cancel := context.WithCancel(context.Background())
con.connCancel = cancel
wg := &sync.WaitGroup{}
con.connWg = wg
wg.Add(1)
go func(wg *sync.WaitGroup) {
defer wg.Done()
con.startEventLoop(ctx)
}(wg)
backgroundRPC := con.backgroundRPCClientLocked()
if backgroundRPC != nil {
wg.Add(1)
go func(wg *sync.WaitGroup, rpc rpcpb.SliverRPCClient) {
defer wg.Done()
con.startEventLoop(ctx, rpc)
}(wg, backgroundRPC)
wg.Add(1)
go func(wg *sync.WaitGroup) {
defer wg.Done()
if err := core.TunnelLoop(ctx, con.Rpc); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("TunnelLoop error: %v", err)
}
}(wg)
wg.Add(1)
go func(wg *sync.WaitGroup, rpc rpcpb.SliverRPCClient) {
defer wg.Done()
if err := core.TunnelLoop(ctx, rpc); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("TunnelLoop error: %v", err)
}
}(wg, backgroundRPC)
}
wg.Add(1)
go func(wg *sync.WaitGroup, conn *grpc.ClientConn) {
defer wg.Done()
con.monitorConnectionLost(ctx, conn)
}(wg, con.grpcConn)
if !con.backgroundDedicated || con.backgroundConn == nil {
wg.Add(1)
go func(wg *sync.WaitGroup, conn *grpc.ClientConn) {
defer wg.Done()
con.monitorConnectionLost(ctx, conn)
}(wg, con.grpcConn)
}
con.refreshRemoteLogStreamsLocked()
@@ -123,16 +145,62 @@ func (con *SliverClient) detachConnectionLocked() {
}
if con.grpcConn != nil {
_ = con.grpcConn.Close()
_ = transport.CloseGRPCConnection(con.grpcConn)
con.grpcConn = nil
}
if con.backgroundConn != nil {
_ = transport.CloseGRPCConnection(con.backgroundConn)
con.backgroundConn = nil
}
con.Rpc = nil
con.backgroundRPC = nil
con.backgroundDedicated = false
con.connDetails = nil
// Tear down any singleton network tooling that was tied to the previous server.
core.ResetClientState()
}
func (con *SliverClient) backgroundRPCClientLocked() rpcpb.SliverRPCClient {
if con.backgroundRPC != nil {
return con.backgroundRPC
}
if con.backgroundDedicated {
return nil
}
return con.Rpc
}
func (con *SliverClient) refreshDedicatedCommandConnectionHook(args []string) ([]string, error) {
if err := con.refreshDedicatedCommandConnection(); err != nil {
return args, err
}
return args, nil
}
func (con *SliverClient) refreshDedicatedCommandConnection() error {
con.connMu.Lock()
defer con.connMu.Unlock()
if !con.backgroundDedicated || con.backgroundConn == nil || con.connDetails == nil || con.connDetails.Config == nil {
return nil
}
rpc, conn, err := transport.MTLSConnect(con.connDetails.Config)
if err != nil {
return fmt.Errorf("refresh command connection: %w", err)
}
oldConn := con.grpcConn
con.Rpc = rpc
con.grpcConn = conn
if oldConn != nil {
_ = transport.CloseGRPCConnection(oldConn)
}
return nil
}
func (con *SliverClient) monitorConnectionLost(ctx context.Context, conn *grpc.ClientConn) {
if conn == nil {
return
+115 -14
View File
@@ -29,6 +29,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/bishopfox/sliver/client/assets"
@@ -86,14 +87,23 @@ type SliverClient struct {
stdoutPipeWriter *os.File
stdoutPipeDone chan struct{}
stdoutPipeOnce sync.Once
stdoutSyncMu sync.Mutex
stdoutSyncSeq uint64
stdoutSyncMarker []byte
stdoutSyncAcks map[uint64]chan struct{}
stdoutSyncAcksMu sync.Mutex
connMu sync.Mutex
grpcConn *grpc.ClientConn
backgroundRPC rpcpb.SliverRPCClient
backgroundConn *grpc.ClientConn
backgroundDedicated bool
connDetails *ConnectionDetails
connCancel context.CancelFunc
connWg *sync.WaitGroup
connectionHooksApplied bool
logCommandHookApplied bool
suppressedEventNotifs atomic.Int32
// These writers are always safe to use (never error); the underlying stream
// can be swapped on server switch to avoid breaking io.MultiWriter/io.Copy.
@@ -317,6 +327,9 @@ func (con *SliverClient) applyConnectionHooksOnce() {
}
con.connectionHooksApplied = true
con.App.PreReadlineHooks = append(con.App.PreReadlineHooks, con.syncOutputHook)
con.App.PostCmdRunHooks = append(con.App.PostCmdRunHooks, con.syncOutputHook)
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, con.refreshDedicatedCommandConnectionHook)
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, con.allowServerRootCommands)
if shell := con.App.Shell(); shell != nil && shell.Completer != nil {
baseCompleter := shell.Completer
@@ -327,8 +340,12 @@ func (con *SliverClient) applyConnectionHooksOnce() {
}
}
func (con *SliverClient) startEventLoop(ctx context.Context) {
eventStream, err := con.Rpc.Events(ctx, &commonpb.Empty{})
func (con *SliverClient) startEventLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) {
if rpc == nil {
return
}
eventStream, err := rpc.Events(ctx, &commonpb.Empty{})
if err != nil {
fmt.Printf("%s%s\n", Warn, err)
return
@@ -345,8 +362,20 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
switch event.EventType {
case consts.CanaryEvent:
con.PrintEventErrorf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
sessions := con.GetSessionsByName(event.Session.Name)
if con.eventNotificationsSuppressed() {
affected := make([]string, 0, len(sessions))
for _, session := range sessions {
affected = append(affected, strings.Split(session.ID, "-")[0])
}
message := fmt.Sprintf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
if len(affected) > 0 {
message += " Affected sessions: " + strings.Join(affected, ", ")
}
con.emitToastEvent("error", message)
break
}
con.PrintEventErrorf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
for _, session := range sessions {
shortID := strings.Split(session.ID, "-")[0]
con.PrintErrorf("\t🔥 Session %s is affected", shortID)
@@ -354,8 +383,20 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
case consts.WatchtowerEvent:
msg := string(event.Data)
con.PrintEventErrorf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
sessions := con.GetSessionsByName(event.Session.Name)
if con.eventNotificationsSuppressed() {
affected := make([]string, 0, len(sessions))
for _, session := range sessions {
affected = append(affected, strings.Split(session.ID, "-")[0])
}
message := fmt.Sprintf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
if len(affected) > 0 {
message += " Affected sessions: " + strings.Join(affected, ", ")
}
con.emitToastEvent("error", message)
break
}
con.PrintEventErrorf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
for _, session := range sessions {
shortID := strings.Split(session.ID, "-")[0]
con.PrintErrorf("\t🔥 Session %s is affected", shortID)
@@ -363,42 +404,42 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
case consts.JoinedEvent:
if con.Settings.UserConnect {
con.PrintInfof("%s has joined the game", event.Client.Operator.Name)
con.emitConsoleNotification("info", false, "%s has joined the game", event.Client.Operator.Name)
}
case consts.LeftEvent:
if con.Settings.UserConnect {
con.PrintInfof("%s left the game", event.Client.Operator.Name)
con.emitConsoleNotification("info", false, "%s left the game", event.Client.Operator.Name)
}
case consts.JobStoppedEvent:
job := event.Job
con.PrintErrorf("Job #%d stopped (%s/%s)", job.ID, job.Protocol, job.Name)
con.emitConsoleNotification("error", false, "Job #%d stopped (%s/%s)", job.ID, job.Protocol, job.Name)
case consts.SessionOpenedEvent:
session := event.Session
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(session.ID, "-")[0]
con.PrintEventInfof("Session %s %s - %s (%s) - %s/%s - %v",
con.emitConsoleNotification("info", true, "Session %s %s - %s (%s) - %s/%s - %v",
shortID, session.Name, session.RemoteAddress, session.Hostname, session.OS, session.Arch, currentTime)
case consts.SessionUpdateEvent:
session := event.Session
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(session.ID, "-")[0]
con.PrintInfof("Session %s has been updated - %v", shortID, currentTime)
con.emitConsoleNotification("info", false, "Session %s has been updated - %v", shortID, currentTime)
case consts.SessionClosedEvent:
session := event.Session
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(session.ID, "-")[0]
con.PrintEventErrorf("Lost session %s %s - %s (%s) - %s/%s - %v",
con.emitConsoleNotification("error", true, "Lost session %s %s - %s (%s) - %s/%s - %v",
shortID, session.Name, session.RemoteAddress, session.Hostname, session.OS, session.Arch, currentTime)
activeSession := con.ActiveTarget.GetSession()
core.GetTunnels().CloseForSession(session.ID)
core.CloseCursedProcesses(session.ID)
if activeSession != nil && activeSession.ID == session.ID {
con.ActiveTarget.Set(nil, nil)
con.PrintErrorf("Active session disconnected")
con.emitConsoleNotification("error", false, "Active session disconnected")
}
case consts.BeaconRegisteredEvent:
@@ -406,7 +447,7 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
proto.Unmarshal(event.Data, beacon)
currentTime := time.Now().Format(time.RFC1123)
shortID := strings.Split(beacon.ID, "-")[0]
con.PrintEventInfof("Beacon %s %s - %s (%s) - %s/%s - %v",
con.emitConsoleNotification("info", true, "Beacon %s %s - %s (%s) - %s/%s - %v",
shortID, beacon.Name, beacon.RemoteAddress, beacon.Hostname, beacon.OS, beacon.Arch, currentTime)
case consts.BeaconTaskResultEvent:
@@ -426,6 +467,66 @@ func (con *SliverClient) CreateEventListener() (string, <-chan *clientpb.Event)
return listenerID.String(), listener
}
// SuppressEventNotifications redirects console event messages to local listeners instead of stdout.
func (con *SliverClient) SuppressEventNotifications() func() {
if con == nil {
return func() {}
}
con.suppressedEventNotifs.Add(1)
return func() {
if con == nil {
return
}
if con.suppressedEventNotifs.Add(-1) < 0 {
con.suppressedEventNotifs.Store(0)
}
}
}
func (con *SliverClient) eventNotificationsSuppressed() bool {
return con != nil && con.suppressedEventNotifs.Load() > 0
}
func (con *SliverClient) emitToastEvent(level string, message string) {
if con == nil || con.EventListeners == nil || strings.TrimSpace(message) == "" {
return
}
con.triggerEventListeners(&clientpb.Event{
EventType: consts.ClientToastEvent,
Data: []byte(message),
Err: strings.TrimSpace(level),
})
}
func (con *SliverClient) emitConsoleNotification(level string, emphasized bool, format string, args ...any) {
message := fmt.Sprintf(format, args...)
if con.eventNotificationsSuppressed() {
con.emitToastEvent(level, message)
return
}
switch strings.ToLower(strings.TrimSpace(level)) {
case "error", "warn", "warning":
if emphasized {
con.PrintEventErrorf("%s", message)
} else {
con.PrintErrorf("%s", message)
}
case "success":
if emphasized {
con.PrintEventSuccessf("%s", message)
} else {
con.PrintSuccessf("%s", message)
}
default:
if emphasized {
con.PrintEventInfof("%s", message)
} else {
con.PrintInfof("%s", message)
}
}
}
// RemoveEventListener - removes an event listener given its id.
func (con *SliverClient) RemoveEventListener(listenerID string) {
value, ok := con.EventListeners.LoadAndDelete(listenerID)
@@ -470,10 +571,10 @@ func (con *SliverClient) triggerReactions(event *clientpb.Event) {
for _, reaction := range reactions {
for _, line := range reaction.Commands {
con.PrintInfof("%s '%s'", StyleBold.Render("Execute reaction:"), line)
con.emitConsoleNotification("info", false, "%s '%s'", StyleBold.Render("Execute reaction:"), line)
err := con.App.ActiveMenu().RunCommandLine(context.Background(), line)
if err != nil {
con.PrintErrorf("Reaction command error: %s\n", err)
con.emitConsoleNotification("error", false, "Reaction command error: %s", err)
}
}
}
+171 -8
View File
@@ -19,7 +19,10 @@ package console
*/
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"log"
@@ -38,6 +41,11 @@ import (
"golang.org/x/term"
)
const (
stdoutSyncMarkerSize = 16
stdoutSyncWait = 2 * time.Second
)
// ConsoleClientLogger is an io.Writer that sends data to the server.
type ConsoleClientLogger struct {
name string
@@ -99,7 +107,8 @@ func (con *SliverClient) refreshRemoteLogStreamsLocked() {
if con.jsonRemoteWriter == nil && con.asciicastRemoteWriter == nil {
return
}
if con.Rpc == nil {
rpc := con.backgroundRPCClientLocked()
if rpc == nil {
con.setRemoteLogStreamsLocked(nil, nil)
return
}
@@ -108,7 +117,7 @@ func (con *SliverClient) refreshRemoteLogStreamsLocked() {
var asciicastStream *ConsoleClientLogger
if con.jsonRemoteWriter != nil {
s, err := con.ClientLogStream("json")
s, err := con.clientLogStream(rpc, "json")
if err != nil {
log.Printf("Could not get client json log stream: %s", err)
} else {
@@ -116,7 +125,7 @@ func (con *SliverClient) refreshRemoteLogStreamsLocked() {
}
}
if con.asciicastRemoteWriter != nil {
s, err := con.ClientLogStream("asciicast")
s, err := con.clientLogStream(rpc, "asciicast")
if err != nil {
log.Printf("Could not get client asciicast log stream: %s", err)
} else {
@@ -158,7 +167,17 @@ func (con *SliverClient) setRemoteLogStreamsLocked(jsonStream, asciicastStream *
// ClientLogStream requires a log stream name, used to save the logs
// going through this stream in a specific log subdirectory/file.
func (con *SliverClient) ClientLogStream(name string) (*ConsoleClientLogger, error) {
stream, err := con.Rpc.ClientLog(context.Background())
con.connMu.Lock()
rpc := con.backgroundRPCClientLocked()
con.connMu.Unlock()
if rpc == nil {
return nil, fmt.Errorf("no RPC connection available for client log stream %q", name)
}
return con.clientLogStream(rpc, name)
}
func (con *SliverClient) clientLogStream(rpc rpcpb.SliverRPCClient, name string) (*ConsoleClientLogger, error) {
stream, err := rpc.ClientLog(context.Background())
if err != nil {
return nil, err
}
@@ -233,14 +252,158 @@ func (con *SliverClient) setupAsciicastRecord(logFile *os.File, server io.Writer
os.Stdout = w
os.Stderr = w
marker := make([]byte, stdoutSyncMarkerSize)
if _, err := rand.Read(marker); err != nil {
copy(marker, []byte(fmt.Sprintf("sliver-sync-%d", time.Now().UnixNano())))
}
done := make(chan struct{})
con.stdoutPipeWriter = w
con.stdoutPipeDone = done
con.stdoutSyncMarker = marker
con.stdoutSyncAcks = map[uint64]chan struct{}{}
go func() {
_, _ = io.Copy(mw, r)
close(done)
}()
go con.copyStdoutPipe(r, mw, done)
}
func buildStdoutSyncFrame(marker []byte, seq uint64) []byte {
frame := make([]byte, len(marker)*2+8)
copy(frame, marker)
binary.BigEndian.PutUint64(frame[len(marker):], seq)
copy(frame[len(marker)+8:], marker)
return frame
}
func pendingMarkerPrefixLen(pending []byte, marker []byte) int {
max := len(marker) - 1
if max > len(pending) {
max = len(pending)
}
for size := max; size > 0; size-- {
if bytes.Equal(pending[len(pending)-size:], marker[:size]) {
return size
}
}
return 0
}
func drainStdoutPipeBuffer(dst io.Writer, pending []byte, marker []byte, ack func(uint64)) []byte {
if len(pending) == 0 {
return pending
}
if len(marker) == 0 {
_, _ = dst.Write(pending)
return pending[:0]
}
frameLen := len(marker)*2 + 8
for {
idx := bytes.Index(pending, marker)
if idx == -1 {
keep := pendingMarkerPrefixLen(pending, marker)
flush := len(pending) - keep
if flush > 0 {
_, _ = dst.Write(pending[:flush])
pending = pending[flush:]
}
return pending
}
if idx > 0 {
_, _ = dst.Write(pending[:idx])
pending = pending[idx:]
}
if len(pending) < frameLen {
return pending
}
if bytes.Equal(pending[len(marker)+8:frameLen], marker) {
if ack != nil {
ack(binary.BigEndian.Uint64(pending[len(marker) : len(marker)+8]))
}
pending = pending[frameLen:]
continue
}
_, _ = dst.Write(pending[:1])
pending = pending[1:]
}
}
func (con *SliverClient) copyStdoutPipe(src *os.File, dst io.Writer, done chan struct{}) {
defer close(done)
defer src.Close()
readBuf := make([]byte, 4096)
pending := make([]byte, 0, 4096)
for {
n, err := src.Read(readBuf)
if n > 0 {
pending = append(pending, readBuf[:n]...)
pending = drainStdoutPipeBuffer(dst, pending, con.stdoutSyncMarker, con.ackStdoutSync)
}
if err != nil {
break
}
}
if len(pending) > 0 {
_, _ = dst.Write(pending)
}
}
func (con *SliverClient) ackStdoutSync(seq uint64) {
con.stdoutSyncAcksMu.Lock()
ack := con.stdoutSyncAcks[seq]
delete(con.stdoutSyncAcks, seq)
con.stdoutSyncAcksMu.Unlock()
if ack != nil {
close(ack)
}
}
func (con *SliverClient) clearStdoutSync(seq uint64) {
con.stdoutSyncAcksMu.Lock()
delete(con.stdoutSyncAcks, seq)
con.stdoutSyncAcksMu.Unlock()
}
func (con *SliverClient) syncOutputHook() error {
con.syncOutput()
return nil
}
func (con *SliverClient) syncOutput() {
if con.stdoutPipeWriter == nil {
_ = os.Stdout.Sync()
return
}
if len(con.stdoutSyncMarker) == 0 {
return
}
con.stdoutSyncMu.Lock()
con.stdoutSyncSeq++
seq := con.stdoutSyncSeq
ack := make(chan struct{})
con.stdoutSyncAcksMu.Lock()
if con.stdoutSyncAcks == nil {
con.stdoutSyncAcks = map[uint64]chan struct{}{}
}
con.stdoutSyncAcks[seq] = ack
con.stdoutSyncAcksMu.Unlock()
frame := buildStdoutSyncFrame(con.stdoutSyncMarker, seq)
_, err := con.stdoutPipeWriter.Write(frame)
con.stdoutSyncMu.Unlock()
if err != nil {
con.clearStdoutSync(seq)
return
}
select {
case <-ack:
case <-time.After(stdoutSyncWait):
con.clearStdoutSync(seq)
}
}
func getConsoleLogFile() *os.File {
+137
View File
@@ -0,0 +1,137 @@
package console
import (
"bytes"
"os"
"sync"
"testing"
"time"
consts "github.com/bishopfox/sliver/client/constants"
)
func TestDrainStdoutPipeBufferStripsSyncFrames(t *testing.T) {
marker := []byte{0x01, 0x02, 0x03, 0x04}
frame := buildStdoutSyncFrame(marker, 7)
var got bytes.Buffer
var acked []uint64
pending := drainStdoutPipeBuffer(&got, append(append([]byte("before"), frame...), []byte("after")...), marker, func(seq uint64) {
acked = append(acked, seq)
})
if len(pending) > 0 {
_, _ = got.Write(pending)
}
if got.String() != "beforeafter" {
t.Fatalf("unexpected output %q", got.String())
}
if len(acked) != 1 || acked[0] != 7 {
t.Fatalf("unexpected sync acknowledgements: %v", acked)
}
}
func TestDrainStdoutPipeBufferHandlesSplitFrame(t *testing.T) {
marker := []byte{0xaa, 0xbb, 0xcc, 0xdd}
frame := buildStdoutSyncFrame(marker, 11)
var got bytes.Buffer
var acked []uint64
chunk1 := append([]byte("left"), frame[:5]...)
pending := drainStdoutPipeBuffer(&got, chunk1, marker, func(seq uint64) {
acked = append(acked, seq)
})
if got.String() != "left" {
t.Fatalf("expected prefix output to flush, got %q", got.String())
}
chunk2 := append(append([]byte{}, pending...), frame[5:]...)
chunk2 = append(chunk2, []byte("right")...)
pending = drainStdoutPipeBuffer(&got, chunk2, marker, func(seq uint64) {
acked = append(acked, seq)
})
if len(pending) > 0 {
_, _ = got.Write(pending)
}
if got.String() != "leftright" {
t.Fatalf("unexpected split-frame output %q", got.String())
}
if len(acked) != 1 || acked[0] != 11 {
t.Fatalf("unexpected split-frame acknowledgements: %v", acked)
}
}
func TestSyncOutputWaitsForPipeDrain(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
done := make(chan struct{})
con := &SliverClient{
stdoutPipeWriter: w,
stdoutPipeDone: done,
stdoutSyncMarker: []byte("0123456789abcdef"),
stdoutSyncAcks: map[uint64]chan struct{}{},
}
var got bytes.Buffer
go con.copyStdoutPipe(r, &got, done)
if _, err := w.Write([]byte("hello")); err != nil {
t.Fatalf("write prefix: %v", err)
}
con.syncOutput()
if got.String() != "hello" {
t.Fatalf("syncOutput returned before draining stdout: %q", got.String())
}
if _, err := w.Write([]byte(" world")); err != nil {
t.Fatalf("write suffix: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timed out waiting for stdout copier to finish")
}
if got.String() != "hello world" {
t.Fatalf("unexpected final output %q", got.String())
}
}
func TestSuppressEventNotificationsRedirectsMessagesToListeners(t *testing.T) {
con := &SliverClient{
EventListeners: &sync.Map{},
printf: func(string, ...any) (int, error) { return 0, nil },
}
listenerID, listener := con.CreateEventListener()
defer con.RemoveEventListener(listenerID)
restore := con.SuppressEventNotifications()
defer restore()
con.emitConsoleNotification("info", true, "Session %s connected", "alpha")
select {
case event := <-listener:
if event.GetEventType() != consts.ClientToastEvent {
t.Fatalf("expected toast event, got %q", event.GetEventType())
}
if got := string(event.GetData()); got != "Session alpha connected" {
t.Fatalf("unexpected toast payload %q", got)
}
if got := event.GetErr(); got != "info" {
t.Fatalf("unexpected toast level %q", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for redirected event notification")
}
}
+12 -1
View File
@@ -113,7 +113,15 @@ const (
CrackStatusEvent = "crack-status"
// WireGuardNewPeer - New Wireguard peer added.
WireGuardNewPeer = "wireguard-newpeer"
WireGuardNewPeer = "wireguard-newpeer"
MultiplayerWireGuardNewPeer = "multiplayer-wireguard-newpeer"
MultiplayerWireGuardRemoved = "multiplayer-wireguard-removed"
// AIConversationEvent - Fires whenever an AI conversation or message changes.
AIConversationEvent = "ai-conversation"
// ClientToastEvent - Local-only event used to surface console notifications inside TUIs.
ClientToastEvent = "client-toast"
)
// Commands.
@@ -220,6 +228,9 @@ const (
MkdirStr = "mkdir"
CdStr = "cd"
PwdStr = "pwd"
AIStr = "ai"
AIConfigStr = "ai-config"
DocsStr = "docs"
CatStr = "cat"
EditStr = "edit"
HexEditStr = "hex-edit"
+1
View File
@@ -216,6 +216,7 @@ func (p *ChannelProxy) dialImplant(ctx context.Context) (*TunnelIO, error) {
Port: p.Port(),
Protocol: sliverpb.PortFwdProtoTCP,
TunnelID: tunnel.ID,
KeepAlive: int32(p.KeepAlivePeriod.Seconds()),
})
if err != nil {
return nil, err
+2 -2
View File
@@ -3,8 +3,8 @@ package forms
import (
"errors"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/charmbracelet/huh"
)
// ArmoryInstallOption represents an armory install option.
@@ -46,7 +46,7 @@ func ArmoryInstallForm(options []ArmoryInstallOption) (*ArmoryInstallFormResult,
Value(&result.SelectedNames)
form := huh.NewForm(huh.NewGroup(field)).WithTheme(theme.HuhTheme())
if err := form.Run(); err != nil {
if err := runForm(form); err != nil {
return nil, err
}
+2 -2
View File
@@ -3,8 +3,8 @@ package forms
import (
"errors"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/charmbracelet/huh"
)
// ArmoryUpdateOption represents an update option in the armory update form.
@@ -45,7 +45,7 @@ func ArmoryUpdateForm(options []ArmoryUpdateOption) (*ArmoryUpdateFormResult, er
Value(&result.SelectedIDs)
form := huh.NewForm(huh.NewGroup(field)).WithTheme(theme.HuhTheme())
if err := form.Run(); err != nil {
if err := runForm(form); err != nil {
return nil, err
}
+2 -2
View File
@@ -4,10 +4,10 @@ import (
"errors"
"strings"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/util"
"github.com/charmbracelet/huh"
)
// ErrUserAborted exposes the huh user-abort sentinel for callers.
@@ -126,7 +126,7 @@ func GenerateForm(compiler *clientpb.Compiler) (*GenerateFormResult, error) {
)
form = form.WithTheme(theme.HuhTheme())
if err := form.Run(); err != nil {
if err := runForm(form); err != nil {
return nil, err
}
+2 -2
View File
@@ -5,10 +5,10 @@ import (
"strconv"
"strings"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/util"
"github.com/charmbracelet/huh"
)
// GenerateBeaconFormResult captures the inputs needed to drive the generate beacon command.
@@ -156,7 +156,7 @@ func GenerateBeaconForm(compiler *clientpb.Compiler) (*GenerateBeaconFormResult,
)
form = form.WithTheme(theme.HuhTheme())
if err := form.Run(); err != nil {
if err := runForm(form); err != nil {
return nil, err
}
+2 -2
View File
@@ -4,9 +4,9 @@ import (
"errors"
"strings"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/bishopfox/sliver/util"
"github.com/charmbracelet/huh"
)
// GenerateProfilesNewFormResult captures the inputs needed to drive the profiles new command.
@@ -136,7 +136,7 @@ func GenerateProfilesNewForm() (*GenerateProfilesNewFormResult, error) {
)
form = form.WithTheme(theme.HuhTheme())
if err := form.Run(); err != nil {
if err := runForm(form); err != nil {
return nil, err
}
+2 -2
View File
@@ -4,9 +4,9 @@ import (
"errors"
"strings"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/bishopfox/sliver/util"
"github.com/charmbracelet/huh"
)
// GenerateProfilesNewBeaconFormResult captures the inputs needed to drive the profiles new beacon command.
@@ -168,7 +168,7 @@ func GenerateProfilesNewBeaconForm() (*GenerateProfilesNewBeaconFormResult, erro
)
form = form.WithTheme(theme.HuhTheme())
if err := form.Run(); err != nil {
if err := runForm(form); err != nil {
return nil, err
}
+1 -1
View File
@@ -3,8 +3,8 @@ package forms
import (
"sort"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/huh"
)
type formatOption struct {
+6 -6
View File
@@ -5,8 +5,8 @@ import (
"slices"
"strings"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/charmbracelet/huh"
"golang.org/x/term"
)
@@ -35,7 +35,7 @@ func Confirm(title string, value *bool) error {
),
).WithTheme(theme.HuhTheme())
return form.Run()
return runForm(form)
}
// Input prompts for a single-line string.
@@ -52,7 +52,7 @@ func Input(title string, value *string) error {
),
).WithTheme(theme.HuhTheme())
return form.Run()
return runForm(form)
}
// Text prompts for multi-line input.
@@ -70,7 +70,7 @@ func Text(title string, value *string) error {
),
).WithTheme(theme.HuhTheme())
return form.Run()
return runForm(form)
}
// Select prompts for a single selection from options.
@@ -102,7 +102,7 @@ func MultiSelect(title string, options []string, value *[]string) error {
form := huh.NewForm(huh.NewGroup(field)).
WithTheme(theme.HuhTheme()).
WithWidth(getTerminalWidth())
return form.Run()
return runForm(form)
}
func selectPrompt(title string, options []string, value *string, required bool) error {
@@ -136,7 +136,7 @@ func selectPrompt(title string, options []string, value *string, required bool)
form := huh.NewForm(huh.NewGroup(field)).
WithTheme(theme.HuhTheme()).
WithWidth(getTerminalWidth())
err := form.Run()
err := runForm(form)
// On error restore the originalValue and return err
if err != nil {
+76
View File
@@ -0,0 +1,76 @@
package forms
import (
"os"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/termio"
"golang.org/x/term"
)
var openFormTTY = tea.OpenTTY
func runForm(form *huh.Form) error {
cleanup := configureFormTTY(form)
if cleanup != nil {
defer cleanup()
}
return form.Run()
}
// Console logging swaps stdout/stderr to pipes so the session can be tee'd to
// disk. Bubble Tea forms need a real TTY output, so temporarily bind them back
// to the controlling terminal when the interactive console has piped stdio.
func configureFormTTY(form *huh.Form) func() {
if form == nil {
return nil
}
stdinTTY := isTTY(os.Stdin)
stdoutTTY := isTTY(os.Stdout)
stderrTTY := isTTY(os.Stderr)
switch {
case shouldUseStdoutTTYForForm(stdinTTY, stdoutTTY, stderrTTY):
form.WithInput(os.Stdin)
form.WithOutput(os.Stdout)
return nil
case needsDedicatedTTYForForm(stdinTTY, stdoutTTY, stderrTTY):
if isTTY(termio.InteractiveInput()) && isTTY(termio.InteractiveOutput()) {
form.WithInput(termio.InteractiveInput())
form.WithOutput(termio.InteractiveOutput())
return nil
}
inTTY, outTTY, err := openFormTTY()
if err != nil {
return nil
}
form.WithInput(inTTY)
form.WithOutput(outTTY)
return func() {
_ = inTTY.Close()
if outTTY != inTTY {
_ = outTTY.Close()
}
}
default:
return nil
}
}
func isTTY(file *os.File) bool {
return file != nil && term.IsTerminal(int(file.Fd()))
}
func shouldUseStdoutTTYForForm(stdinTTY, stdoutTTY, stderrTTY bool) bool {
return stdinTTY && stdoutTTY && !stderrTTY
}
func needsDedicatedTTYForForm(stdinTTY, stdoutTTY, stderrTTY bool) bool {
return stdinTTY && !stdoutTTY && !stderrTTY
}
+49
View File
@@ -0,0 +1,49 @@
package forms
import "testing"
func TestShouldUseStdoutTTYForForm(t *testing.T) {
tests := []struct {
name string
stdinTTY bool
stdoutTTY bool
stderrTTY bool
want bool
}{
{name: "stderr redirected but stdout interactive", stdinTTY: true, stdoutTTY: true, stderrTTY: false, want: true},
{name: "default stderr already interactive", stdinTTY: true, stdoutTTY: true, stderrTTY: true, want: false},
{name: "non interactive stdin", stdinTTY: false, stdoutTTY: true, stderrTTY: false, want: false},
{name: "all stdio piped", stdinTTY: true, stdoutTTY: false, stderrTTY: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldUseStdoutTTYForForm(tt.stdinTTY, tt.stdoutTTY, tt.stderrTTY); got != tt.want {
t.Fatalf("shouldUseStdoutTTYForForm(%v, %v, %v) = %v, want %v", tt.stdinTTY, tt.stdoutTTY, tt.stderrTTY, got, tt.want)
}
})
}
}
func TestNeedsDedicatedTTYForForm(t *testing.T) {
tests := []struct {
name string
stdinTTY bool
stdoutTTY bool
stderrTTY bool
want bool
}{
{name: "interactive stdin with piped stdout and stderr", stdinTTY: true, stdoutTTY: false, stderrTTY: false, want: true},
{name: "stdout can be reused", stdinTTY: true, stdoutTTY: true, stderrTTY: false, want: false},
{name: "stderr already interactive", stdinTTY: true, stdoutTTY: false, stderrTTY: true, want: false},
{name: "non interactive stdin", stdinTTY: false, stdoutTTY: false, stderrTTY: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := needsDedicatedTTYForForm(tt.stdinTTY, tt.stdoutTTY, tt.stderrTTY); got != tt.want {
t.Fatalf("needsDedicatedTTYForForm(%v, %v, %v) = %v, want %v", tt.stdinTTY, tt.stdoutTTY, tt.stderrTTY, got, tt.want)
}
})
}
}
+2 -2
View File
@@ -5,9 +5,9 @@ import (
"strconv"
"strings"
"charm.land/huh/v2"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/theme"
"github.com/charmbracelet/huh"
)
// SettingsFormResult captures the inputs needed to update client settings.
@@ -108,7 +108,7 @@ func SettingsForm(settings *assets.ClientSettings, tableStyleOptions []string) (
)
form = form.WithTheme(theme.HuhTheme())
if err := form.Run(); err != nil {
if err := runForm(form); err != nil {
return nil, err
}
+191
View File
@@ -0,0 +1,191 @@
package mcp
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/bishopfox/sliver/client/assets"
"gopkg.in/yaml.v3"
)
const (
authConfigFileName = "mcp.yaml"
authHeaderName = "Authorization"
minAuthTokenLength = 8
authTokenBytes = 16
sseMessagePath = "/message"
)
// AuthInfo describes the persisted MCP HTTP/SSE authentication settings.
type AuthInfo struct {
Header string
Token string
ConfigPath string
}
// AuthHeaderName returns the required header name for MCP HTTP/SSE requests.
func AuthHeaderName() string {
return authHeaderName
}
// AuthConfigPath returns the MCP auth config path in the client app directory.
func AuthConfigPath() string {
rootDir, _ := filepath.Abs(assets.GetRootAppDir())
return filepath.Join(rootDir, authConfigFileName)
}
type authConfig struct {
Token string `yaml:"token"`
}
// LoadAuthInfo loads the persisted MCP auth configuration without creating it.
func LoadAuthInfo() (AuthInfo, error) {
cfg, exists, err := loadAuthConfig()
if err != nil {
return AuthInfo{}, err
}
if !exists {
return AuthInfo{}, os.ErrNotExist
}
cfg.Token = strings.TrimSpace(cfg.Token)
if err := validateAuthToken(cfg.Token); err != nil {
return AuthInfo{}, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
}
return AuthInfo{
Header: authHeaderName,
Token: cfg.Token,
ConfigPath: AuthConfigPath(),
}, nil
}
// ResolveAuthInfo loads the persisted auth token or creates and saves one on first use.
func ResolveAuthInfo() (AuthInfo, bool, error) {
cfg, exists, err := loadAuthConfig()
if err != nil {
return AuthInfo{}, false, err
}
if exists {
cfg.Token = strings.TrimSpace(cfg.Token)
if err := validateAuthToken(cfg.Token); err != nil {
return AuthInfo{}, false, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
}
return AuthInfo{
Header: authHeaderName,
Token: cfg.Token,
ConfigPath: AuthConfigPath(),
}, false, nil
}
token, err := generateAuthToken()
if err != nil {
return AuthInfo{}, false, err
}
cfg = &authConfig{Token: token}
if err := saveAuthConfig(cfg); err != nil {
return AuthInfo{}, false, err
}
return AuthInfo{
Header: authHeaderName,
Token: token,
ConfigPath: AuthConfigPath(),
}, true, nil
}
func loadAuthConfig() (*authConfig, bool, error) {
data, err := os.ReadFile(AuthConfigPath())
if os.IsNotExist(err) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
cfg := &authConfig{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
func saveAuthConfig(cfg *authConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(AuthConfigPath(), data, 0o600)
}
func generateAuthToken() (string, error) {
tokenBytes := make([]byte, authTokenBytes)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("generate mcp auth token: %w", err)
}
return hex.EncodeToString(tokenBytes), nil
}
func validateAuthToken(token string) error {
if len(strings.TrimSpace(token)) < minAuthTokenLength {
return fmt.Errorf("token must be at least %d characters", minAuthTokenLength)
}
return nil
}
func authMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !authTokenMatches(r.Header.Get(authHeaderName), token) {
http.Error(w, fmt.Sprintf("missing or invalid %s header", authHeaderName), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func authTokenMatches(actual string, expected string) bool {
if len(expected) == 0 || len(actual) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
}
type authenticatedHTTPTransport struct {
server *http.Server
}
func newAuthenticatedTransport(cfg Config, handler http.Handler, token string) serverTransport {
mux := http.NewServeMux()
authenticatedHandler := authMiddleware(token, handler)
switch cfg.Transport {
case TransportHTTP:
mux.Handle("/mcp", authenticatedHandler)
case TransportSSE:
mux.Handle("/sse", authenticatedHandler)
mux.Handle(sseMessagePath, authenticatedHandler)
}
return &authenticatedHTTPTransport{
server: &http.Server{
Handler: mux,
},
}
}
func (t *authenticatedHTTPTransport) Start(addr string) error {
if t.server.Addr == "" {
t.server.Addr = addr
} else if t.server.Addr != addr {
return fmt.Errorf("conflicting listen address: %q vs %q", t.server.Addr, addr)
}
return t.server.ListenAndServe()
}
func (t *authenticatedHTTPTransport) Shutdown(ctx context.Context) error {
return t.server.Shutdown(ctx)
}
+97
View File
@@ -0,0 +1,97 @@
package mcp
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestResolveAuthInfoCreatesAndPersistsToken(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
authInfo, created, err := ResolveAuthInfo()
if err != nil {
t.Fatalf("ResolveAuthInfo returned error: %v", err)
}
if !created {
t.Fatalf("expected first ResolveAuthInfo call to create a token")
}
if authInfo.Header != AuthHeaderName() {
t.Fatalf("expected header %q, got %q", AuthHeaderName(), authInfo.Header)
}
if len(authInfo.Token) != authTokenBytes*2 {
t.Fatalf("expected %d hex characters, got %d", authTokenBytes*2, len(authInfo.Token))
}
data, err := os.ReadFile(AuthConfigPath())
if err != nil {
t.Fatalf("failed to read persisted auth config: %v", err)
}
if !strings.Contains(string(data), "token: "+authInfo.Token) {
t.Fatalf("persisted config did not contain generated token: %s", string(data))
}
authInfoAgain, createdAgain, err := ResolveAuthInfo()
if err != nil {
t.Fatalf("second ResolveAuthInfo returned error: %v", err)
}
if createdAgain {
t.Fatalf("expected second ResolveAuthInfo call to reuse the existing token")
}
if authInfoAgain.Token != authInfo.Token {
t.Fatalf("expected persisted token %q, got %q", authInfo.Token, authInfoAgain.Token)
}
}
func TestResolveAuthInfoRejectsInvalidExistingToken(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
if err := os.WriteFile(AuthConfigPath(), []byte("token: short\n"), 0o600); err != nil {
t.Fatalf("failed to seed auth config: %v", err)
}
_, _, err := ResolveAuthInfo()
if err == nil {
t.Fatal("expected invalid token error")
}
if !strings.Contains(err.Error(), "at least 8 characters") {
t.Fatalf("expected length validation error, got %v", err)
}
}
func TestAuthMiddlewareRequiresExactToken(t *testing.T) {
const token = "0123456789abcdef"
handler := authMiddleware(token, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
testCases := []struct {
name string
header string
statusCode int
}{
{name: "missing", statusCode: http.StatusUnauthorized},
{name: "wrong", header: "wrong-token", statusCode: http.StatusUnauthorized},
{name: "prefixed", header: "Bearer " + token, statusCode: http.StatusUnauthorized},
{name: "exact", header: token, statusCode: http.StatusNoContent},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
if testCase.header != "" {
req.Header.Set(AuthHeaderName(), testCase.header)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != testCase.statusCode {
t.Fatalf("expected status %d, got %d", testCase.statusCode, recorder.Code)
}
})
}
}
+24 -11
View File
@@ -20,10 +20,13 @@ var (
// Status describes the running state of the MCP server.
type Status struct {
Running bool
StartedAt time.Time
Config Config
LastError string
Running bool
StartedAt time.Time
Config Config
LastError string
AuthHeader string
AuthToken string
AuthConfigPath string
}
type serverTransport interface {
@@ -42,6 +45,7 @@ type mcpManager struct {
done chan struct{}
logger *log.Logger
logFile *os.File
authInfo AuthInfo
}
func newManager() *mcpManager {
@@ -93,10 +97,13 @@ func (m *mcpManager) status() Status {
m.mu.Lock()
defer m.mu.Unlock()
return Status{
Running: m.running,
StartedAt: m.startedAt,
Config: m.cfg,
LastError: m.lastErr,
Running: m.running,
StartedAt: m.startedAt,
Config: m.cfg,
LastError: m.lastErr,
AuthHeader: m.authInfo.Header,
AuthToken: m.authInfo.Token,
AuthConfigPath: m.authInfo.ConfigPath,
}
}
@@ -105,6 +112,10 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
if err := cfg.Validate(); err != nil {
return err
}
authInfo, _, err := ResolveAuthInfo()
if err != nil {
return err
}
m.mu.Lock()
if m.running {
@@ -120,9 +131,9 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
var transport serverTransport
switch cfg.Transport {
case TransportHTTP:
transport = mcpserver.NewStreamableHTTPServer(mcpServer.server)
transport = newAuthenticatedTransport(cfg, mcpserver.NewStreamableHTTPServer(mcpServer.server), authInfo.Token)
case TransportSSE:
transport = mcpserver.NewSSEServer(mcpServer.server)
transport = newAuthenticatedTransport(cfg, mcpserver.NewSSEServer(mcpServer.server), authInfo.Token)
default:
m.mu.Unlock()
logFile.Close()
@@ -138,11 +149,12 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
m.done = make(chan struct{})
m.logger = logger
m.logFile = logFile
m.authInfo = authInfo
addr := cfg.ListenAddress
done := m.done
m.mu.Unlock()
logger.Printf("starting mcp server transport=%s listen=%s", cfg.Transport, cfg.ListenAddress)
logger.Printf("starting mcp server transport=%s listen=%s auth_header=%s auth_config=%s", cfg.Transport, cfg.ListenAddress, authInfo.Header, authInfo.ConfigPath)
go m.run(addr, transport, done)
return nil
}
@@ -161,6 +173,7 @@ func (m *mcpManager) run(addr string, transport serverTransport, done chan struc
m.running = false
m.transport = nil
m.server = nil
m.authInfo = AuthInfo{}
logFile := m.logFile
m.logFile = nil
m.logger = nil
+443
View File
@@ -0,0 +1,443 @@
// Package thinking provides Crush's pending-request animation as a reusable
// Bubble Tea component.
package thinking
import (
"fmt"
"image/color"
"math/rand/v2"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/exp/charmtone"
"github.com/lucasb-eyer/go-colorful"
"github.com/zeebo/xxh3"
)
const (
fps = 20
initialChar = '.'
labelGap = " "
labelGapWidth = 1
// If the FPS is 20 (50 milliseconds) this means that the ellipsis will
// change every 8 frames (400 milliseconds).
ellipsisAnimSpeed = 8
// The maximum amount of time that can pass before a character appears.
// This is used to create a staggered entrance effect.
maxBirthOffset = time.Second
// Number of frames to prerender for the animation. After this number of
// frames, the animation will loop. This only applies when color cycling is
// disabled.
prerenderedFrames = 10
// Default number of cycling chars.
defaultNumCyclingChars = 10
)
var (
availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
ellipsisFrames = []string{".", "..", "...", ""}
)
// Internal ID management. Used during animating to ensure that frame messages
// are received only by animation components that sent them.
var lastID int64
func nextID() int {
return int(atomic.AddInt64(&lastID, 1))
}
type animCache struct {
initialFrames [][]string
cyclingFrames [][]string
width int
labelWidth int
label []string
ellipsisFrames []string
}
var animCacheMap sync.Map
// Colors groups the animation's configurable colors.
type Colors struct {
GradientA color.Color
GradientB color.Color
Label color.Color
}
// DefaultColors returns the same color palette Crush uses today for pending
// and thinking animations.
func DefaultColors() Colors {
return Colors{
GradientA: charmtone.Charple,
GradientB: charmtone.Dolly,
Label: charmtone.Ash,
}
}
func settingsHash(opts Settings) string {
h := xxh3.New()
fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
return fmt.Sprintf("%x", h.Sum(nil))
}
func withDefaultColors(opts Settings) Settings {
defaults := DefaultColors()
if colorIsUnset(opts.GradColorA) {
opts.GradColorA = defaults.GradientA
}
if colorIsUnset(opts.GradColorB) {
opts.GradColorB = defaults.GradientB
}
if colorIsUnset(opts.LabelColor) {
opts.LabelColor = defaults.Label
}
return opts
}
// StepMsg is a message type used to trigger the next step in the animation.
type StepMsg struct{ ID string }
// Settings defines animation settings.
type Settings struct {
ID string
Size int
Label string
LabelColor color.Color
GradColorA color.Color
GradColorB color.Color
CycleColors bool
SkipIntro bool
}
// Anim is a Bubble Tea component for Crush's pending/thinking animation.
type Anim struct {
mu sync.RWMutex
width int
cyclingCharWidth int
label []string
labelWidth int
labelColor color.Color
startTime time.Time
birthOffsets []time.Duration
initialFrames [][]string
initialized atomic.Bool
cyclingFrames [][]string
step atomic.Int64
ellipsisStep atomic.Int64
ellipsisFrames []string
id string
}
// New creates a new animation instance with the specified settings.
func New(opts Settings) *Anim {
a := &Anim{}
if opts.Size < 1 {
opts.Size = defaultNumCyclingChars
}
opts = withDefaultColors(opts)
if opts.ID != "" {
a.id = opts.ID
} else {
a.id = fmt.Sprintf("%d", nextID())
}
a.startTime = time.Now()
a.cyclingCharWidth = opts.Size
a.labelColor = opts.LabelColor
cacheKey := settingsHash(opts)
if cached, ok := loadAnimCache(cacheKey); ok {
a.width = cached.width
a.labelWidth = cached.labelWidth
a.label = slices.Clone(cached.label)
a.ellipsisFrames = slices.Clone(cached.ellipsisFrames)
a.initialFrames = cached.initialFrames
a.cyclingFrames = cached.cyclingFrames
} else {
a.labelWidth = lipgloss.Width(opts.Label)
a.width = opts.Size
if opts.Label != "" {
a.width += labelGapWidth + a.labelWidth
}
a.renderLabel(opts.Label)
var ramp []color.Color
numFrames := prerenderedFrames
if opts.CycleColors {
ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
numFrames = a.width * 2
} else {
ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
}
a.initialFrames = make([][]string, numFrames)
offset := 0
for i := range a.initialFrames {
a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
for j := range a.initialFrames[i] {
if j+offset >= len(ramp) {
continue
}
c := opts.LabelColor
if j <= a.cyclingCharWidth {
c = ramp[j+offset]
}
a.initialFrames[i][j] = lipgloss.NewStyle().
Foreground(c).
Render(string(initialChar))
}
if opts.CycleColors {
offset++
}
}
a.cyclingFrames = make([][]string, numFrames)
offset = 0
for i := range a.cyclingFrames {
a.cyclingFrames[i] = make([]string, a.width)
for j := range a.cyclingFrames[i] {
if j+offset >= len(ramp) {
continue
}
r := availableRunes[rand.IntN(len(availableRunes))]
a.cyclingFrames[i][j] = lipgloss.NewStyle().
Foreground(ramp[j+offset]).
Render(string(r))
}
if opts.CycleColors {
offset++
}
}
storeAnimCache(cacheKey, &animCache{
initialFrames: a.initialFrames,
cyclingFrames: a.cyclingFrames,
width: a.width,
labelWidth: a.labelWidth,
label: slices.Clone(a.label),
ellipsisFrames: slices.Clone(a.ellipsisFrames),
})
}
a.birthOffsets = make([]time.Duration, a.width)
for i := range a.birthOffsets {
a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
}
if opts.SkipIntro {
a.initialized.Store(true)
}
return a
}
func loadAnimCache(key string) (*animCache, bool) {
cached, ok := animCacheMap.Load(key)
if !ok {
return nil, false
}
animCache, ok := cached.(*animCache)
return animCache, ok
}
func storeAnimCache(key string, cached *animCache) {
animCacheMap.Store(key, cached)
}
// SetLabel updates the label text and re-renders it.
func (a *Anim) SetLabel(newLabel string) {
a.mu.Lock()
defer a.mu.Unlock()
a.labelWidth = lipgloss.Width(newLabel)
a.width = a.cyclingCharWidth
if newLabel != "" {
a.width += labelGapWidth + a.labelWidth
}
a.renderLabel(newLabel)
}
func (a *Anim) renderLabel(label string) {
if a.labelWidth > 0 {
labelRunes := []rune(label)
a.label = make([]string, 0, len(labelRunes))
for i := range labelRunes {
rendered := lipgloss.NewStyle().
Foreground(a.labelColor).
Render(string(labelRunes[i]))
a.label = append(a.label, rendered)
}
a.ellipsisFrames = make([]string, 0, len(ellipsisFrames))
for _, frame := range ellipsisFrames {
rendered := lipgloss.NewStyle().
Foreground(a.labelColor).
Render(frame)
a.ellipsisFrames = append(a.ellipsisFrames, rendered)
}
return
}
a.label = nil
a.ellipsisFrames = nil
}
// Width returns the total width of the animation, including the animated
// ellipsis when a label is present.
func (a *Anim) Width() int {
a.mu.RLock()
defer a.mu.RUnlock()
w := a.cyclingCharWidth
if a.labelWidth > 0 {
w += labelGapWidth + a.labelWidth
var widestEllipsisFrame int
for _, frame := range ellipsisFrames {
widestEllipsisFrame = max(widestEllipsisFrame, lipgloss.Width(frame))
}
w += widestEllipsisFrame
}
return w
}
// Start starts the animation.
func (a *Anim) Start() tea.Cmd {
return a.Step()
}
// Animate advances the animation to the next step.
func (a *Anim) Animate(msg StepMsg) tea.Cmd {
if msg.ID != a.id {
return nil
}
step := a.step.Add(1)
if int(step) >= len(a.cyclingFrames) {
a.step.Store(0)
}
a.mu.RLock()
labelWidth := a.labelWidth
a.mu.RUnlock()
if a.initialized.Load() && labelWidth > 0 {
ellipsisStep := a.ellipsisStep.Add(1)
if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
a.ellipsisStep.Store(0)
}
} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
a.initialized.Store(true)
}
return a.Step()
}
// Render renders the current state of the animation.
func (a *Anim) Render() string {
a.mu.RLock()
defer a.mu.RUnlock()
var b strings.Builder
step := int(a.step.Load())
for i := range a.width {
switch {
case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
b.WriteString(a.initialFrames[step][i])
case i < a.cyclingCharWidth:
b.WriteString(a.cyclingFrames[step][i])
case i == a.cyclingCharWidth:
b.WriteString(labelGap)
case i > a.cyclingCharWidth:
labelIdx := i - a.cyclingCharWidth - labelGapWidth
if labelIdx >= 0 && labelIdx < len(a.label) {
b.WriteString(a.label[labelIdx])
}
}
}
if a.initialized.Load() && a.labelWidth > 0 {
ellipsisStep := int(a.ellipsisStep.Load())
frameIdx := ellipsisStep / ellipsisAnimSpeed
if frameIdx >= 0 && frameIdx < len(a.ellipsisFrames) {
b.WriteString(a.ellipsisFrames[frameIdx])
}
}
return b.String()
}
// Step is a command that triggers the next step in the animation.
func (a *Anim) Step() tea.Cmd {
return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
return StepMsg{ID: a.id}
})
}
// makeGradientRamp returns a slice of colors blended between the given keys.
// Blending is done as Hcl to stay in gamut.
func makeGradientRamp(size int, stops ...color.Color) []color.Color {
if len(stops) < 2 {
return nil
}
points := make([]colorful.Color, len(stops))
for i, stop := range stops {
points[i], _ = colorful.MakeColor(stop)
}
numSegments := len(stops) - 1
if numSegments == 0 {
return nil
}
blended := make([]color.Color, 0, size)
segmentSizes := make([]int, numSegments)
baseSize := size / numSegments
remainder := size % numSegments
for i := range numSegments {
segmentSizes[i] = baseSize
if i < remainder {
segmentSizes[i]++
}
}
for i := range numSegments {
c1 := points[i]
c2 := points[i+1]
segmentSize := segmentSizes[i]
for j := range segmentSize {
if segmentSize == 0 {
continue
}
t := float64(j) / float64(segmentSize)
c := c1.BlendHcl(c2, t)
blended = append(blended, c)
}
}
return blended
}
func colorIsUnset(c color.Color) bool {
if c == nil {
return true
}
_, _, _, a := c.RGBA()
return a == 0
}
+20
View File
@@ -0,0 +1,20 @@
package termio
import "os"
var (
interactiveInput = os.Stdin
interactiveOutput = os.Stdout
)
// InteractiveInput returns the process's original stdin handle before any
// console logging hooks swapped stdio to pipes.
func InteractiveInput() *os.File {
return interactiveInput
}
// InteractiveOutput returns the process's original stdout handle before any
// console logging hooks swapped stdio to pipes.
func InteractiveOutput() *os.File {
return interactiveOutput
}
+56 -52
View File
@@ -1,84 +1,88 @@
package theme
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"image/color"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
// HuhTheme returns a huh Theme derived from the current Sliver client theme.
//
// This is used for all interactive TUI forms in the client so that colors are
// consistent with the rest of the console styling (theme.yaml).
func HuhTheme() *huh.Theme {
t := huh.ThemeBase()
func HuhTheme() huh.Theme {
return huh.ThemeFunc(func(isDark bool) *huh.Styles {
t := huh.ThemeBase(isDark)
current := Current()
border := paletteModV1(current.Default, 300)
text := paletteModV1(current.Default, 900)
dim := paletteModV1(current.Default, 500)
faint := paletteModV1(current.Default, 400)
current := Current()
border := paletteMod(current.Default, 300)
text := paletteMod(current.Default, 900)
dim := paletteMod(current.Default, 500)
faint := paletteMod(current.Default, 400)
primary := paletteColorV1(current.Primary)
accent := paletteColorV1(current.Secondary)
primary := paletteColor(current.Primary)
accent := paletteColor(current.Secondary)
success := paletteColorV1(current.Success)
danger := paletteColorV1(current.Danger)
success := paletteColor(current.Success)
danger := paletteColor(current.Danger)
// Focused styles.
t.Focused.Base = t.Focused.Base.BorderForeground(border)
t.Focused.Card = t.Focused.Base
t.Focused.Title = t.Focused.Title.Foreground(primary).Bold(true)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(primary).Bold(true).MarginBottom(1)
t.Focused.Description = t.Focused.Description.Foreground(dim)
t.Focused.Directory = t.Focused.Directory.Foreground(primary)
t.Focused.File = t.Focused.File.Foreground(text)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(danger)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(danger)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(accent)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(accent)
t.Focused.Option = t.Focused.Option.Foreground(text)
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(accent)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(success)
t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(success)
t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(text)
t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(faint)
// Focused styles.
t.Focused.Base = t.Focused.Base.BorderForeground(border)
t.Focused.Card = t.Focused.Base
t.Focused.Title = t.Focused.Title.Foreground(primary).Bold(true)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(primary).Bold(true).MarginBottom(1)
t.Focused.Description = t.Focused.Description.Foreground(dim)
t.Focused.Directory = t.Focused.Directory.Foreground(primary)
t.Focused.File = t.Focused.File.Foreground(text)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(danger)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(danger)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(accent)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(accent)
t.Focused.Option = t.Focused.Option.Foreground(text)
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(accent)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(success)
t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(success)
t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(text)
t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(faint)
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(text).Background(primary)
t.Focused.Next = t.Focused.FocusedButton
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(paletteModV1(current.Default, 100))
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(text).Background(primary)
t.Focused.Next = t.Focused.FocusedButton
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(paletteMod(current.Default, 100))
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primary)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(faint)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent)
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primary)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(faint)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent)
// Blurred styles (same palette, but without the focused border/indicators).
t.Blurred = t.Focused
t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
t.Blurred.Card = t.Blurred.Base
t.Blurred.MultiSelectSelector = lipgloss.NewStyle().SetString(" ")
t.Blurred.NextIndicator = lipgloss.NewStyle()
t.Blurred.PrevIndicator = lipgloss.NewStyle()
// Blurred styles (same palette, but without the focused border/indicators).
t.Blurred = t.Focused
t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
t.Blurred.Card = t.Blurred.Base
t.Blurred.MultiSelectSelector = lipgloss.NewStyle().SetString(" ")
t.Blurred.NextIndicator = lipgloss.NewStyle()
t.Blurred.PrevIndicator = lipgloss.NewStyle()
// Group styles.
t.Group.Title = t.Focused.Title
t.Group.Description = t.Focused.Description
// Group styles.
t.Group.Title = t.Focused.Title
t.Group.Description = t.Focused.Description
return t
return t
})
}
func paletteColorV1(p Palette) lipgloss.TerminalColor {
func paletteColor(p Palette) color.Color {
if p.Default != "" {
return lipgloss.Color(p.Default)
}
return lipgloss.Color("#ffffff")
}
func paletteModV1(p Palette, mod int) lipgloss.TerminalColor {
func paletteMod(p Palette, mod int) color.Color {
if mod != 0 {
if v, ok := p.Mods[mod]; ok && v != "" {
return lipgloss.Color(v)
}
}
return paletteColorV1(p)
return paletteColor(p)
}
+72
View File
@@ -0,0 +1,72 @@
package transport
import (
"errors"
"sync"
"google.golang.org/grpc"
)
// MultiplayerConnectMode controls how the client reaches multiplayer.
type MultiplayerConnectMode int
const (
MultiplayerConnectAuto MultiplayerConnectMode = iota
MultiplayerConnectDisableWG
)
type connectionCloser interface {
Close() error
}
var (
multiplayerConnectModeMu sync.RWMutex
multiplayerConnectMode = MultiplayerConnectAuto
connClosers sync.Map // *grpc.ClientConn -> connectionCloser
)
// SetMultiplayerConnectMode configures future multiplayer client dials.
func SetMultiplayerConnectMode(mode MultiplayerConnectMode) {
multiplayerConnectModeMu.Lock()
multiplayerConnectMode = mode
multiplayerConnectModeMu.Unlock()
}
func getMultiplayerConnectMode() MultiplayerConnectMode {
multiplayerConnectModeMu.RLock()
mode := multiplayerConnectMode
multiplayerConnectModeMu.RUnlock()
return mode
}
func registerConnCloser(conn *grpc.ClientConn, closer connectionCloser) {
if conn == nil || closer == nil {
return
}
connClosers.Store(conn, closer)
}
func unregisterConnCloser(conn *grpc.ClientConn) connectionCloser {
if conn == nil {
return nil
}
if closer, ok := connClosers.LoadAndDelete(conn); ok {
return closer.(connectionCloser)
}
return nil
}
// CloseGRPCConnection closes a client connection and any transport-specific
// resources attached to it, such as the multiplayer WireGuard tunnel.
func CloseGRPCConnection(conn *grpc.ClientConn) error {
if conn == nil {
return nil
}
var errs []error
errs = append(errs, conn.Close())
if closer := unregisterConnCloser(conn); closer != nil {
errs = append(errs, closer.Close())
}
return errors.Join(errs...)
}
+81 -3
View File
@@ -22,6 +22,7 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
"os"
@@ -50,6 +51,22 @@ type TokenAuth struct {
token string
}
type multiplayerDialStrategy int
const (
multiplayerDialDirect multiplayerDialStrategy = iota
multiplayerDialWireGuard
)
const (
connectStatusGRPCMTLS = "grpc/mtls"
connectStatusWireGuard = "wireguard"
connectStatusGRPCMTLSOverWireGuard = "grpc/mtls over wireguard"
)
// ConnectStatusFn receives human-readable connection phase updates.
type ConnectStatusFn func(string)
// Return value is mapped to request headers.
func (t TokenAuth) GetRequestMetadata(ctx context.Context, in ...string) (map[string]string, error) {
return map[string]string{
@@ -63,10 +80,64 @@ func (TokenAuth) RequireTransportSecurity() bool {
// MTLSConnect - Connect to the sliver server
func MTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
tlsConfig, err := getTLSConfig(config.CACertificate, config.Certificate, config.PrivateKey)
return MTLSConnectWithStatus(config, nil)
}
// MTLSConnectWithStatus connects to the sliver server and optionally reports
// transport phase updates, such as WireGuard setup and gRPC dialing.
func MTLSConnectWithStatus(config *assets.ClientConfig, statusFn ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
strategy, err := selectMultiplayerDialStrategy(config)
if err != nil {
return nil, nil, err
}
switch strategy {
case multiplayerDialWireGuard:
return wireGuardMTLSConnect(config, statusFn)
default:
return directMTLSConnect(config, statusFn)
}
}
func selectMultiplayerDialStrategy(config *assets.ClientConfig) (multiplayerDialStrategy, error) {
if config == nil {
return multiplayerDialDirect, errors.New("client config is required")
}
switch getMultiplayerConnectMode() {
case MultiplayerConnectDisableWG:
return multiplayerDialDirect, nil
default:
if config.WG == nil {
return multiplayerDialDirect, nil
}
if err := validateWireGuardConfig(config); err != nil {
return multiplayerDialDirect, err
}
return multiplayerDialWireGuard, nil
}
}
func directMTLSConnect(config *assets.ClientConfig, statusFn ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
notifyConnectStatus(statusFn, connectStatusGRPCMTLS)
options, err := newMTLSDialOptions(config)
if err != nil {
return nil, nil, err
}
return dialRPCClient(fmt.Sprintf("%s:%d", config.LHost, config.LPort), options, nil)
}
func notifyConnectStatus(statusFn ConnectStatusFn, status string) {
if statusFn != nil {
statusFn(status)
}
}
func newMTLSDialOptions(config *assets.ClientConfig) ([]grpc.DialOption, error) {
tlsConfig, err := getTLSConfig(config.CACertificate, config.Certificate, config.PrivateKey)
if err != nil {
return nil, err
}
transportCreds := credentials.NewTLS(tlsConfig)
callCreds := credentials.PerRPCCredentials(TokenAuth{token: config.Token})
options := []grpc.DialOption{
@@ -78,11 +149,18 @@ func MTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.Clie
if kp, ok := getKeepaliveParams(); ok {
options = append(options, grpc.WithKeepaliveParams(kp))
}
return options, nil
}
connection, err := grpc.NewClient(fmt.Sprintf("%s:%d", config.LHost, config.LPort), options...)
func dialRPCClient(target string, options []grpc.DialOption, closer connectionCloser) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
connection, err := grpc.NewClient(target, options...)
if err != nil {
if closer != nil {
_ = closer.Close()
}
return nil, nil, err
}
registerConnCloser(connection, closer)
// Preserve the legacy grpc.DialContext + grpc.WithBlock behavior: block
// until the channel becomes READY or the timeout expires.
@@ -97,7 +175,7 @@ func MTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.Clie
break
}
if !connection.WaitForStateChange(ctx, state) {
_ = connection.Close()
_ = CloseGRPCConnection(connection)
return nil, nil, ctx.Err()
}
}
+109
View File
@@ -0,0 +1,109 @@
package transport
import (
"errors"
"testing"
"github.com/bishopfox/sliver/client/assets"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
)
func TestSelectMultiplayerDialStrategyLegacyConfigUsesDirectMTLS(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyRejectsIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientIP: "100.64.0.2",
},
})
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
t.Fatalf("expected incomplete WG config error, got %v", err)
}
}
func TestSelectMultiplayerDialStrategyUsesWireGuardWhenConfigComplete(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
})
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialWireGuard {
t.Fatalf("expected WireGuard strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyDisableWGOverridesCompleteConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectDisableWG)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
})
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct strategy when WG is disabled, got %v", strategy)
}
}
func setTestMultiplayerConnectMode(t *testing.T, mode MultiplayerConnectMode) {
t.Helper()
previous := getMultiplayerConnectMode()
SetMultiplayerConnectMode(mode)
t.Cleanup(func() {
SetMultiplayerConnectMode(previous)
})
}
func TestCloseGRPCConnectionClosesConnBeforeTransportCloser(t *testing.T) {
conn, err := grpc.NewClient("passthrough:///sliver-test", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("create grpc client: %v", err)
}
var stateSeen connectivity.State
registerConnCloser(conn, testConnectionCloser(func() error {
stateSeen = conn.GetState()
return nil
}))
if err := CloseGRPCConnection(conn); err != nil {
t.Fatalf("close grpc connection: %v", err)
}
if stateSeen != connectivity.Shutdown {
t.Fatalf("expected transport closer to run after grpc shutdown, got state %v", stateSeen)
}
}
type testConnectionCloser func() error
func (fn testConnectionCloser) Close() error {
return fn()
}
+319
View File
@@ -0,0 +1,319 @@
package transport
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"net"
"net/netip"
"strconv"
"strings"
"sync"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/device"
"google.golang.org/grpc"
)
const (
multiplayerWireGuardDefaultServerIP = "100.65.0.1"
multiplayerWireGuardMTU = 1420
multiplayerWireGuardKeepalive = 25
multiplayerWireGuardDialTimeout = 30 * time.Second
multiplayerWireGuardRetryDelay = 250 * time.Millisecond
)
var (
ErrMissingWireGuardConfig = errors.New("operator config has no wg block")
ErrIncompleteWireGuardConfig = errors.New("operator config has incomplete wg block")
multiplayerWireGuardIdleTimeout = 5 * time.Second
wireGuardTunnelCacheMu sync.Mutex
wireGuardTunnelCache = map[string]*cachedWireGuardTunnel{}
)
type wireGuardTunnel struct {
dev *device.Device
net *transportNet
closeOnce sync.Once
}
type cachedWireGuardTunnel struct {
tunnel *wireGuardTunnel
target string
timer *time.Timer
}
type idleWireGuardTunnelCloser struct {
key string
tunnel *wireGuardTunnel
target string
}
func (t *wireGuardTunnel) Close() error {
if t == nil {
return nil
}
t.closeOnce.Do(func() {
if t.dev != nil {
t.dev.Close()
<-t.dev.Wait()
}
})
return nil
}
func (t *wireGuardTunnel) DialContext(ctx context.Context, address string) (net.Conn, error) {
if t == nil || t.net == nil {
return nil, errors.New("wireguard tunnel is not initialized")
}
return t.net.DialContext(ctx, "tcp", address)
}
func (c *idleWireGuardTunnelCloser) Close() error {
if c == nil || c.key == "" || c.tunnel == nil {
return nil
}
cacheIdleWireGuardTunnel(c.key, c.tunnel, c.target)
return nil
}
func wireGuardMTLSConnect(config *assets.ClientConfig, statusFn ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
deadline := time.Now().Add(multiplayerWireGuardDialTimeout)
var lastErr error
attempts := 0
for {
attempts++
notifyConnectStatus(statusFn, connectStatusWireGuard)
cacheKey, tunnel, target, err := acquireWireGuardTunnel(config)
if err != nil {
return nil, nil, err
}
options, err := newMTLSDialOptions(config)
if err != nil {
_ = tunnel.Close()
return nil, nil, err
}
options = append(options, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return tunnel.DialContext(ctx, addr)
}))
notifyConnectStatus(statusFn, connectStatusGRPCMTLSOverWireGuard)
rpcClient, conn, err := dialRPCClient(target, options, nil)
if err == nil {
registerConnCloser(conn, &idleWireGuardTunnelCloser{
key: cacheKey,
tunnel: tunnel,
target: target,
})
return rpcClient, conn, nil
}
_ = tunnel.Close()
lastErr = err
if !errors.Is(err, context.DeadlineExceeded) || !time.Now().Before(deadline) {
break
}
time.Sleep(multiplayerWireGuardRetryDelay)
}
if attempts > 1 {
return nil, nil, fmt.Errorf("wireguard multiplayer connect failed after %d attempts: %w", attempts, lastErr)
}
return nil, nil, lastErr
}
func acquireWireGuardTunnel(config *assets.ClientConfig) (string, *wireGuardTunnel, string, error) {
key, err := wireGuardTunnelCacheKey(config)
if err != nil {
return "", nil, "", err
}
wireGuardTunnelCacheMu.Lock()
if cached := wireGuardTunnelCache[key]; cached != nil && cached.tunnel != nil {
delete(wireGuardTunnelCache, key)
if cached.timer != nil {
cached.timer.Stop()
cached.timer = nil
}
tunnel := cached.tunnel
target := cached.target
wireGuardTunnelCacheMu.Unlock()
return key, tunnel, target, nil
}
wireGuardTunnelCacheMu.Unlock()
tunnel, target, err := newWireGuardTunnel(config)
if err != nil {
return "", nil, "", err
}
return key, tunnel, target, nil
}
func cacheIdleWireGuardTunnel(key string, tunnel *wireGuardTunnel, target string) {
if key == "" || tunnel == nil {
return
}
wireGuardTunnelCacheMu.Lock()
previous := wireGuardTunnelCache[key]
cached := &cachedWireGuardTunnel{
tunnel: tunnel,
target: target,
}
wireGuardTunnelCache[key] = cached
if previous != nil && previous.timer != nil {
previous.timer.Stop()
}
cached.timer = time.AfterFunc(multiplayerWireGuardIdleTimeout, func() {
wireGuardTunnelCacheMu.Lock()
current := wireGuardTunnelCache[key]
if current != cached || current == nil {
wireGuardTunnelCacheMu.Unlock()
return
}
delete(wireGuardTunnelCache, key)
current.timer = nil
tunnel := current.tunnel
wireGuardTunnelCacheMu.Unlock()
if tunnel != nil {
_ = tunnel.Close()
}
})
wireGuardTunnelCacheMu.Unlock()
if previous != nil && previous.tunnel != nil {
_ = previous.tunnel.Close()
}
}
func wireGuardTunnelCacheKey(config *assets.ClientConfig) (string, error) {
if err := validateWireGuardConfig(config); err != nil {
return "", err
}
return strings.Join([]string{
strings.TrimSpace(config.LHost),
strconv.Itoa(config.LPort),
strings.TrimSpace(config.WG.ServerPubKey),
strings.TrimSpace(config.WG.ClientPrivateKey),
strings.TrimSpace(config.WG.ClientIP),
strings.TrimSpace(config.WG.ServerIP),
}, "\x00"), nil
}
func newWireGuardTunnel(config *assets.ClientConfig) (*wireGuardTunnel, string, error) {
if err := validateWireGuardConfig(config); err != nil {
return nil, "", err
}
if config.LPort <= 0 || 65535 < config.LPort {
return nil, "", fmt.Errorf("invalid multiplayer port %d", config.LPort)
}
clientIP, err := netip.ParseAddr(strings.TrimSpace(config.WG.ClientIP))
if err != nil {
return nil, "", fmt.Errorf("invalid wireguard client IP %q: %w", config.WG.ClientIP, err)
}
serverIPValue := strings.TrimSpace(config.WG.ServerIP)
if serverIPValue == "" {
serverIPValue = multiplayerWireGuardDefaultServerIP
}
serverIP, err := netip.ParseAddr(serverIPValue)
if err != nil {
return nil, "", fmt.Errorf("invalid wireguard server IP %q: %w", serverIPValue, err)
}
endpoint, err := resolveWireGuardEndpoint(config.LHost, config.LPort)
if err != nil {
return nil, "", err
}
tun, tNet, err := createTransportNetTUN([]netip.Addr{clientIP}, multiplayerWireGuardMTU)
if err != nil {
return nil, "", err
}
dev := device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "[client/wg] "))
wgConf := bytes.NewBuffer(nil)
fmt.Fprintf(wgConf, "private_key=%s\n", strings.TrimSpace(config.WG.ClientPrivateKey))
fmt.Fprintf(wgConf, "public_key=%s\n", strings.TrimSpace(config.WG.ServerPubKey))
fmt.Fprintf(wgConf, "endpoint=%s\n", endpoint.String())
fmt.Fprintf(wgConf, "allowed_ip=%s\n", multiplayerWireGuardAllowedIP(serverIP))
fmt.Fprintf(wgConf, "persistent_keepalive_interval=%d\n", multiplayerWireGuardKeepalive)
if err := dev.IpcSetOperation(bufio.NewReader(wgConf)); err != nil {
dev.Close()
return nil, "", err
}
if err := dev.Up(); err != nil {
dev.Close()
return nil, "", err
}
target := net.JoinHostPort(serverIP.String(), strconv.Itoa(config.LPort))
return &wireGuardTunnel{dev: dev, net: tNet}, target, nil
}
func validateWireGuardConfig(config *assets.ClientConfig) error {
if config == nil {
return errors.New("client config is required")
}
if config.WG == nil {
return ErrMissingWireGuardConfig
}
missing := make([]string, 0, 3)
if strings.TrimSpace(config.WG.ServerPubKey) == "" {
missing = append(missing, "server_pub_key")
}
if strings.TrimSpace(config.WG.ClientPrivateKey) == "" {
missing = append(missing, "client_private_key")
}
if strings.TrimSpace(config.WG.ClientIP) == "" {
missing = append(missing, "client_ip")
}
if len(missing) != 0 {
return fmt.Errorf("%w: missing %s", ErrIncompleteWireGuardConfig, strings.Join(missing, ", "))
}
return nil
}
func resolveWireGuardEndpoint(host string, port int) (netip.AddrPort, error) {
host = strings.Trim(strings.TrimSpace(host), "[]")
if host == "" {
return netip.AddrPort{}, errors.New("wireguard endpoint host is required")
}
if addr, err := netip.ParseAddr(host); err == nil {
return netip.AddrPortFrom(addr, uint16(port)), nil
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return netip.AddrPort{}, fmt.Errorf("failed to resolve wireguard endpoint %q: %w", host, err)
}
for _, ip := range ips {
if ip.IsValid() {
return netip.AddrPortFrom(ip, uint16(port)), nil
}
}
return netip.AddrPort{}, fmt.Errorf("wireguard endpoint %q did not resolve to a usable IP", host)
}
func multiplayerWireGuardAllowedIP(addr netip.Addr) string {
if addr.Is6() {
return fmt.Sprintf("%s/128", addr)
}
return fmt.Sprintf("%s/32", addr)
}
+267
View File
@@ -0,0 +1,267 @@
package transport
import (
"context"
"crypto/rand"
"fmt"
"math/big"
insecurerand "math/rand"
"net"
"net/netip"
"os"
"syscall"
"golang.zx2c4.com/wireguard/tun"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
type transportTun struct {
ep *channel.Endpoint
stack *stack.Stack
events chan tun.Event
notifyHandle *channel.NotificationHandle
incomingPacket chan *buffer.View
mtu int
hasV4 bool
hasV6 bool
}
type transportNet transportTun
const (
transportTCPReceiveBufferMax = 8 << 20
transportTCPSendBufferMax = 6 << 20
)
func createTransportNetTUN(localAddresses []netip.Addr, mtu int) (tun.Device, *transportNet, error) {
n, err := rand.Int(rand.Reader, big.NewInt(0xFFFFFFFF))
if err != nil {
return nil, nil, fmt.Errorf("failed to create random number: %w", err)
}
clock := tcpip.NewStdClock()
rng := insecurerand.New(insecurerand.NewSource(n.Int64()))
opts := stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
HandleLocal: true,
IPTables: stack.DefaultTables(clock, rng),
}
dev := &transportTun{
ep: channel.New(1024, uint32(mtu), ""),
stack: stack.New(opts),
events: make(chan tun.Event, 10),
incomingPacket: make(chan *buffer.View),
mtu: mtu,
}
if err := configureTransportTCPStack(dev.stack); err != nil {
return nil, nil, err
}
dev.notifyHandle = dev.ep.AddNotify(dev)
if tcpipErr := dev.stack.CreateNIC(1, dev.ep); tcpipErr != nil {
return nil, nil, fmt.Errorf("CreateNIC: %v", tcpipErr)
}
for _, ip := range localAddresses {
var protoNumber tcpip.NetworkProtocolNumber
if ip.Is4() {
protoNumber = ipv4.ProtocolNumber
} else if ip.Is6() {
protoNumber = ipv6.ProtocolNumber
}
protoAddr := tcpip.ProtocolAddress{
Protocol: protoNumber,
AddressWithPrefix: tcpip.AddrFromSlice(ip.AsSlice()).WithPrefix(),
}
if tcpipErr := dev.stack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}); tcpipErr != nil {
return nil, nil, fmt.Errorf("AddProtocolAddress(%v): %v", ip, tcpipErr)
}
if ip.Is4() {
dev.hasV4 = true
} else if ip.Is6() {
dev.hasV6 = true
}
}
if dev.hasV4 {
dev.stack.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: 1})
}
if dev.hasV6 {
dev.stack.AddRoute(tcpip.Route{Destination: header.IPv6EmptySubnet, NIC: 1})
}
dev.events <- tun.EventUp
return dev, (*transportNet)(dev), nil
}
func configureTransportTCPStack(ipstack *stack.Stack) error {
sackEnabledOpt := tcpip.TCPSACKEnabled(true)
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); tcpipErr != nil {
return fmt.Errorf("could not enable TCP SACK: %v", tcpipErr)
}
tcpRecoveryOpt := tcpip.TCPRecovery(0)
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpRecoveryOpt); tcpipErr != nil {
return fmt.Errorf("could not disable TCP RACK: %v", tcpipErr)
}
renoOpt := tcpip.CongestionControlOption("reno")
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &renoOpt); tcpipErr != nil {
return fmt.Errorf("could not set TCP congestion control to reno: %v", tcpipErr)
}
tcpRXBufOpt := tcpip.TCPReceiveBufferSizeRangeOption{
Min: tcp.MinBufferSize,
Default: tcp.DefaultSendBufferSize,
Max: transportTCPReceiveBufferMax,
}
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpRXBufOpt); tcpipErr != nil {
return fmt.Errorf("could not set TCP RX buffer size: %v", tcpipErr)
}
tcpTXBufOpt := tcpip.TCPSendBufferSizeRangeOption{
Min: tcp.MinBufferSize,
Default: tcp.DefaultReceiveBufferSize,
Max: transportTCPSendBufferMax,
}
if tcpipErr := ipstack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpTXBufOpt); tcpipErr != nil {
return fmt.Errorf("could not set TCP TX buffer size: %v", tcpipErr)
}
return nil
}
func (tun *transportTun) Name() (string, error) {
return "go", nil
}
func (tun *transportTun) File() *os.File {
return nil
}
func (tun *transportTun) Events() <-chan tun.Event {
return tun.events
}
func (tun *transportTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
view, ok := <-tun.incomingPacket
if !ok {
return 0, os.ErrClosed
}
n, err := view.Read(buf[0][offset:])
if err != nil {
return 0, err
}
sizes[0] = n
return 1, nil
}
func (tun *transportTun) Write(buf [][]byte, offset int) (int, error) {
for _, buf := range buf {
packet := buf[offset:]
if len(packet) == 0 {
continue
}
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)})
switch packet[0] >> 4 {
case 4:
tun.ep.InjectInbound(header.IPv4ProtocolNumber, pkb)
case 6:
tun.ep.InjectInbound(header.IPv6ProtocolNumber, pkb)
default:
return 0, syscall.EAFNOSUPPORT
}
}
return len(buf), nil
}
func (tun *transportTun) WriteNotify() {
pkt := tun.ep.Read()
if pkt == nil {
return
}
view := pkt.ToView()
pkt.DecRef()
tun.incomingPacket <- view
}
func (tun *transportTun) Close() error {
tun.stack.RemoveNIC(1)
tun.stack.Close()
tun.ep.RemoveNotify(tun.notifyHandle)
tun.ep.Close()
if tun.events != nil {
close(tun.events)
}
if tun.incomingPacket != nil {
close(tun.incomingPacket)
}
return nil
}
func (tun *transportTun) MTU() (int, error) {
return tun.mtu, nil
}
func (tun *transportTun) BatchSize() int {
return 1
}
func convertTransportFullAddr(endpoint netip.AddrPort) (tcpip.FullAddress, tcpip.NetworkProtocolNumber) {
var protoNumber tcpip.NetworkProtocolNumber
if endpoint.Addr().Is4() {
protoNumber = ipv4.ProtocolNumber
} else {
protoNumber = ipv6.ProtocolNumber
}
return tcpip.FullAddress{
NIC: 1,
Addr: tcpip.AddrFromSlice(endpoint.Addr().AsSlice()),
Port: endpoint.Port(),
}, protoNumber
}
func (netstack *transportNet) DialContextTCPAddrPort(ctx context.Context, addr netip.AddrPort) (*gonet.TCPConn, error) {
fa, pn := convertTransportFullAddr(addr)
return gonet.DialContextTCP(ctx, netstack.stack, fa, pn)
}
func (netstack *transportNet) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
switch network {
case "tcp", "tcp4", "tcp6":
default:
return nil, fmt.Errorf("unsupported network %q", network)
}
host, portString, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
port, err := net.LookupPort("tcp", portString)
if err != nil {
return nil, err
}
addr, err := netip.ParseAddr(host)
if err != nil {
return nil, err
}
return netstack.DialContextTCPAddrPort(ctx, netip.AddrPortFrom(addr, uint16(port)))
}
+86
View File
@@ -0,0 +1,86 @@
package transport
import (
"testing"
"time"
"github.com/bishopfox/sliver/client/assets"
)
func TestWireGuardTunnelCacheKeyIncludesConfigMaterial(t *testing.T) {
config := &assets.ClientConfig{
LHost: "127.0.0.1",
LPort: 31337,
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.65.0.2",
ServerIP: "100.65.0.1",
},
}
key1, err := wireGuardTunnelCacheKey(config)
if err != nil {
t.Fatalf("cache key: %v", err)
}
config.WG.ClientPrivateKey = "other-client-priv"
key2, err := wireGuardTunnelCacheKey(config)
if err != nil {
t.Fatalf("cache key after config change: %v", err)
}
if key1 == key2 {
t.Fatal("expected cache key to change when wireguard config changes")
}
}
func TestCacheIdleWireGuardTunnelRemovesIdleTunnel(t *testing.T) {
resetWireGuardTunnelCacheForTest(t)
previousIdleTimeout := multiplayerWireGuardIdleTimeout
multiplayerWireGuardIdleTimeout = 10 * time.Millisecond
t.Cleanup(func() {
multiplayerWireGuardIdleTimeout = previousIdleTimeout
})
const cacheKey = "test-cache-key"
cacheIdleWireGuardTunnel(cacheKey, &wireGuardTunnel{}, "100.65.0.1:31337")
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
wireGuardTunnelCacheMu.Lock()
_, exists := wireGuardTunnelCache[cacheKey]
wireGuardTunnelCacheMu.Unlock()
if !exists {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("expected idle tunnel cache entry to be removed")
}
func resetWireGuardTunnelCacheForTest(t *testing.T) {
t.Helper()
wireGuardTunnelCacheMu.Lock()
for key, shared := range wireGuardTunnelCache {
if shared != nil && shared.timer != nil {
shared.timer.Stop()
}
delete(wireGuardTunnelCache, key)
}
wireGuardTunnelCacheMu.Unlock()
t.Cleanup(func() {
wireGuardTunnelCacheMu.Lock()
for key, shared := range wireGuardTunnelCache {
if shared != nil && shared.timer != nil {
shared.timer.Stop()
}
delete(wireGuardTunnelCache, key)
}
wireGuardTunnelCacheMu.Unlock()
})
}
+109
View File
@@ -0,0 +1,109 @@
package docs
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"embed"
"io/fs"
"path"
"strings"
)
const (
markdownDir = "sliver-docs/pages/docs/md"
asciinemaDir = "sliver-docs/public/asciinema"
)
var (
// rawFS stores embedded docs content from the Sliver docs site.
//go:embed sliver-docs/pages/docs/md/*.md sliver-docs/public/asciinema/*
rawFS embed.FS
// FS provides access to the embedded markdown docs rooted at the md directory.
FS = mustSubFS(rawFS, markdownDir)
// AsciinemaFS provides access to the embedded asciinema recordings.
AsciinemaFS = mustSubFS(rawFS, asciinemaDir)
)
// Doc is a single embedded markdown document.
type Doc struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Docs mirrors the JSON shape used by the docs site.
type Docs struct {
Docs []Doc `json:"docs"`
}
// All returns every embedded markdown document.
func All() (Docs, error) {
entries, err := fs.ReadDir(FS, ".")
if err != nil {
return Docs{}, err
}
docs := make([]Doc, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || path.Ext(entry.Name()) != ".md" {
continue
}
content, err := fs.ReadFile(FS, entry.Name())
if err != nil {
return Docs{}, err
}
docs = append(docs, Doc{
Name: strings.TrimSuffix(entry.Name(), path.Ext(entry.Name())),
Content: string(content),
})
}
return Docs{Docs: docs}, nil
}
// Read returns the embedded markdown for a doc name, with or without the .md suffix.
func Read(name string) (string, error) {
if !strings.HasSuffix(name, ".md") {
name += ".md"
}
content, err := fs.ReadFile(FS, name)
if err != nil {
return "", err
}
return string(content), nil
}
// ReadAsciinema returns the embedded asciinema recording, with or without the .cast suffix.
func ReadAsciinema(name string) ([]byte, error) {
if !strings.HasSuffix(name, ".cast") {
name += ".cast"
}
return fs.ReadFile(AsciinemaFS, name)
}
func mustSubFS(embedded fs.FS, dir string) fs.FS {
sub, err := fs.Sub(embedded, dir)
if err != nil {
panic(err)
}
return sub
}
+139
View File
@@ -0,0 +1,139 @@
package docs
import (
"io/fs"
"os"
"path/filepath"
"sort"
"testing"
)
func TestAllMatchesEmbeddedMarkdownSources(t *testing.T) {
dirEntries, err := os.ReadDir("sliver-docs/pages/docs/md")
if err != nil {
t.Fatalf("read markdown source directory: %v", err)
}
expected := make(map[string]string, len(dirEntries))
for _, entry := range dirEntries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
continue
}
content, err := os.ReadFile(filepath.Join("sliver-docs/pages/docs/md", entry.Name()))
if err != nil {
t.Fatalf("read markdown source %q: %v", entry.Name(), err)
}
expected[entry.Name()] = string(content)
}
embeddedEntries, err := fs.ReadDir(FS, ".")
if err != nil {
t.Fatalf("read embedded directory: %v", err)
}
if len(embeddedEntries) != len(expected) {
t.Fatalf("embedded entry count = %d, want %d", len(embeddedEntries), len(expected))
}
docs, err := All()
if err != nil {
t.Fatalf("All(): %v", err)
}
if len(docs.Docs) != len(expected) {
t.Fatalf("All() doc count = %d, want %d", len(docs.Docs), len(expected))
}
gotNames := make([]string, 0, len(docs.Docs))
for _, doc := range docs.Docs {
filename := doc.Name + ".md"
gotNames = append(gotNames, doc.Name)
expectedContent, ok := expected[filename]
if !ok {
t.Fatalf("unexpected embedded doc %q", filename)
}
if doc.Content != expectedContent {
t.Fatalf("embedded content mismatch for %q", filename)
}
}
wantNames := make([]string, 0, len(expected))
for filename := range expected {
wantNames = append(wantNames, filename[:len(filename)-len(filepath.Ext(filename))])
}
sort.Strings(gotNames)
sort.Strings(wantNames)
for i := range gotNames {
if gotNames[i] != wantNames[i] {
t.Fatalf("embedded names mismatch at index %d: got %q want %q", i, gotNames[i], wantNames[i])
}
}
}
func TestReadAcceptsDocNameWithoutSuffix(t *testing.T) {
want, err := os.ReadFile(filepath.Join("sliver-docs/pages/docs/md", "Getting Started.md"))
if err != nil {
t.Fatalf("read source doc: %v", err)
}
got, err := Read("Getting Started")
if err != nil {
t.Fatalf("Read(): %v", err)
}
if got != string(want) {
t.Fatal("Read() returned unexpected content")
}
}
func TestAsciinemaFSMatchesEmbeddedSources(t *testing.T) {
dirEntries, err := os.ReadDir("sliver-docs/public/asciinema")
if err != nil {
t.Fatalf("read asciinema source directory: %v", err)
}
expected := make(map[string][]byte, len(dirEntries))
for _, entry := range dirEntries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".cast" {
continue
}
content, err := os.ReadFile(filepath.Join("sliver-docs/public/asciinema", entry.Name()))
if err != nil {
t.Fatalf("read asciinema source %q: %v", entry.Name(), err)
}
expected[entry.Name()] = content
}
embeddedEntries, err := fs.ReadDir(AsciinemaFS, ".")
if err != nil {
t.Fatalf("read embedded asciinema directory: %v", err)
}
if len(embeddedEntries) != len(expected) {
t.Fatalf("embedded asciinema entry count = %d, want %d", len(embeddedEntries), len(expected))
}
for filename, want := range expected {
got, err := fs.ReadFile(AsciinemaFS, filename)
if err != nil {
t.Fatalf("read embedded asciinema %q: %v", filename, err)
}
if string(got) != string(want) {
t.Fatalf("embedded asciinema mismatch for %q", filename)
}
}
}
func TestReadAsciinemaAcceptsNameWithoutSuffix(t *testing.T) {
want, err := os.ReadFile(filepath.Join("sliver-docs/public/asciinema", "intro.cast"))
if err != nil {
t.Fatalf("read source asciinema: %v", err)
}
got, err := ReadAsciinema("intro")
if err != nil {
t.Fatalf("ReadAsciinema(): %v", err)
}
if string(got) != string(want) {
t.Fatal("ReadAsciinema() returned unexpected content")
}
}
+232
View File
@@ -0,0 +1,232 @@
# Sliver AI Agentic Loop
## Goal
Port the example agentic loop from `examples/codex/codex-rs` into Sliver's AI chat so the server can:
- run a multi-step Responses API turn
- emit turn and item lifecycle events similar to Codex
- persist reasoning and tool-call items for sync and restoration
- keep those non-chat items out of the model context window
- render the new items in the client TUI
## How The Codex Example Loop Works
The Codex example splits the loop into three layers.
### 1. Provider/Protocol Layer
The protocol layer receives raw model events such as:
- turn started / completed / failed
- reasoning deltas and completed reasoning items
- command/tool begin and end notifications
- assistant messages
These provider events are fine-grained and often arrive as partial updates.
### 2. Event Processor Layer
`event_processor_with_jsonl_output.rs` converts the raw provider stream into a stable thread event model.
The important behaviors are:
- a turn gets a lifecycle: `turn.started`, then `turn.completed` or `turn.failed`
- each logical item gets a stable id
- begin/end pairs are folded into a single item lifecycle
- reasoning is emitted as its own item type
- tool calls and command executions are emitted as their own item types
- active items can be updated in place before completion
This is the key abstraction that makes the TUI and persisted history simple.
### 3. TUI Layer
The Codex TUI consumes those stable events rather than reloading the whole thread.
That lets it:
- append new items incrementally
- update an existing in-flight item in place
- keep the transcript stable while a turn runs
- restore the same item types from stored history later
## Sliver Port
Sliver ports the same shape, adapted to its existing RPC and database model.
### Conversation State
`AIConversation` now stores:
- `ActiveTurnID`
- `TurnState`
- `TargetSessionID`
- `TargetBeaconID`
The turn fields let clients restore "waiting", "completed", and "failed" state without inferring it from the last chat message.
The target fields bind a conversation to the active session or beacon so server-side tools can default to the conversation target.
### Message State
`AIConversationMessage` now stores:
- `Kind`: chat, reasoning, or tool call
- `Visibility`: context or UI-only
- `IncludeInContext`: whether the block should be replayed into the provider context window
- `State`: in progress, completed, or failed
- `TurnID`
- `ItemID`
- tool call metadata: name, arguments, result, error text, tool call id
This keeps one ordered transcript table while still distinguishing chat context from UI-only telemetry.
### UI-Only Persistence
Reasoning, tool-call, and intermediate assistant chat items can be stored as normal conversation messages with `Visibility = UI_ONLY`.
That gives Sliver:
- cross-client sync through the existing conversation storage path
- transcript restoration after reconnect or TUI restart
- ordered replay of an entire turn, including intermediate items
At the same time, the model context builder keys off `IncludeInContext`, so UI visibility and provider visibility are now independent.
### Event Model
Sliver now publishes `AIConversationEvent` payloads instead of raw `AIConversation` snapshots.
Supported event types:
- `CONVERSATION_UPDATED`
- `CONVERSATION_DELETED`
- `TURN_STARTED`
- `TURN_COMPLETED`
- `TURN_FAILED`
- `MESSAGE_STARTED`
- `MESSAGE_UPDATED`
- `MESSAGE_COMPLETED`
Each event can carry:
- a conversation summary
- a single message/item
- the turn id
- optional error text
This mirrors the Codex pattern of stable turn and item lifecycle events.
## Server Turn Flow
For each new user message:
1. The user chat message is saved as a normal context-visible chat item.
2. The server resolves runtime provider/model settings.
3. A new turn id is generated and persisted with `TURN_STARTED`.
4. If the runtime supports the Responses API loop, Sliver runs the agentic loop.
5. Reasoning items are persisted as completed UI-only reasoning blocks.
6. Tool calls are persisted as in-progress UI-only tool blocks, executed locally, then updated in place to completed or failed.
7. Intermediate assistant chat blocks on tool-use turns are persisted as UI-only chat items with `IncludeInContext = false`.
8. Tool outputs are fed back into the Responses API with `previous_response_id`.
9. When the model returns final assistant text, that assistant message is persisted as a normal context-visible chat item with `IncludeInContext = true`.
10. The conversation turn state is cleared with `TURN_COMPLETED`.
If anything fails:
- a UI-only failed system message is persisted
- the conversation is marked `TURN_FAILED`
- the failure remains visible in the transcript without polluting future model context
## Tool Surface
The first port exposes a safe read-only tool set:
- `list_sessions_and_beacons`
- `fs_ls`
- `fs_cat`
- `fs_pwd`
Why read-only first:
- it proves the loop, storage, eventing, and TUI behavior
- it avoids surprising destructive remote actions in the first server-side tool release
Filesystem tools accept explicit `session_id` or `beacon_id`, but when omitted they fall back to the conversation target.
Beacon-backed tools wait for task completion on the server before emitting the completed item update.
## Context Filtering
Only chat messages with:
- `Kind = CHAT`
- `IncludeInContext = true`
are included in future provider requests.
That means these items are excluded from the model context window:
- reasoning blocks
- tool call blocks
- UI-only intermediate assistant chat blocks
- failed system status blocks
This is the main rule that preserves transcript richness without degrading prompt quality.
## TUI Behavior
The AI TUI now consumes `AIConversationEvent` incrementally.
Instead of reloading the whole conversation for every server event, it:
- merges conversation summaries into the sidebar
- upserts messages in place by message id or item id
- updates waiting state from `TurnState`
- removes deleted conversations immediately
Reasoning and tool-call blocks render as dedicated dark-grey transcript boxes.
### Reasoning Blocks
Reasoning blocks show:
- the `Reasoning` label
- the persisted reasoning text
- item state metadata
### Tool Call Blocks
Tool blocks show:
- `Tool: <name>`
- arguments
- result
- error text when present
- item state metadata
These blocks are restored exactly like chat messages because they are stored in the same ordered transcript.
## Restore And Sync
Because UI-only items live in the database:
- another Sliver client can attach to the same conversation and see the full turn history
- reconnecting the TUI restores reasoning and tool-call blocks
- turn state survives restart, so "in progress" and "failed" status can be reconstructed from persisted conversation metadata
## Differences From Codex
This port intentionally differs from the example in a few places.
- Sliver currently uses non-streaming Responses API requests, so reasoning items arrive as completed items rather than token deltas.
- Tool execution is currently a bounded read-only server-side function set, not the full Codex local tool surface.
- Sliver persists intermediate items in its conversation DB rather than only in a transient thread runtime.
The important invariant is still the same:
- turn lifecycle is explicit
- item lifecycle is explicit
- UI-only items are persisted and replayable
- only context-visible chat reaches the model
+1764 -2661
View File
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,18 @@
Sliver allows an operator to extend the local client console and its features by adding new commands based on third party tools. The easiest way to install an alias or extension is using the [armory](/docs?name=Armory).
## Server AI Access
Aliases and extensions used from the client console still live under `~/.sliver-client/aliases` and `~/.sliver-client/extensions`.
The server-side AI agent uses a separate store under `~/.sliver/ai/aliases` and `~/.sliver/ai/extensions`. To make a package available to the AI agent, copy the unpacked package directory into the matching server-side path so the manifest and payload artifacts are present together. For example:
- `~/.sliver/ai/aliases/Rubeus/alias.json`
- `~/.sliver/ai/aliases/Rubeus/Rubeus.exe`
- `~/.sliver/ai/extensions/nanodump/extension.json`
- `~/.sliver/ai/extensions/nanodump/nanodump.x64.o`
There is currently no dedicated UI for this workflow. The AI agent can search and execute only the packages that have been copied into these server-side directories.
#### Aliases Command Parsing
**⚠️ IMPORTANT:** It's important to understand that all alias commands have certain Sliver shell flags (those that appear in `--help`). Your Sliver shell commands are lexically parsed by the Sliver shell first, and only unnamed positional arguments are passed to the alias code. This means you may need to escape certain arguments in order for them to be correctly parsed.
@@ -5,8 +5,8 @@ This document describes the technical design of Sliver.
There are four major components to the Sliver ecosystem:
- **Server Console -** The server console is the main interface, which is started when you run the `sliver-server` executable. The server console is a superset of the client console. All code is shared between the client/server consoles except server-specific commands related to client (i.e., operator) management. The server console talks over an in-memory gRPC interface to the server.
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network (i.e., multiplayer mode) over mutual TLS (mTLS).
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the "mutliplayer" gRPC network listener.
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network in multiplayer mode, either directly over mutual TLS (mTLS) or, by default, through a WireGuard-protected wrapper that carries the same gRPC/mTLS service.
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the multiplayer gRPC network listener.
- **Implant -** The implant is the actual malicious code run on the target system you want remote access to.
```
@@ -17,7 +17,8 @@ There are four major components to the Sliver ecosystem:
└─────────┘ └───────────────┘
▲
│
│gRPC/mTLS
│WireGuard (default)
│or direct gRPC/mTLS
│
┌────┴────┐
│ Sliver │
@@ -25,4 +26,4 @@ There are four major components to the Sliver ecosystem:
└─────────┘
```
By implementing all functionality over this gRPC interface, and only differing the in-memory/mTLS connection types the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
By implementing all functionality over this gRPC interface, and only differing the in-memory or remote transport types, the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
+4
View File
@@ -4,6 +4,8 @@ The armory downloads packages from `github.com` and `api.github.com` so you'll n
Aliases and extensions are installed on the "sliver client"-side, and thus are not shared among operators in [multiplayer mode](/docs?name=Multi-player+Mode).
The server-side AI agent uses a separate store under `~/.sliver/ai/aliases` and `~/.sliver/ai/extensions`. If you want the AI agent to discover and execute an alias or extension, copy the unpacked package directory (the manifest plus its artifacts) into the matching server-side path. There is currently no dedicated UI or install command for the AI store.
You can also use `armory install all` to install _everything_ if you really want to.
## The Official Armory
@@ -44,6 +46,8 @@ You remove packages installed from the `armory` using the `aliases rm` and `exte
Installed alias and extension files are stored in `~/.sliver-client/` by default if you want to manually remove a package simply delete its corresponding directory and restart the client.
If you also copied a package into the server-side AI store, remove that copy separately from `~/.sliver/ai/aliases` or `~/.sliver/ai/extensions`.
## Private Armories
Sliver has experimental support for self-hosted private armories, but I have not gotten around to testing and writing the documentation for these, so you'll have to read through the source code to figure out how they work for now. I'll eventually update this documentation and release a reference implementation. If you do play around with this, just know the design is subject to change before becoming a real feature.
@@ -6,7 +6,7 @@ BOF support is provided via the [COFF Loader](https://github.com/sliverarmory/CO
The easiest way to install a BOF extension, for example [`nanodump`](https://github.com/sliverarmory/nanodump), is using the [armory](/docs?name=Armory) package manager:
**IMPORTANT:** BOF Extensions are installed per-sliver client, they are not stored on the server. Thus extensions are not shared across operators, each operator must install the extension to use it.
**IMPORTANT:** BOF extensions are still installed per-sliver client for normal operator use, so they are not shared across operators automatically. If you want the server-side AI agent to use a BOF, copy the unpacked BOF directory into `~/.sliver/ai/extensions` on the server as well, and copy any dependency such as `coff-loader` into that same server-side AI store.
```
sliver > armory install nanodump
@@ -114,6 +114,8 @@ Looking at the script we can see the BOF requires a single integer argument. The
Once the manifest is defined load it into your client using `extensions load`, locally loaded extensions do not need to be cryptographically signed. The paths in the manifest should be relative to the manifest file, parent directories are not allowed.
If you want the server-side AI agent to discover and run the same BOF, copy that unpacked directory into `~/.sliver/ai/extensions/<name>/` so the `extension.json` file and `.o` artifact are available on the server.
More details can be found on the [Aliases & Extensions](/docs?name=Aliases+and+Extensions) page.
**IMPORTANT:** For BOF extensions to be properly detected by the Sliver client, the `path` must use the `.o` file extension.
@@ -12,6 +12,7 @@ daemon:
host: ""
port: 31337
tailscale: false
disable_wg: false
logs:
level: 4
grpc_unary_payloads: false
@@ -21,6 +22,16 @@ grpc:
keepalive:
min_time_seconds: 30
permit_without_stream: true
ai:
provider: ""
model: ""
thinking_level: ""
anthropic:
api_key: ""
base_url: ""
openai:
api_key: ""
base_url: ""
watch_tower: null
go_proxy: ""
http_default:
@@ -41,8 +52,9 @@ cxx: {}
- `daemon_mode` - Enable [daemon mode](/docs?name=Daemon+Mode).
- `daemon` - Options for daemon mode.
- `host` - Interface to bind the daemon listener to. Empty string means all interfaces.
- `port` - TCP port for the daemon listener.
- `tailscale` - Enable Tailscale for daemon listener setup.
- `port` - Listen port for the multiplayer listener.
- `tailscale` - Enable Tailscale for daemon listener setup. When enabled, Sliver does not wrap multiplayer in its own WireGuard layer.
- `disable_wg` - Expose multiplayer directly over mTLS instead of the default WireGuard-protected mode.
- `logs` - Server logging options.
- `level` - `logrus` level (`0`-`6`, clamped by Sliver). `4` is `INFO`, `5` is `DEBUG`, `6` is `TRACE`.
- `grpc_unary_payloads` - Log gRPC unary payloads.
@@ -51,6 +63,12 @@ cxx: {}
- `grpc.keepalive` - gRPC keepalive enforcement configuration.
- `min_time_seconds` - Minimum time (seconds) between client pings before sending GOAWAY (`too_many_pings`).
- `permit_without_stream` - Allow client pings when there are no active streams.
- `ai` - Optional server-side AI defaults and provider credentials.
- `provider` - Default AI provider (`openai` or `anthropic`).
- `model` - Optional default model identifier.
- `thinking_level` - Optional reasoning level (for example `low`, `medium`, `high`, or `disabled`).
- `anthropic.api_key` / `openai.api_key` - Provider API key.
- `anthropic.base_url` / `openai.base_url` - Optional provider API endpoint override.
- `watch_tower` - Optional API keys for Watchtower integrations.
- `vt_api_key` - VirusTotal API key.
- `xforce_api_key` - IBM X-Force API key.
@@ -124,8 +142,21 @@ For details on generating them, see [multiplayer mode](/docs?name=Multi-player+M
"lport": 31337,
"ca_certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"private_key": "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n",
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"wg": {
"server_pub_key": "<multiplayer-wg-server-pubkey>",
"client_private_key": "<operator-wg-private-key>",
"client_pub_key": "<operator-wg-public-key>",
"client_ip": "100.65.0.10",
"server_ip": "100.65.0.1"
}
}
```
**Note:** The `operator` field is mostly a local label in the client. Server-side identity is derived from the client certificate (`CommonName`) and token material generated by the server. If you need to rename an operator, generate a new client configuration.
The `wg` block is optional:
- When present, `sliver-client` automatically brings up the multiplayer WireGuard wrapper and then dials the in-tunnel gRPC/mTLS service.
- When absent, the client connects directly to the multiplayer mTLS listener.
- Generate a direct-only profile with `new-operator --disable-wg` or `sliver-server operator --disable-wg` when you need compatibility with external gRPC clients that do not implement the multiplayer WireGuard wrapper.
@@ -1,9 +1,20 @@
Although the `sliver-client` is the default way to interact with a `sliver-server` and with implant sessions, there might be a time where you would want to automate some tasks upon reception of certain events.
To do so, one case use [sliver-script](https://github.com/moloch--/sliver-script), [sliver-py](https://github.com/moloch--/sliver-py) or write a custom client in another language. As all the communications between the client and the server are based on gRPC, any language with gRPC support should in theory be used to create a custom client.
To do so, you can use [sliver-script](https://github.com/moloch--/sliver-script), [sliver-py](https://github.com/moloch--/sliver-py), or write a custom client in another language.
Current Sliver releases generate multiplayer operator configs with a `wg` block by default. The official `sliver-client`, and Go clients that reuse Sliver's `client/assets` plus `client/transport` packages, will automatically bring up that WireGuard wrapper before dialing the in-tunnel gRPC/mTLS service.
If you are writing a custom client in another language, you have two options:
- Implement the multiplayer WireGuard wrapper described in [multiplayer mode](/docs?name=Multi-player+Mode), then connect to the in-tunnel gRPC/mTLS service.
- Keep multiplayer in direct mode by starting the listener with `multiplayer --disable-wg` or `sliver-server daemon --disable-wg`, then generate operator profiles with `new-operator --disable-wg` or `sliver-server operator --disable-wg`.
Once connected, the client/server API is still gRPC, so any language with gRPC support can in theory be used to create a custom client.
## Writing a Go client
This example uses Sliver's Go client libraries, so it works with either a direct multiplayer profile or a WireGuard-enabled profile.
In this example, we will focus on writing a custom Go client that executes a new system command on every new implant that connects to the sliver server.
Create a new Go project somewhere on your file system:
@@ -1,4 +1,6 @@
Sliver supports running in daemon mode, which automatically starts the multiplayer client listener (but not an interactive console). To connect to a daemonized server, use [multiplayer mode](/docs?name=Multi-player+Mode) operator configs.
Sliver supports running in daemon mode, which automatically starts the multiplayer listener but does not provide an interactive console. To connect to a daemonized server, use [multiplayer mode](/docs?name=Multi-player+Mode) operator configs.
By default, daemon mode starts the same WireGuard-protected multiplayer listener used by the interactive `multiplayer` command. That means the outer listener is UDP on `31337` by default, while the authenticated gRPC/mTLS service stays inside the WireGuard tunnel.
There are two ways to run in daemon mode:
@@ -13,9 +15,11 @@ If `server.yaml` does not exist, Sliver generates it. If a legacy `server.json`
- `-l, --lhost` multiplayer listener host
- `-p, --lport` multiplayer listener port
- `-t, --tailscale` enable Tailscale listener
- `--disable-wg` expose multiplayer directly over mTLS instead of using the default WireGuard wrapper
- `-f, --force` force unpack static assets
- For `sliver-server daemon`, `--lhost` and `--lport` override config values. If omitted, Sliver uses `daemon.host` and `daemon.port` from `server.yaml`.
- For normal startup (`sliver-server`) with `daemon_mode: true`, Sliver uses `daemon.tailscale` from `server.yaml`.
- For normal startup (`sliver-server`) with `daemon_mode: true`, Sliver uses `daemon.tailscale` and `daemon.disable_wg` from `server.yaml`.
- With `--disable-wg` or `daemon.disable_wg: true`, multiplayer falls back to direct TCP mTLS on the configured port.
### Example Config
@@ -25,6 +29,7 @@ daemon:
host: ""
port: 31337
tailscale: false
disable_wg: false
logs:
level: 4
grpc_unary_payloads: false
@@ -44,6 +49,8 @@ Since daemon mode does not provide an interactive server console, generate opera
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
```
The `operator` CLI matches the daemon's multiplayer exposure by default. If the daemon is running in direct mode or over Tailscale, the generated config omits the multiplayer `wg` block automatically. You can also force a direct-only profile with `--disable-wg`.
### Shutdown Behavior
On Unix-like systems, daemon mode handles `SIGTERM` and exits cleanly.
@@ -1,10 +1,10 @@
This script will install the latest version of Sliver as a systemd service, installs Windows cross-compiler dependencies (mingw), and setup multiplayer for the all local users. After running the script connect locally by running `sliver`.
This script installs the latest version of Sliver as a systemd service, installs Windows cross-compiler dependencies (mingw), and sets up multiplayer for all local users. After running the script, connect locally by running `sliver`.
https://sliver.sh/install
This script should work on Kali, Ubuntu, and RHEL (CentOS, etc) distributions of Linux.
**⚠️ OPSEC:** By default the Linux install script will bind the multiplayer listener to `:31337` i.e. all interfaces. Ensure your firewalls are properly configured if this is a concern, or reconfigure the server to bind to localhost if you only wish to allow local users. Multiplayer Mode is authenticated using mTLS/gRPC and per-operator bearer tokens, so this doesn't present a security problem (notwithstanding bugs in our implementation), but publicly exposing your multiplayer listener will make it trivial to identify what the server is running.
**⚠️ OPSEC:** By default the Linux install script will bind the multiplayer listener to `:31337` on all interfaces. In current releases that is the WireGuard-protected multiplayer listener, so the outer service is UDP/31337 and the authenticated gRPC/mTLS server only exists inside the tunnel. Ensure your firewalls are properly configured if this is a concern, or reconfigure the server to bind to localhost if you only wish to allow local users. Publicly exposing the multiplayer listener still makes the server easier to discover and fingerprint.
### One Liner
+5 -1
View File
@@ -47,6 +47,10 @@ sliver > mcp start --transport http
[*] Starting MCP server (http) on 127.0.0.1:8080
[*] Endpoint: http://127.0.0.1:8080/mcp
[*] Auth Header: Authorization
[*] Auth Token: 6f90c3b3c6058fa59f570e281f3f8d39
```
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server. Authentication is not yet supported for MCP over HTTP/SSE.
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server.
On first HTTP/SSE use, Sliver generates a random 128-bit token and saves it to `~/.sliver-client/mcp.yaml`. Every MCP HTTP/SSE request must include that token exactly in the `Authorization` header. If `mcp.yaml` already exists, the listener will only start when the token is at least 8 characters long.
@@ -1,4 +1,6 @@
Multiplayer-mode allows multiple operators (players) to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script) which will configure the server as a systemd service. However, any Sliver server binary supports multiplayer mode.
Multiplayer mode allows multiple operators to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script), which configures the server as a systemd service. However, any `sliver-server` binary supports multiplayer mode.
By default, Sliver now protects the operator-facing multiplayer listener with a dedicated WireGuard wrapper. The gRPC/mTLS authentication stack is still used inside the tunnel, but the server no longer exposes the multiplayer gRPC listener directly unless you explicitly opt out with `--disable-wg`.
```
┌──────────────────┐ C2
@@ -8,7 +10,8 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
└──────────────────┘
▲
│
gRPC/mTLS │
UDP/WireGuard (default) │
gRPC/mTLS inside │
┌────────────┬────────┴─────┬───────────┐
│ │ │ │
┌─────┴──┐ │ │ ┌──┴─────┐
@@ -18,32 +21,39 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
└────────┘ └────────┘
```
## New Operators
## Default WireGuard-Protected Mode
Operators will need to download their preferred platform's client software from the releases page. Clients can connect to servers of differing platforms (e.g. a Windows client can connect to a Linux server). Operators and servers authenticate using Mutual TLS, however all the certificates are managed automatically for you.
- If you did not install the server as a systemd service, be sure to leave the server console running in screen or [tmux](https://github.com/tmux/tmux) after starting the multiplayer listener.
- If you did install the server as systemd service see the CLI section below for an alternative to the console commands for generating operator configs.
- If you did install the server as a systemd service, see the CLI section below for an alternative to the console commands for generating operator configs.
Operators need a configuration file generated by the Sliver server to connect in multiplayer mode. The Sliver client can support multiple configuration files, but you can only be connected to one server at a time (per process).
From the server use the `new-operator` command to generate a new operator configuration file. You'll need to specify the operator's `--name` and the `--lhost` of the server (e.g., its public IP). Then from the server start a multiplayer listener using the `multiplayer` command to allow operators to connect:
In the default mode:
**Note:** The `new-operator` and `multiplayer` commands are only accessible from the server's console
- `multiplayer` exposes a WireGuard listener on `--lport` (default `31337`) over UDP.
- The authenticated gRPC/mTLS server only listens inside that WireGuard tunnel.
- `new-operator` generates a per-operator WireGuard keypair and tunnel IP, then embeds that information in the operator config's `wg` block.
- The multiplayer listener uses WireGuard keys that are separate from Sliver's implant `wg` C2 listener.
- `sliver-client` automatically uses the WireGuard wrapper when the imported config contains that `wg` block.
From the server, start the multiplayer listener and generate an operator config with the server's public address or hostname in `--lhost`. Use `--permissions all` for a standard interactive operator profile:
**Note:** The `new-operator` and `multiplayer` commands are only available from the server console.
```
[server] sliver > new-operator --name moloch --lhost 1.2.3.4
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
[server] sliver > multiplayer
[*] Multiplayer mode enabled!
[server] sliver > new-operator --name moloch --lhost 1.2.3.4 --permissions all
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
```
**IMPORTANT:** Before clients can connect to a server you must start an RPC listener with the `multiplayer` command. The default port is TCP/31337.
**IMPORTANT:** Before clients can connect to a server you must start a multiplayer listener with the `multiplayer` command. In the default WireGuard-protected mode the outer listener is UDP/31337. If you disable the wrapper, multiplayer is exposed directly on TCP/31337 instead.
You can now give this configuration file `moloch_example.com.cfg` to the operator and they can connect to the server using the `sliver-client` binary. The Sliver client will look for configuration files in `~/.sliver-client/configs/` or you can import configs using the `import` CLI. The configs directory can contain multiple configs for different servers.
@@ -56,19 +66,48 @@ $ ./sliver-client
localhost
```
## Direct mTLS Mode
If you want the old behavior, or you need compatibility with third-party gRPC clients that do not implement the multiplayer WireGuard wrapper, disable it on both the listener and the generated operator profile:
```
[server] sliver > multiplayer --disable-wg
[*] Multiplayer mode enabled!
[server] sliver > new-operator --name tester --lhost 1.2.3.4 --permissions all --disable-wg
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/tester/Desktop/tester_example.com.cfg
```
In direct mode:
- Multiplayer is exposed directly over TCP on `--lport` (default `31337`).
- Generated operator configs omit the `wg` block.
- `sliver-client --disable-wg` forces a direct connection even if the config includes a `wg` block.
The listener mode and the operator config need to match. A WireGuard-enabled config cannot talk to a direct listener, and a direct-only client cannot talk to the default WireGuard-wrapped listener.
### Server CLI / Daemon Mode Multiplayer
If the server is running in daemon mode (as is the default with the Linux install script), that means the multiplayer listener is started for you without an interactive console. You can use the server binaries' CLI to generate operator configuration files to connect:
If the server is running in daemon mode, the multiplayer listener is started for you without an interactive console. By default the daemon uses the same WireGuard-protected multiplayer mode described above. You can switch the daemon back to direct exposure with `sliver-server daemon --disable-wg` or `daemon.disable_wg: true` in `server.yaml`.
Use the server CLI to generate operator configuration files:
```
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --save zer0cool.cfg
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
```
The installation scripts by default will place the `sliver-server` binary in `/root`.
When daemon mode is configured for direct multiplayer or Tailscale, the CLI automatically omits the `wg` block by default. You can also force a direct-only profile with `--disable-wg`.
The installation script places the `sliver-server` binary in `/root` by default.
### Tailscale Integration
Sliver v1.6 supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when wanting to maintain operational security.
Sliver supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when you want to keep the multiplayer listener off the public Internet.
Tailscale mode is separate from the default multiplayer WireGuard wrapper. When you use `multiplayer -T`, Sliver exposes the listener only through Tailscale instead of layering its own multiplayer WireGuard tunnel on top.
```
$ export TS_AUTHKEY=<tailscale authkey>
@@ -77,4 +116,4 @@ $ ./sliver-server
sliver > multiplayer -T
```
You should now see a new Tailscale host named `sliver-server-<machine>`, you can set this using the `--lhost` flag. Use this as the hostname when connecting to the Sliver server from any other host on the Tailscale network.
You should now see a new Tailscale host named `sliver-server-<machine>`. Use that hostname as `--lhost` when generating operator configs for other hosts on the same tailnet. If you generate the operator config before enabling Tailscale multiplayer, add `--disable-wg` explicitly so the profile does not include the multiplayer `wg` block.
Loaded 100 of 1804 files, more files were not shown because too many files have changed in this diff. Show more