mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-07 17:08:13 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b635adcb3c | ||
|
|
2b92397e07 | ||
|
|
c23067a0e0 | ||
|
|
3aefc39c08 | ||
|
|
abd8c83df8 | ||
|
|
6cce9426f2 | ||
|
|
2e742863a1 | ||
|
|
dc13a5bb93 | ||
|
|
1f2946eb87 | ||
|
|
5eeda4c02b | ||
|
|
55a3386313 | ||
|
|
b34589a002 | ||
|
|
4486b17b49 | ||
|
|
377ee5ca7c | ||
|
|
4765f40854 | ||
|
|
e239d85821 | ||
|
|
dec419b284 | ||
|
|
6d51a23cc9 | ||
|
|
3d6eec47be | ||
|
|
464fb7008c | ||
|
|
ffa5f89f5f | ||
|
|
f19cbdc957 | ||
|
|
83bfc8d8e6 | ||
|
|
365bb3596e | ||
|
|
9ee96c2ce6 | ||
|
|
e776e266d0 | ||
|
|
d080c8a646 | ||
|
|
cb6402245e | ||
|
|
e4357277ec | ||
|
|
5dc2ab1490 | ||
|
|
9f034a9956 | ||
|
|
f700535997 | ||
|
|
31bca034d8 | ||
|
|
fac4ad5fb7 | ||
|
|
c128d33514 | ||
|
|
4a37262bcf | ||
|
|
a2295112bd | ||
|
|
834dff0f6e | ||
|
|
f4a0431fdd | ||
|
|
d072b328c0 | ||
|
|
435bbcbd7e | ||
|
|
84dc28e584 | ||
|
|
639592cb02 | ||
|
|
8c7a1a6b40 | ||
|
|
06274c4061 | ||
|
|
593925b4a5 | ||
|
|
255477e572 | ||
|
|
0acc6911d8 | ||
|
|
4ca026906b | ||
|
|
97a249e28e | ||
|
|
9f331687d3 | ||
|
|
14d68d6fcd | ||
|
|
05c5334fea | ||
|
|
d277a14c57 | ||
|
|
a7b5dbe6ee | ||
|
|
c188dc1e69 | ||
|
|
9fc9d1762e | ||
|
|
4ef86448d0 | ||
|
|
02117a6072 | ||
|
|
c272c8abac | ||
|
|
cdf2412b39 | ||
|
|
40f52791d3 | ||
|
|
a5d8bd16b9 | ||
|
|
bf113f2151 | ||
|
|
4cd010afb9 | ||
|
|
04b0bdd199 | ||
|
|
9682db93b3 | ||
|
|
1d7ffb4ac2 | ||
|
|
c5e7cd6354 | ||
|
|
b20ba7284b | ||
|
|
49498210f8 | ||
|
|
14e9121302 | ||
|
|
8bd0257dd9 | ||
|
|
93fdfb3add | ||
|
|
e02b03cc70 | ||
|
|
6831fe50a6 | ||
|
|
405108866d | ||
|
|
4d551b71e7 | ||
|
|
a7edd9838d | ||
|
|
9ef54ca6b1 | ||
|
|
4622c50288 | ||
|
|
b1ee0355af | ||
|
|
01d9b213f2 | ||
|
|
f56886bce5 | ||
|
|
88176dfd00 | ||
|
|
45dc3ea09b | ||
|
|
682b3c2409 | ||
|
|
3a93f312b4 | ||
|
|
2ca95f7a7c | ||
|
|
d2319432ab | ||
|
|
253f1a8806 | ||
|
|
2c025d5970 | ||
|
|
f0034683f9 | ||
|
|
8e8398b654 | ||
|
|
aa02293f05 | ||
|
|
f967a32308 | ||
|
|
99ec812455 | ||
|
|
eeef542eeb | ||
|
|
6d7a10b392 | ||
|
|
ca40c0a1e7 | ||
|
|
4a5ad6ea92 | ||
|
|
266dabf6c3 | ||
|
|
a4c8968539 | ||
|
|
613c030ea3 | ||
|
|
9f6bfb6714 | ||
|
|
a764814a8e | ||
|
|
0c9ce138a0 | ||
|
|
6c1dcf6a06 | ||
|
|
fffe93819d | ||
|
|
635ee421e3 | ||
|
|
3ce6d69474 | ||
|
|
cd523c7f77 | ||
|
|
1f1b759914 | ||
|
|
b78915fcbd | ||
|
|
fa0e13a5cc | ||
|
|
924bcddfd2 | ||
|
|
affbf61452 | ||
|
|
7c6b2f70e4 | ||
|
|
c46f2938de | ||
|
|
b355fe70d6 | ||
|
|
10b1079286 | ||
|
|
9c042e6977 | ||
|
|
62583cac8f | ||
|
|
95764945b8 | ||
|
|
26abda1c15 | ||
|
|
01dd3cac72 | ||
|
|
f8faa1f767 | ||
|
|
a9e48e84cd | ||
|
|
9d6704d925 | ||
|
|
ce5157e2e9 | ||
|
|
7d19051df8 | ||
|
|
a0d3bd579f | ||
|
|
82c309632a | ||
|
|
5c0b8ec460 | ||
|
|
7791a86eb4 | ||
|
|
7ac4db3fa0 | ||
|
|
512bc822ef | ||
|
|
f2bb9c3733 | ||
|
|
0bd4f100fd | ||
|
|
3230322fd6 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
* @BishopFox/red-team
|
||||
* @BishopFox/bf-red-team
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/crush
|
||||
|
||||
/examples
|
||||
|
||||
/.vscode
|
||||
/.vscode/*
|
||||
/.devcontainer
|
||||
/.idea
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -164,7 +164,7 @@ func ArmoryCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
cacheEntry, ok := value.(pkgCacheEntry)
|
||||
if !ok {
|
||||
// Something is wrong with this entry
|
||||
pkgCache.Delete(value)
|
||||
pkgCache.Delete(key)
|
||||
return true
|
||||
}
|
||||
if cacheEntry.ArmoryConfig.PublicKey != index.ArmoryConfig.PublicKey {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Generated
+1764
-2661
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,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
|
||||
|
||||
|
||||
Loaded 100 of 1806 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user