Compare commits

...
Author SHA1 Message Date
moloch-- 92d85cb8ff Tweak styling 2026-05-01 07:17:02 -07:00
moloch-- f5e831844a Fix various docs ui quirks in hero v3 2026-04-30 18:21:06 -07:00
moloch-- ab2fc7dc4a Initial update to heroui v3 2026-04-30 17:59:11 -07:00
Joe 5e377019e4 Merge pull request #2254 from smeukinou/fix-sliverpy
fix: safely close event broker subscription channels
2026-04-30 11:37:01 -07:00
smeukinou 88a4bebca0 fix: safely close event broker subscription channels
Moves the responsibility of closing the event subscription channel to the broker.Start() goroutine when processing an unsubscribe request. This prevents potential data races and panics caused by closing a channel concurrently while the broker might still be attempting to publish events to it.
2026-04-29 23:07:40 +02:00
Joe 2daccb5bd3 Merge pull request #2250 from BishopFox/wg/opt-in
Disable wg wrapper on multiplayer by default
2026-04-21 21:24:17 -07:00
Joe e2a009d4ac Merge pull request #2249 from Moriz82/fix/services-windows-nil-deref
fix(implant): services list panics on nil error on Windows (#1989)
2026-04-21 21:03:40 -07:00
moloch-- eda51f826a Disable wg wrapper on multiplayer by default 2026-04-21 20:51:06 -07:00
Will DeDominico f83dc42a64 fix(implant): services list panics on nil error (#1989)
servicesListHandler called err.Error() unconditionally after
service.ListServices(). On a healthy Windows host ListServices returns
nil error, causing a nil pointer dereference that crashed the implant.

serviceDetailHandler in the same file already guards correctly with
if err != nil. Apply the same pattern to servicesListHandler by
extracting response construction into a buildServicesResp helper with
a proper nil check.

Verified on Windows 11 VM: unfixed helper panics with "runtime error:
invalid memory address or nil pointer dereference" on the nil-error
path; fixed helper passes.

Fixes #1989
2026-04-20 20:13:46 -05:00
Joe 17e8c996a6 Merge pull request #2244 from Moriz82/fix/localtask-process-isolation
fix(implant): run LocalTask shellcode in forked child (#2237)
2026-04-19 08:44:56 -07:00
Will DeDominico 2b667f0e7e fix(implant): run LocalTask shellcode in forked child (#2237)
The previous implementation ran shellcode in a goroutine inside the
beacon; payloads that terminate via exit/execve killed the whole
beacon. Fork a child instead and let the parent wait for it.

Linux uses SYS_CLONE(SIGCHLD) since linux/arm64 has no SYS_FORK.
Darwin uses SYS_FORK, with the isChild check routed through the r1==1
convention that libSystem's fork() wrapper normally hides.

Post-fork the child runs only raw syscalls: runtime_BeforeFork poisons
stackguard0 and AfterForkInChild clears inherited signal handlers.
Mprotect happens in the child so the parent's page table stays
writable, inherited FDs are closed to avoid leaking C2 sockets into
exec'd payloads, and the child exits via SYS_EXIT_GROUP (Linux) or
SYS_EXIT (Darwin).

Tests under implant/sliver/taskrunner/ cover parent survival and
non-zero-exit error reporting on both Linux and Darwin.
2026-04-16 20:52:38 -05:00
Ronan Kervella 73bcf40bfd Merge pull request #2240 from BishopFox/CLD-10806/update-codeowners-replace-red-team
chore(doc): replace red-team with bf-red-team in CODEOWNERS (CLD-10806)
2026-04-10 09:02:55 -07:00
George Robbins b635adcb3c chore(doc): replace red-team with bf-red-team in CODEOWNERS (CLD-10806) 2026-04-10 10:56:01 -05:00
Joe 2b92397e07 Merge pull request #2236 from BishopFox/codex/pr2234-wg-ci
Fix WG client Windows CI regressions
2026-04-09 10:11:57 -07:00
moloch-- c23067a0e0 Wait for AI RPC event streams to subscribe 2026-04-02 09:17:33 -07:00
moloch-- 3aefc39c08 Stabilize AI RPC event stream tests 2026-04-02 08:50:19 -07:00
moloch-- abd8c83df8 Stabilize Windows WireGuard reconnects 2026-04-02 08:22:51 -07:00
Joe 6cce9426f2 Merge pull request #2235 from skoveit/fix/pkg-cache-delete-wrong-argument
fix: pkgCache.Delete(value) should be pkgCache.Delete(key)
2026-04-01 17:30:20 -07:00
skove 2e742863a1 fix: pass key instead of value to pkgcache.delete() 2026-04-01 14:47:39 +02:00
moloch-- dc13a5bb93 Add connecting animation 2026-03-31 13:33:19 -07:00
moloch-- 1f2946eb87 Revert changes to netstack 2026-03-31 09:45:40 -07:00
moloch-- 5eeda4c02b console now keeps the long-lived background WG streams on one connection 2026-03-31 09:36:05 -07:00
moloch-- 55a3386313 Improve reliabilty of multiplayer/wg setup 2026-03-30 20:02:55 -07:00
moloch-- b34589a002 Merge branch 'master' of github.com:BishopFox/sliver 2026-03-30 15:56:54 -07:00
moloch-- 4486b17b49 Tweak release trigger 2026-03-30 15:56:45 -07:00
Joe 377ee5ca7c Merge pull request #2229 from BishopFox/docs/multiplayer-wg
Update docs for wg+grpc/mtls multiplayer
2026-03-30 12:13:43 -07:00
moloch-- 4765f40854 v1.7.4 2026-03-30 11:51:15 -07:00
moloch-- e239d85821 v1.7.4 2026-03-30 11:48:34 -07:00
Joe dec419b284 Merge pull request #2232 from BishopFox/fix/linux-ver
Implement Detailed Linux Version Info
2026-03-30 09:52:39 -07:00
moloch-- 593925b4a5 Update docs for wg+grpc/mtls multiplayer 2026-03-26 17:30:33 -07:00
58 changed files with 3295 additions and 4445 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @BishopFox/red-team
* @BishopFox/bf-red-team
+16 -5
View File
@@ -2,8 +2,8 @@ name: Release
on:
push:
branches:
- master
tags:
- "v*.*.*"
permissions:
contents: write
@@ -21,11 +21,22 @@ jobs:
with:
fetch-depth: 0
- name: Find Semver Tag
- name: Validate Release Tag
id: tag
run: |
git fetch --prune --tags -f
tag=$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
git fetch --prune --tags -f origin
tag="${GITHUB_REF_NAME}"
if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag $tag is not a release semver tag."
exit 0
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/master"; then
echo "Tag $tag does not point to a commit on origin/master."
exit 0
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
release:
+1 -2
View File
@@ -52,8 +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")
rootCmd.PersistentFlags().Bool(requireWGFlag, false, "require the operator config's WireGuard wrapper for multiplayer connections")
rootCmd.PersistentFlags().Bool(enableWGFlag, false, "connect to multiplayer through the operator config's WireGuard wrapper")
// Create the console client, without any RPC or commands bound to it yet.
// This created before anything so that multiple commands can make use of
+177
View File
@@ -0,0 +1,177 @@
package cli
import (
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
bspinner "charm.land/bubbles/v2/spinner"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
const (
minConnectSpinnerDuration = 900 * time.Millisecond
minConnectStatusDuration = 350 * time.Millisecond
)
type connectResult struct {
rpc rpcpb.SliverRPCClient
conn *grpc.ClientConn
err error
}
func connectWithSpinner(out io.Writer, target string, connect func(transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error)) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusCh := make(chan string, 8)
resultCh := make(chan connectResult, 1)
go func() {
rpc, conn, err := connect(func(status string) {
sendStatus(statusCh, status)
})
resultCh <- connectResult{rpc: rpc, conn: conn, err: err}
}()
currentStatus := ""
currentStatusSince := time.Time{}
queuedStatus := ""
lastWidth := 0
spinner := bspinner.New(bspinner.WithSpinner(bspinner.Line))
ticker := time.NewTicker(spinner.Spinner.FPS)
defer ticker.Stop()
startedAt := time.Now()
var pendingResult *connectResult
setStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" || status == currentStatus {
return
}
currentStatus = status
currentStatusSince = time.Now()
}
stageStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
if currentStatus == "" || time.Since(currentStatusSince) >= minConnectStatusDuration {
queuedStatus = ""
setStatus(status)
return
}
queuedStatus = status
}
flushQueuedStatus := func() {
if queuedStatus == "" || time.Since(currentStatusSince) < minConnectStatusDuration {
return
}
nextStatus := queuedStatus
queuedStatus = ""
setStatus(nextStatus)
}
canReturnPendingResult := func() bool {
if pendingResult == nil {
return false
}
if pendingResult.err != nil {
return true
}
if time.Since(startedAt) < minConnectSpinnerDuration {
return false
}
if queuedStatus != "" {
return false
}
if !currentStatusSince.IsZero() && time.Since(currentStatusSince) < minConnectStatusDuration {
return false
}
return true
}
render := func() {
line := fmt.Sprintf("%s %s", spinner.View(), formatConnectSpinnerMessage(target, currentStatus))
lastWidth = writeSpinnerLine(out, line, lastWidth)
}
render()
for {
select {
case status := <-statusCh:
stageStatus(status)
spinner, _ = spinner.Update(spinner.Tick())
render()
case result := <-resultCh:
pendingResult = &result
case <-ticker.C:
spinner, _ = spinner.Update(spinner.Tick())
flushQueuedStatus()
render()
if canReturnPendingResult() {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
if pendingResult != nil && pendingResult.err != nil {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
}
func sendStatus(statusCh chan string, status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
statusCh <- status
}
func formatConnectSpinnerMessage(target string, status string) string {
target = strings.TrimSpace(target)
status = strings.TrimSpace(status)
if target == "" {
if status == "" {
return "Connecting ..."
}
return fmt.Sprintf("Connecting (%s) ...", status)
}
if status == "" {
return fmt.Sprintf("Connecting to %s ...", target)
}
return fmt.Sprintf("Connecting to %s (%s) ...", target, status)
}
func writeSpinnerLine(out io.Writer, line string, lastWidth int) int {
width := utf8.RuneCountInString(line)
padding := ""
if lastWidth > width {
padding = strings.Repeat(" ", lastWidth-width)
}
fmt.Fprintf(out, "\r%s%s", line, padding)
if width > lastWidth {
return width
}
return lastWidth
}
func clearSpinnerLine(out io.Writer, lastWidth int) {
if lastWidth <= 0 {
return
}
fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", lastWidth))
}
+132
View File
@@ -0,0 +1,132 @@
package cli
import (
"bytes"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
func TestFormatConnectSpinnerMessage(t *testing.T) {
tests := []struct {
name string
target string
status string
want string
}{
{
name: "target only",
target: "127.0.0.1:31337",
want: "Connecting to 127.0.0.1:31337 ...",
},
{
name: "target with status",
target: "127.0.0.1:31337",
status: "wireguard",
want: "Connecting to 127.0.0.1:31337 (wireguard) ...",
},
{
name: "status without target",
status: "grpc/mtls",
want: "Connecting (grpc/mtls) ...",
},
{
name: "trim whitespace",
target: " 127.0.0.1:31337 ",
status: " grpc/mtls over wireguard ",
want: "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatConnectSpinnerMessage(test.target, test.status); got != test.want {
t.Fatalf("expected %q, got %q", test.want, got)
}
})
}
}
func TestConnectWithSpinnerOutput(t *testing.T) {
var out bytes.Buffer
spinnerDelay := 2 * (100 * time.Millisecond)
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
time.Sleep(spinnerDelay)
statusFn("grpc/mtls over wireguard")
time.Sleep(spinnerDelay)
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected wireguard status in output, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected grpc over wireguard status in output, got %q", got)
}
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames in output, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("grpc/mtls")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337 (grpc/mtls) ...") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames for fast success, got %q", got)
}
}
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
statusFn("grpc/mtls over wireguard")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected fast success output to include wireguard status, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected fast success output to include grpc over wireguard status, got %q", got)
}
if strings.Contains(got, "Connected to 127.0.0.1:31337") {
t.Fatalf("did not expect past-tense success output, got %q", got)
}
}
+11 -7
View File
@@ -20,6 +20,7 @@ package cli
import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command"
@@ -64,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)
+6 -19
View File
@@ -1,41 +1,28 @@
package cli
import (
"fmt"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
const (
requireWGFlag = "require-wg"
disableWGFlag = "disable-wg"
enableWGFlag = "enable-wg"
)
func applyMultiplayerConnectMode(cmd *cobra.Command) error {
if cmd == nil {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectDirect)
return nil
}
requireWG, err := cmd.Flags().GetBool(requireWGFlag)
enableWG, err := cmd.Flags().GetBool(enableWGFlag)
if err != nil {
return err
}
disableWG, err := cmd.Flags().GetBool(disableWGFlag)
if err != nil {
return err
}
if requireWG && disableWG {
return fmt.Errorf("--%s and --%s cannot be used together", requireWGFlag, disableWGFlag)
}
mode := transport.MultiplayerConnectAuto
switch {
case requireWG:
mode = transport.MultiplayerConnectRequireWG
case disableWG:
mode = transport.MultiplayerConnectDisableWG
mode := transport.MultiplayerConnectDirect
if enableWG {
mode = transport.MultiplayerConnectEnableWG
}
transport.SetMultiplayerConnectMode(mode)
return nil
+1 -1
View File
@@ -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 {
+84 -17
View File
@@ -46,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
@@ -58,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()
@@ -127,13 +148,59 @@ func (con *SliverClient) detachConnectionLocked() {
_ = 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
+10 -2
View File
@@ -95,6 +95,9 @@ type SliverClient struct {
connMu sync.Mutex
grpcConn *grpc.ClientConn
backgroundRPC rpcpb.SliverRPCClient
backgroundConn *grpc.ClientConn
backgroundDedicated bool
connDetails *ConnectionDetails
connCancel context.CancelFunc
connWg *sync.WaitGroup
@@ -326,6 +329,7 @@ func (con *SliverClient) applyConnectionHooksOnce() {
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
@@ -336,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
+15 -4
View File
@@ -107,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
}
@@ -116,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 {
@@ -124,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 {
@@ -166,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
}
+4 -5
View File
@@ -11,9 +11,8 @@ import (
type MultiplayerConnectMode int
const (
MultiplayerConnectAuto MultiplayerConnectMode = iota
MultiplayerConnectDisableWG
MultiplayerConnectRequireWG
MultiplayerConnectDirect MultiplayerConnectMode = iota
MultiplayerConnectEnableWG
)
type connectionCloser interface {
@@ -22,7 +21,7 @@ type connectionCloser interface {
var (
multiplayerConnectModeMu sync.RWMutex
multiplayerConnectMode = MultiplayerConnectAuto
multiplayerConnectMode = MultiplayerConnectDirect
connClosers sync.Map // *grpc.ClientConn -> connectionCloser
)
@@ -65,9 +64,9 @@ func CloseGRPCConnection(conn *grpc.ClientConn) error {
}
var errs []error
errs = append(errs, conn.Close())
if closer := unregisterConnCloser(conn); closer != nil {
errs = append(errs, closer.Close())
}
errs = append(errs, conn.Close())
return errors.Join(errs...)
}
+27 -13
View File
@@ -58,6 +58,15 @@ const (
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{
@@ -71,6 +80,12 @@ func (TokenAuth) RequireTransportSecurity() bool {
// MTLSConnect - Connect to the sliver server
func MTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
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
@@ -78,9 +93,9 @@ func MTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.Clie
switch strategy {
case multiplayerDialWireGuard:
return wireGuardMTLSConnect(config)
return wireGuardMTLSConnect(config, statusFn)
default:
return directMTLSConnect(config)
return directMTLSConnect(config, statusFn)
}
}
@@ -90,25 +105,18 @@ func selectMultiplayerDialStrategy(config *assets.ClientConfig) (multiplayerDial
}
switch getMultiplayerConnectMode() {
case MultiplayerConnectDisableWG:
return multiplayerDialDirect, nil
case MultiplayerConnectRequireWG:
case MultiplayerConnectEnableWG:
if err := validateWireGuardConfig(config); err != nil {
return multiplayerDialDirect, err
}
return multiplayerDialWireGuard, nil
default:
if config.WG == nil {
return multiplayerDialDirect, nil
}
if err := validateWireGuardConfig(config); err != nil {
return multiplayerDialDirect, err
}
return multiplayerDialWireGuard, nil
return multiplayerDialDirect, nil
}
}
func directMTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
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
@@ -116,6 +124,12 @@ func directMTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grp
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 {
+56 -10
View File
@@ -5,10 +5,13 @@ import (
"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)
setTestMultiplayerConnectMode(t, MultiplayerConnectDirect)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if err != nil {
@@ -19,8 +22,25 @@ func TestSelectMultiplayerDialStrategyLegacyConfigUsesDirectMTLS(t *testing.T) {
}
}
func TestSelectMultiplayerDialStrategyRequireWGRejectsMissingWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectRequireWG)
func TestSelectMultiplayerDialStrategyDefaultIgnoresIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectDirect)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientIP: "100.64.0.2",
},
})
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyEnableWGRejectsMissingWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if !errors.Is(err, ErrMissingWireGuardConfig) {
@@ -28,8 +48,8 @@ func TestSelectMultiplayerDialStrategyRequireWGRejectsMissingWGConfig(t *testing
}
}
func TestSelectMultiplayerDialStrategyRejectsIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
func TestSelectMultiplayerDialStrategyEnableWGRejectsIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
@@ -42,8 +62,8 @@ func TestSelectMultiplayerDialStrategyRejectsIncompleteWGConfig(t *testing.T) {
}
}
func TestSelectMultiplayerDialStrategyUsesWireGuardWhenConfigComplete(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
func TestSelectMultiplayerDialStrategyEnableWGUsesWireGuardWhenConfigComplete(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
@@ -60,8 +80,8 @@ func TestSelectMultiplayerDialStrategyUsesWireGuardWhenConfigComplete(t *testing
}
}
func TestSelectMultiplayerDialStrategyDisableWGOverridesCompleteConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectDisableWG)
func TestSelectMultiplayerDialStrategyDefaultUsesDirectMTLSEvenWhenWGConfigComplete(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectDirect)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
@@ -74,7 +94,7 @@ func TestSelectMultiplayerDialStrategyDisableWGOverridesCompleteConfig(t *testin
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct strategy when WG is disabled, got %v", strategy)
t.Fatalf("expected direct strategy when WG is not explicitly enabled, got %v", strategy)
}
}
@@ -87,3 +107,29 @@ func setTestMultiplayerConnectMode(t *testing.T, mode MultiplayerConnectMode) {
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()
}
+145 -10
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/protobuf/rpcpb"
@@ -23,11 +24,17 @@ 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 {
@@ -37,6 +44,19 @@ type wireGuardTunnel struct {
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
@@ -44,6 +64,7 @@ func (t *wireGuardTunnel) Close() error {
t.closeOnce.Do(func() {
if t.dev != nil {
t.dev.Close()
<-t.dev.Wait()
}
})
return nil
@@ -56,22 +77,136 @@ func (t *wireGuardTunnel) DialContext(ctx context.Context, address string) (net.
return t.net.DialContext(ctx, "tcp", address)
}
func wireGuardMTLSConnect(config *assets.ClientConfig) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
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, nil, err
return "", nil, "", err
}
return key, tunnel, target, nil
}
options, err := newMTLSDialOptions(config)
if err != nil {
_ = tunnel.Close()
return nil, nil, err
func cacheIdleWireGuardTunnel(key string, tunnel *wireGuardTunnel, target string) {
if key == "" || tunnel == nil {
return
}
options = append(options, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return tunnel.DialContext(ctx, addr)
}))
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()
}
}
return dialRPCClient(target, options, tunnel)
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) {
+44 -3
View File
@@ -38,6 +38,11 @@ type transportTun struct {
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 {
@@ -60,9 +65,8 @@ func createTransportNetTUN(localAddresses []netip.Addr, mtu int) (tun.Device, *t
mtu: mtu,
}
sackEnabledOpt := tcpip.TCPSACKEnabled(true)
if tcpipErr := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); tcpipErr != nil {
return nil, nil, fmt.Errorf("could not enable TCP SACK: %v", tcpipErr)
if err := configureTransportTCPStack(dev.stack); err != nil {
return nil, nil, err
}
dev.notifyHandle = dev.ep.AddNotify(dev)
@@ -102,6 +106,43 @@ func createTransportNetTUN(localAddresses []netip.Addr, mtu int) (tun.Device, *t
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
}
+86
View File
@@ -0,0 +1,86 @@
package transport
import (
"testing"
"time"
"github.com/bishopfox/sliver/client/assets"
)
func TestWireGuardTunnelCacheKeyIncludesConfigMaterial(t *testing.T) {
config := &assets.ClientConfig{
LHost: "127.0.0.1",
LPort: 31337,
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.65.0.2",
ServerIP: "100.65.0.1",
},
}
key1, err := wireGuardTunnelCacheKey(config)
if err != nil {
t.Fatalf("cache key: %v", err)
}
config.WG.ClientPrivateKey = "other-client-priv"
key2, err := wireGuardTunnelCacheKey(config)
if err != nil {
t.Fatalf("cache key after config change: %v", err)
}
if key1 == key2 {
t.Fatal("expected cache key to change when wireguard config changes")
}
}
func TestCacheIdleWireGuardTunnelRemovesIdleTunnel(t *testing.T) {
resetWireGuardTunnelCacheForTest(t)
previousIdleTimeout := multiplayerWireGuardIdleTimeout
multiplayerWireGuardIdleTimeout = 10 * time.Millisecond
t.Cleanup(func() {
multiplayerWireGuardIdleTimeout = previousIdleTimeout
})
const cacheKey = "test-cache-key"
cacheIdleWireGuardTunnel(cacheKey, &wireGuardTunnel{}, "100.65.0.1:31337")
deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
wireGuardTunnelCacheMu.Lock()
_, exists := wireGuardTunnelCache[cacheKey]
wireGuardTunnelCacheMu.Unlock()
if !exists {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("expected idle tunnel cache entry to be removed")
}
func resetWireGuardTunnelCacheForTest(t *testing.T) {
t.Helper()
wireGuardTunnelCacheMu.Lock()
for key, shared := range wireGuardTunnelCache {
if shared != nil && shared.timer != nil {
shared.timer.Stop()
}
delete(wireGuardTunnelCache, key)
}
wireGuardTunnelCacheMu.Unlock()
t.Cleanup(func() {
wireGuardTunnelCacheMu.Lock()
for key, shared := range wireGuardTunnelCache {
if shared != nil && shared.timer != nil {
shared.timer.Stop()
}
delete(wireGuardTunnelCache, key)
}
wireGuardTunnelCacheMu.Unlock()
})
}
@@ -3,8 +3,9 @@ import React from "react";
const LoadingState: React.FC = () => {
return (
<div className="flex min-h-screen w-full items-center justify-center">
<Spinner label="Loading ..." labelColor="foreground" />
<div className="flex min-h-screen w-full items-center justify-center gap-3 text-foreground">
<Spinner aria-label="Loading" />
<span>Loading...</span>
</div>
);
};
+209 -236
View File
@@ -1,7 +1,6 @@
"use client";
import { SliversIcon } from "@/components/icons/slivers";
import { useSearchContext } from "@/util/search-context";
import { Themes } from "@/util/themes";
import { faDiscord, faGithub } from "@fortawesome/free-brands-svg-icons";
import {
@@ -13,281 +12,255 @@ import {
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
Input,
Link,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
Tooltip,
} from "@heroui/react";
import { Button, SearchField, Tooltip } from "@heroui/react";
import { useTheme } from "next-themes";
import { useRouter } from "next/router";
import React from "react";
export type TopNavbarProps = {};
const routes = [
{ href: "/tutorials", label: "Tutorials" },
{ href: "/talks", label: "Talks" },
{ href: "/docs", label: "Docs" },
];
export default function TopNavbar(props: TopNavbarProps) {
export default function TopNavbar() {
const router = useRouter();
const search = useSearchContext();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const lightDarkModeIcon = React.useMemo(() => {
if (!mounted) {
return faMoon;
}
return theme === Themes.DARK ? faSun : faMoon;
}, [mounted, theme]);
const [query, setQuery] = React.useState("");
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
React.useEffect(() => {
setIsMobileMenuOpen(false);
}, [router.pathname]);
const activeTheme = theme || Themes.DARK;
const lightDarkModeIcon = React.useMemo(() => {
return activeTheme === Themes.DARK ? faSun : faMoon;
}, [activeTheme]);
const handleSearchSubmit = React.useCallback(() => {
if (query.trim().length === 0) {
return;
}
router.push({ pathname: "/search/", query: { search: query } });
setQuery("");
}, [query, router]);
const handleSearchSubmit = React.useCallback(
(value = query) => {
const searchQuery = value.trim();
if (searchQuery.length === 0) {
return;
}
router.push({ pathname: "/search/", query: { search: searchQuery } });
setQuery("");
setIsMobileMenuOpen(false);
},
[query, router],
);
const renderTooltip = React.useCallback(
(content: string, children: React.ReactNode) => (
<Tooltip delay={0}>
{children}
<Tooltip.Content>{content}</Tooltip.Content>
</Tooltip>
),
[],
);
const renderSearchInput = (wrapperClassName?: string) => (
<div className={wrapperClassName}>
<Tooltip content="Press enter to search" isOpen={query.length > 0}>
<Input
size="sm"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onClear={() => setQuery("")}
startContent={<FontAwesomeIcon icon={faSearch} />}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearchSubmit();
}
}}
/>
</Tooltip>
</div>
<SearchField
aria-label="Search documentation"
className={wrapperClassName}
fullWidth
value={query}
onChange={setQuery}
onSubmit={handleSearchSubmit}
>
<SearchField.Group>
<SearchField.SearchIcon>
<FontAwesomeIcon icon={faSearch} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="Search..." />
{query.length > 0 ? <SearchField.ClearButton aria-label="Clear search" /> : null}
</SearchField.Group>
</SearchField>
);
const renderDesktopRoute = (href: string, label: string) => {
const isActive = router.pathname.startsWith(href);
return (
<li key={href}>
<Button
variant={isActive ? "secondary" : "ghost"}
onPress={() => router.push(href)}
>
{label}
</Button>
</li>
);
};
const renderMobileRoute = (href: string, label: string) => {
const isActive = href === "/" ? router.pathname === "/" : router.pathname.startsWith(href);
return (
<Button
key={href}
fullWidth
variant={isActive ? "primary" : "ghost"}
onPress={() => {
router.push(href);
setIsMobileMenuOpen(false);
}}
>
{href === "/" ? <FontAwesomeIcon fixedWidth icon={faHome} /> : null}
{label}
</Button>
);
};
return (
<>
<Navbar
isBordered
maxWidth="full"
classNames={{
item: [
"flex",
"relative",
"h-full",
"items-center",
"data-[active=true]:after:content-['']",
"data-[active=true]:after:absolute",
"data-[active=true]:after:bottom-0",
"data-[active=true]:after:left-0",
"data-[active=true]:after:right-0",
"data-[active=true]:after:h-[2px]",
"data-[active=true]:after:rounded-[2px]",
"data-[active=true]:after:bg-primary",
],
}}
>
<NavbarBrand>
<SliversIcon />
<span className="hidden sm:block font-bold text-inherit">
&nbsp; Sliver C2
</span>
</NavbarBrand>
<NavbarContent className="hidden md:flex">
<NavbarItem isActive={router.pathname === "/"}>
<Button
variant="light"
color={router.pathname === "/" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/")}
>
<FontAwesomeIcon fixedWidth icon={faHome} />
</Button>
</NavbarItem>
<NavbarItem isActive={router.pathname.startsWith("/tutorials")}>
<Button
variant="light"
color={router.pathname === "/tutorials" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/tutorials")}
>
Tutorials
</Button>
</NavbarItem>
<NavbarItem isActive={router.pathname.startsWith("/talks")}>
<Button
variant="light"
color={router.pathname === "/talks" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/talks")}
>
Talks
</Button>
</NavbarItem>
<NavbarItem isActive={router.pathname.startsWith("/docs")}>
<Button
variant="light"
color={router.pathname === "/docs" ? "primary" : "default"}
as={Link}
onPress={() => router.push("/docs")}
>
Docs
</Button>
</NavbarItem>
</NavbarContent>
<NavbarContent as="div" justify="end" className="hidden md:flex">
{renderSearchInput("w-64")}
<Tooltip
content={theme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
>
<Button
variant="ghost"
onPress={() => {
setTheme(theme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
<nav className="sliver-navbar-glass sticky top-0 z-40 px-4 shadow-sm md:px-6">
<div className="flex h-16 items-center gap-4">
<button
type="button"
className="flex items-center gap-2 text-foreground"
onClick={() => {
router.push("/");
setIsMobileMenuOpen(false);
}}
isDisabled={!mounted}
aria-label="Sliver C2 home"
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>
</Tooltip>
<SliversIcon />
<span className="hidden font-bold text-inherit sm:block">
Sliver C2
</span>
</button>
<Tooltip content="Join Discord">
<Button
variant="ghost"
onPress={() => {
window.open(
"https://discord.com/channels/791066041198968873/1339996286514106409",
"_blank",
);
}}
>
<FontAwesomeIcon icon={faDiscord} />
</Button>
</Tooltip>
<ul className="hidden items-center gap-2 md:flex">
<li>
{renderTooltip(
"Home",
<Button
isIconOnly
aria-label="Home"
variant={router.pathname === "/" ? "secondary" : "ghost"}
onPress={() => router.push("/")}
>
<FontAwesomeIcon fixedWidth icon={faHome} />
</Button>,
)}
</li>
{routes.map((route) => renderDesktopRoute(route.href, route.label))}
</ul>
<Tooltip content="View on GitHub">
<Button
variant="ghost"
onPress={() => {
window.open("https://github.com/BishopFox/sliver", "_blank");
}}
>
<FontAwesomeIcon icon={faGithub} />
</Button>
</Tooltip>
</NavbarContent>
<div className="ml-auto hidden items-center gap-2 md:flex">
{renderSearchInput("w-64")}
{renderTooltip(
activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode",
<Button
isIconOnly
aria-label={activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
variant="ghost"
onPress={() => {
setTheme(activeTheme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
}}
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>,
)}
<div className="flex items-center gap-2 md:hidden">
<Tooltip
content={theme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
>
<Button
variant="ghost"
onPress={() => {
setTheme(theme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
}}
isDisabled={!mounted}
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>
</Tooltip>
<Tooltip content={isMobileMenuOpen ? "Close menu" : "Open menu"}>
<Button
variant="ghost"
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
onPress={() => setIsMobileMenuOpen((current) => !current)}
>
<FontAwesomeIcon icon={isMobileMenuOpen ? faXmark : faBars} />
</Button>
</Tooltip>
</div>
</Navbar>
{renderTooltip(
"Join Discord",
<Button
isIconOnly
aria-label="Join Discord"
variant="ghost"
onPress={() => {
window.open(
"https://discord.com/channels/791066041198968873/1339996286514106409",
"_blank",
"noopener,noreferrer",
);
}}
>
<FontAwesomeIcon icon={faDiscord} />
</Button>,
)}
{renderTooltip(
"View on GitHub",
<Button
isIconOnly
aria-label="View on GitHub"
variant="ghost"
onPress={() => {
window.open(
"https://github.com/BishopFox/sliver",
"_blank",
"noopener,noreferrer",
);
}}
>
<FontAwesomeIcon icon={faGithub} />
</Button>,
)}
</div>
<div className="ml-auto flex items-center gap-2 md:hidden">
{renderTooltip(
activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode",
<Button
isIconOnly
aria-label={activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
variant="ghost"
onPress={() => {
setTheme(activeTheme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
}}
>
<FontAwesomeIcon icon={lightDarkModeIcon} />
</Button>,
)}
{renderTooltip(
isMobileMenuOpen ? "Close menu" : "Open menu",
<Button
isIconOnly
variant="ghost"
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
onPress={() => setIsMobileMenuOpen((current) => !current)}
>
<FontAwesomeIcon icon={isMobileMenuOpen ? faXmark : faBars} />
</Button>,
)}
</div>
</div>
</nav>
{isMobileMenuOpen ? (
<div className="md:hidden border-b border-default-200 dark:border-default-100/40 bg-content1 px-4 pb-4 shadow-sm">
<div className="sliver-navbar-glass px-4 pb-4 shadow-sm md:hidden">
<div className="mt-3">{renderSearchInput("w-full")}</div>
<div className="mt-4 flex flex-col gap-2">
{renderMobileRoute("/", "Home")}
{routes.map((route) => renderMobileRoute(route.href, route.label))}
<Button
variant={router.pathname === "/" ? "solid" : "light"}
color={router.pathname === "/" ? "primary" : "default"}
onPress={() => {
router.push("/");
setIsMobileMenuOpen(false);
}}
startContent={<FontAwesomeIcon fixedWidth icon={faHome} />}
>
Home
</Button>
<Button
variant={router.pathname.startsWith("/tutorials") ? "solid" : "light"}
color={router.pathname.startsWith("/tutorials") ? "primary" : "default"}
onPress={() => {
router.push("/tutorials");
setIsMobileMenuOpen(false);
}}
>
Tutorials
</Button>
<Button
variant={router.pathname.startsWith("/talks") ? "solid" : "light"}
color={router.pathname.startsWith("/talks") ? "primary" : "default"}
onPress={() => {
router.push("/talks");
setIsMobileMenuOpen(false);
}}
>
Talks
</Button>
<Button
variant={router.pathname.startsWith("/docs") ? "solid" : "light"}
color={router.pathname.startsWith("/docs") ? "primary" : "default"}
onPress={() => {
router.push("/docs");
setIsMobileMenuOpen(false);
}}
>
Docs
</Button>
<Button
variant="light"
fullWidth
variant="ghost"
onPress={() => {
window.open(
"https://discord.com/channels/791066041198968873/1339996286514106409",
"_blank",
"noopener,noreferrer",
);
}}
startContent={<FontAwesomeIcon icon={faDiscord} />}
>
<FontAwesomeIcon icon={faDiscord} />
Discord
</Button>
<Button
variant="light"
fullWidth
variant="ghost"
onPress={() => {
window.open("https://github.com/BishopFox/sliver", "_blank");
window.open(
"https://github.com/BishopFox/sliver",
"_blank",
"noopener,noreferrer",
);
}}
startContent={<FontAwesomeIcon icon={faGithub} />}
>
<FontAwesomeIcon icon={faGithub} />
GitHub
</Button>
</div>
+16 -14
View File
@@ -1,4 +1,4 @@
import { Button, Card, CardFooter, CardHeader } from "@heroui/react";
import { Button, Card } from "@heroui/react";
import AsciinemaPlayer from "./asciinema";
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
@@ -21,15 +21,18 @@ export type TutorialCardCardProps = {
};
export default function TutorialCard(props: TutorialCardCardProps) {
const actionButtonClassName =
"ml-auto h-auto min-h-0 shrink-0 px-0 py-0 text-sm font-semibold [--button-bg-hover:transparent] [--button-bg-pressed:transparent] [--button-bg:transparent] [--button-fg:var(--warning)]";
return (
<Card isFooterBlurred className="relative z-0">
<CardHeader className="absolute z-10 top-1 flex-col items-end">
<p className="text-md text-white/70 uppercase font-bold">
<Card className="relative z-0 gap-0 overflow-hidden p-0">
<Card.Header className="absolute left-3 right-3 top-3 z-10 flex-col items-end p-0 text-right">
<p className="text-sm font-bold uppercase leading-none text-white/70">
{props.name}
</p>
</CardHeader>
</Card.Header>
<div className={styles["hide-control-bar"]}>
<div className={`${styles["hide-control-bar"]} overflow-hidden`}>
<AsciinemaPlayer
src={props.asciiCast}
rows={props.rows ? props.rows : "18"}
@@ -41,13 +44,13 @@ export default function TutorialCard(props: TutorialCardCardProps) {
/>
</div>
<CardFooter className="absolute bottom-0 z-10 bg-black/40 border-t-1 border-default-600 dark:border-default-100">
<div className="flex flex-row w-full items-center">
<Card.Footer className="absolute bottom-0 left-0 right-0 z-10 border-t border-white/20 bg-black/40 p-3 backdrop-blur-md">
<div className="flex min-h-10 w-full flex-row items-center gap-4">
<p
className={
props.italicDescription
? "text-xs text-white/80 italic"
: "text-xs text-white/80"
? "min-w-0 flex-1 text-sm leading-snug text-white/80 italic"
: "min-w-0 flex-1 text-sm leading-snug text-white/80"
}
>
{props.description}
@@ -56,19 +59,18 @@ export default function TutorialCard(props: TutorialCardCardProps) {
{props.showButton ? (
<Button
variant="ghost"
color="warning"
size="sm"
onPress={props.onPress}
className="ml-auto"
className={actionButtonClassName}
>
{props.buttonText ? props.buttonText : "Read Tutorial"}{" "}
{props.buttonText ? props.buttonText : "Read Tutorial"}
<FontAwesomeIcon icon={faChevronRight} />
</Button>
) : (
<></>
)}
</div>
</CardFooter>
</Card.Footer>
</Card>
);
}
+1
View File
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
reactStrictMode: true,
images: {
unoptimized: true,
+1055 -3680
View File
File diff suppressed because it is too large. Load diff
+30 -25
View File
@@ -14,40 +14,45 @@
},
"dependencies": {
"@fontsource/fira-code": "^5.2.7",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@heroui/react": "^2.8.5",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1",
"@heroui/react": "^3.0.3",
"@heroui/styles": "^3.0.3",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/postcss": "^4.1.14",
"@tanstack/react-query": "^5.90.2",
"asciinema-player": "^3.11.0",
"framer-motion": "^12.23.22",
"fuse.js": "^7.1.0",
"@tailwindcss/postcss": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"asciinema-player": "^3.15.1",
"fuse.js": "^7.3.0",
"lunr": "^2.3.9",
"mermaid": "^11.12.2",
"next": "^16.1.6",
"mermaid": "^11.14.0",
"next": "^16.2.4",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/lunr": "^2.3.7",
"@types/node": "^24.6.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.4.21",
"concurrently": "^9.1.0",
"eslint": "^9.37.0",
"eslint-config-next": "^16.1.6",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3"
"autoprefixer": "^10.5.0",
"concurrently": "^9.2.1",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.4",
"postcss": "^8.5.13",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3"
},
"overrides": {
"dompurify": "^3.4.2",
"postcss": "^8.5.13",
"uuid": "^14.0.0"
}
}
+59 -62
View File
@@ -8,7 +8,6 @@ import { fetchDocs as fetchDocsContent, fetchTutorials as fetchTutorialsContent
import { Themes } from "@/util/themes";
import { faExternalLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HeroUIProvider } from "@heroui/react";
import {
HydrationBoundary,
QueryClient,
@@ -94,66 +93,64 @@ export default function App({ Component, pageProps }: AppProps) {
});
return (
<HeroUIProvider>
<NextThemesProvider
attribute="class"
defaultTheme={Themes.DARK}
enableSystem={false}
storageKey="theme"
>
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<SearchContext.Provider value={search}>
<Navbar />
<Component {...pageProps} />
<div className="mb-16 md:mb-20"></div>
<footer className="z-20 w-full border-t border-gray-200 bg-white px-3 py-4 text-center shadow dark:bg-black dark:border-gray-600 md:fixed md:bottom-0 md:left-0 md:flex md:items-center md:justify-between md:px-6 md:py-4">
<div className="flex flex-col items-center gap-2 text-xs leading-snug text-gray-500 sm:text-sm md:flex-row md:items-center md:gap-3 md:text-left dark:text-gray-400">
<span>
© {new Date().getFullYear()}&nbsp;
<a
href="https://bishopfox.com/"
className="hover:underline"
rel="noreferrer"
target="_blank"
>
Bishop Fox
</a>
</span>
<span className="hidden md:inline" aria-hidden="true">
·
</span>
<span className="max-w-xs md:max-w-none">
<a
href="https://github.com/BishopFox/sliver/pulls"
rel="noreferrer"
className="hover:underline"
target="_blank"
>
Help improve this documentation
<span className="ml-1 inline-block">
<FontAwesomeIcon icon={faExternalLink} />
</span>
</a>
</span>
</div>
<ul className="mt-3 flex flex-wrap items-center justify-center gap-3 text-xs font-medium text-gray-500 md:mt-0 md:gap-6 md:text-sm dark:text-gray-400">
<li>
<a
href="https://github.com/BishopFox/sliver/blob/master/LICENSE"
target="_blank"
rel="noreferrer"
className="hover:underline"
>
GPLv3 License
</a>
</li>
</ul>
</footer>
</SearchContext.Provider>
</HydrationBoundary>
</QueryClientProvider>
</NextThemesProvider>
</HeroUIProvider>
<NextThemesProvider
attribute="class"
defaultTheme={Themes.DARK}
enableSystem={false}
storageKey="theme"
>
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<SearchContext.Provider value={search}>
<Navbar />
<Component {...pageProps} />
<div className="mb-16 md:mb-20"></div>
<footer className="z-20 w-full border-t border-separator bg-surface/80 px-3 py-4 text-center shadow backdrop-blur-md dark:bg-black/75 md:fixed md:bottom-0 md:left-0 md:right-0 md:flex md:items-center md:justify-between md:px-6 md:py-4">
<div className="flex flex-col items-center gap-2 text-xs leading-snug text-muted sm:text-sm md:flex-row md:items-center md:gap-3 md:text-left">
<span>
© {new Date().getFullYear()}&nbsp;
<a
href="https://bishopfox.com/"
className="hover:underline"
rel="noreferrer"
target="_blank"
>
Bishop Fox
</a>
</span>
<span className="hidden md:inline" aria-hidden="true">
·
</span>
<span className="max-w-xs md:max-w-none">
<a
href="https://github.com/BishopFox/sliver/pulls"
rel="noreferrer"
className="hover:underline"
target="_blank"
>
Help improve this documentation
<span className="ml-1 inline-block">
<FontAwesomeIcon icon={faExternalLink} />
</span>
</a>
</span>
</div>
<ul className="mt-3 flex flex-wrap items-center justify-center gap-3 text-xs font-medium text-muted md:mt-0 md:gap-6 md:text-sm">
<li>
<a
href="https://github.com/BishopFox/sliver/blob/master/LICENSE"
target="_blank"
rel="noreferrer"
className="hover:underline"
>
GPLv3 License
</a>
</li>
</ul>
</footer>
</SearchContext.Provider>
</HydrationBoundary>
</QueryClientProvider>
</NextThemesProvider>
);
}
+47 -55
View File
@@ -3,30 +3,24 @@ import LoadingState from "@/components/loading-state";
import { Docs } from "@/util/docs";
import { PREBUILD_VERSION } from "@/util/__generated__/prebuild-version";
import { fetchDocs as fetchDocsContent } from "@/util/content-fetchers";
import { Themes } from "@/util/themes";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Card,
CardBody,
CardHeader,
Divider,
Input,
Listbox,
ListboxItem,
ListBox,
ScrollShadow,
SearchField,
Separator,
} from "@heroui/react";
import { useQuery } from "@tanstack/react-query";
import Fuse from "fuse.js";
import { NextPage } from "next";
import { useTheme } from "next-themes";
import Head from "next/head";
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/router";
import React from "react";
const DocsIndexPage: NextPage = () => {
const { theme } = useTheme();
const router = useRouter();
const { data: docs, isLoading } = useQuery({
@@ -75,13 +69,28 @@ const DocsIndexPage: NextPage = () => {
return selectedDoc ? [selectedDoc, ...visibleDocs] : visibleDocs;
}, [docs, name, visibleDocs]);
const listboxClasses = React.useMemo(() => {
if (theme === Themes.DARK) {
return "p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 overflow-visible shadow-small rounded-large";
} else {
return "p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 overflow-visible rounded-large";
}
}, [theme]);
const listboxClasses =
"p-0 gap-0 divide-y divide-separator bg-surface overflow-visible rounded-lg shadow-sm";
const renderFilterInput = (className?: string) => (
<SearchField
aria-label="Filter documents"
className={className}
fullWidth
value={filterValue}
onChange={setFilterValue}
>
<SearchField.Group>
<SearchField.SearchIcon>
<FontAwesomeIcon icon={faSearch} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="Filter..." />
{filterValue.length > 0 ? (
<SearchField.ClearButton aria-label="Clear document filter" />
) : null}
</SearchField.Group>
</SearchField>
);
if (isLoading || !docs || (hasNameQueryInPath && !name)) {
return <LoadingState />;
@@ -99,19 +108,10 @@ const DocsIndexPage: NextPage = () => {
>
Select a document
</label>
<div className="mt-2">
<Input
placeholder="Filter..."
startContent={<FontAwesomeIcon icon={faSearch} />}
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
isClearable={true}
onClear={() => setFilterValue("")}
/>
</div>
<div className="mt-2">{renderFilterInput()}</div>
<select
id="docs-mobile-selector"
className="mt-3 w-full rounded-lg border border-default-200 bg-content1 p-2 text-sm dark:border-default-100/60"
className="mt-3 w-full rounded-lg border border-separator bg-surface p-2 text-sm"
value={name}
onChange={(event) => {
const selectedName = event.target.value;
@@ -132,58 +132,50 @@ const DocsIndexPage: NextPage = () => {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 lg:gap-8">
<aside className="hidden lg:block lg:col-span-3">
<div className="sticky top-16 ml-4 flex flex-col gap-3">
<Input
className="mt-2"
placeholder="Filter..."
startContent={<FontAwesomeIcon icon={faSearch} />}
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
isClearable={true}
onClear={() => setFilterValue("")}
/>
<ScrollShadow className="max-h-[calc(100vh-15rem)] sliver-scrollbar overflow-y-auto pr-1 rounded-large">
<Listbox
{renderFilterInput("mt-2")}
<ScrollShadow className="max-h-[calc(100vh-15rem)] sliver-scrollbar overflow-y-auto pr-1 rounded-lg">
<ListBox
aria-label="Toolbox Menu"
className={listboxClasses}
itemClasses={{
base: "px-3 first:rounded-t-large last:rounded-b-large rounded-none gap-3 h-12 data-[hover=true]:bg-default-100/80",
onAction={(key) => {
router.push({
pathname: "/docs",
query: { name: String(key) },
});
}}
>
{visibleDocs.map((doc) => (
<ListboxItem
<ListBox.Item
key={doc.name}
onClick={() => {
router.push({
pathname: "/docs",
query: { name: doc.name },
});
}}
id={doc.name}
textValue={doc.name}
className="h-12 rounded-none px-3 first:rounded-t-lg last:rounded-b-lg"
>
{doc.name}
</ListboxItem>
</ListBox.Item>
))}
</Listbox>
</ListBox>
</ScrollShadow>
</div>
</aside>
<div className="px-4 pb-8 lg:col-span-9 lg:px-8">
{name !== "" ? (
<Card className="mt-2">
<CardHeader>
<Card.Header>
<span className="text-3xl">{name}</span>
</CardHeader>
<Divider />
<CardBody>
</Card.Header>
<Separator />
<Card.Content>
<MarkdownViewer
key={name}
markdown={markdown || ""}
/>
</CardBody>
</Card.Content>
</Card>
) : (
<div className="mt-8 text-center text-2xl text-foreground/90">
Welcome to the Sliver Wiki!
<div className="mt-2 text-xl text-default-500">
<div className="mt-2 text-xl text-muted">
Please select a document
</div>
</div>
@@ -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.
@@ -12,6 +12,7 @@ daemon:
host: ""
port: 31337
tailscale: false
enable_wg: false
logs:
level: 4
grpc_unary_payloads: false
@@ -51,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.
- `enable_wg` - Wrap multiplayer in WireGuard. If omitted or false, multiplayer is exposed directly over mTLS.
- `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.
@@ -140,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 WireGuard-enabled profile with `new-operator --enable-wg` or `sliver-server operator --enable-wg` when you want 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 default, or explicitly opt into the wrapper with `multiplayer --enable-wg` or `sliver-server daemon --enable-wg`. Generate matching operator profiles with `new-operator --enable-wg` or `sliver-server operator --enable-wg` when you want the client-side WireGuard wrapper.
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
- `--enable-wg` wrap multiplayer in WireGuard instead of exposing it directly over mTLS
- `-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.enable_wg` from `server.yaml`.
- With `--enable-wg` or `daemon.enable_wg: true`, multiplayer is wrapped in WireGuard. Otherwise it stays on direct TCP mTLS.
### Example Config
@@ -25,6 +29,7 @@ daemon:
host: ""
port: 31337
tailscale: false
enable_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 generates direct multiplayer profiles by default. Add `--enable-wg` when the daemon is running with the WireGuard wrapper.
### 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
@@ -1,4 +1,6 @@
Multiplayer-mode allows multiple operators (players) to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script) which will configure the server as a systemd service. However, any Sliver server binary supports multiplayer mode.
Multiplayer mode allows multiple operators to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script), which configures the server as a systemd service. However, any `sliver-server` binary supports multiplayer mode.
Sliver exposes the operator-facing multiplayer listener directly over gRPC/mTLS by default. You can opt into a dedicated WireGuard wrapper with `--enable-wg`; the gRPC/mTLS authentication stack still runs inside that tunnel.
```
┌──────────────────┐ C2
@@ -8,7 +10,8 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
└──────────────────┘
▲
│
gRPC/mTLS │
UDP/WireGuard (default) │
gRPC/mTLS inside │
┌────────────┬────────┴─────┬───────────┐
│ │ │ │
┌─────┴──┐ │ │ ┌──┴─────┐
@@ -18,32 +21,39 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
└────────┘ └────────┘
```
## New Operators
## Default WireGuard-Protected Mode
Operators will need to download their preferred platform's client software from the releases page. Clients can connect to servers of differing platforms (e.g. a Windows client can connect to a Linux server). Operators and servers authenticate using Mutual TLS, however all the certificates are managed automatically for you.
- If you did not install the server as a systemd service, be sure to leave the server console running in screen or [tmux](https://github.com/tmux/tmux) after starting the multiplayer listener.
- If you did install the server as systemd service see the CLI section below for an alternative to the console commands for generating operator configs.
- If you did install the server as a systemd service, see the CLI section below for an alternative to the console commands for generating operator configs.
Operators need a configuration file generated by the Sliver server to connect in multiplayer mode. The Sliver client can support multiple configuration files, but you can only be connected to one server at a time (per process).
From the server use the `new-operator` command to generate a new operator configuration file. You'll need to specify the operator's `--name` and the `--lhost` of the server (e.g., its public IP). Then from the server start a multiplayer listener using the `multiplayer` command to allow operators to connect:
In the default mode:
**Note:** The `new-operator` and `multiplayer` commands are only accessible from the server's console
- `multiplayer` exposes a WireGuard listener on `--lport` (default `31337`) over UDP.
- The authenticated gRPC/mTLS server only listens inside that WireGuard tunnel.
- `new-operator` generates a per-operator WireGuard keypair and tunnel IP, then embeds that information in the operator config's `wg` block.
- The multiplayer listener uses WireGuard keys that are separate from Sliver's implant `wg` C2 listener.
- `sliver-client` automatically uses the WireGuard wrapper when the imported config contains that `wg` block.
From the server, start the multiplayer listener and generate an operator config with the server's public address or hostname in `--lhost`. Use `--permissions all` for a standard interactive operator profile:
**Note:** The `new-operator` and `multiplayer` commands are only available from the server console.
```
[server] sliver > new-operator --name moloch --lhost 1.2.3.4
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
[server] sliver > multiplayer
[*] Multiplayer mode enabled!
[server] sliver > new-operator --name moloch --lhost 1.2.3.4 --permissions all
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
```
**IMPORTANT:** Before clients can connect to a server you must start an RPC listener with the `multiplayer` command. The default port is TCP/31337.
**IMPORTANT:** Before clients can connect to a server you must start a multiplayer listener with the `multiplayer` command. By default multiplayer is exposed directly on TCP/31337. If you enable the wrapper, the outer listener becomes UDP/31337 and the gRPC/mTLS service stays inside the tunnel.
You can now give this configuration file `moloch_example.com.cfg` to the operator and they can connect to the server using the `sliver-client` binary. The Sliver client will look for configuration files in `~/.sliver-client/configs/` or you can import configs using the `import` CLI. The configs directory can contain multiple configs for different servers.
@@ -56,19 +66,48 @@ $ ./sliver-client
localhost
```
## Direct mTLS Mode
If you want the old behavior, or you need compatibility with third-party gRPC clients that do not implement the multiplayer WireGuard wrapper, disable it on both the listener and the generated operator profile:
```
[server] sliver > multiplayer
[*] Multiplayer mode enabled!
[server] sliver > new-operator --name tester --lhost 1.2.3.4 --permissions all
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/tester/Desktop/tester_example.com.cfg
```
In direct mode:
- Multiplayer is exposed directly over TCP on `--lport` (default `31337`).
- Generated operator configs omit the `wg` block.
- `sliver-client --enable-wg` opts into the multiplayer WireGuard wrapper when the operator config includes a `wg` block.
The listener mode, operator config, and client flag need to match. A WireGuard-wrapped listener needs a `wg` block in the operator profile plus `sliver-client --enable-wg`. Direct multiplayer works without the `wg` block and without the flag.
### Server CLI / Daemon Mode Multiplayer
If the server is running in daemon mode (as is the default with the Linux install script), that means the multiplayer listener is started for you without an interactive console. You can use the server binaries' CLI to generate operator configuration files to connect:
If the server is running in daemon mode, the multiplayer listener is started for you without an interactive console. By default the daemon uses direct multiplayer mTLS. You can opt into the WireGuard wrapper with `sliver-server daemon --enable-wg` or `daemon.enable_wg: true` in `server.yaml`.
Use the server CLI to generate operator configuration files:
```
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --save zer0cool.cfg
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
```
The installation scripts by default will place the `sliver-server` binary in `/root`.
Operator profiles omit the multiplayer `wg` block by default. Add `--enable-wg` when the listener is wrapped in WireGuard.
The installation script places the `sliver-server` binary in `/root` by default.
### Tailscale Integration
Sliver v1.6 supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when wanting to maintain operational security.
Sliver supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when you want to keep the multiplayer listener off the public Internet.
Tailscale mode is separate from the default multiplayer WireGuard wrapper. When you use `multiplayer -T`, Sliver exposes the listener only through Tailscale instead of layering its own multiplayer WireGuard tunnel on top.
```
$ export TS_AUTHKEY=<tailscale authkey>
@@ -77,4 +116,4 @@ $ ./sliver-server
sliver > multiplayer -T
```
You should now see a new Tailscale host named `sliver-server-<machine>`, you can set this using the `--lhost` flag. Use this as the hostname when connecting to the Sliver server from any other host on the Tailscale network.
You should now see a new Tailscale host named `sliver-server-<machine>`. Use that hostname as `--lhost` when generating operator configs for other hosts on the same tailnet. Tailscale multiplayer does not use the multiplayer WireGuard wrapper, so operator profiles should be generated without `--enable-wg`.
+27 -31
View File
@@ -4,7 +4,7 @@ import TutorialCard from "@/components/tutorial-card";
import { Themes } from "@/util/themes";
import { faDownload, faExternalLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Card, CardBody, CardHeader, Divider, Link } from "@heroui/react";
import { Button, Card, Separator } from "@heroui/react";
import { useTheme } from "next-themes";
import { useRouter } from "next/router";
import React from "react";
@@ -12,23 +12,15 @@ import React from "react";
export default function Home() {
const router = useRouter();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const isDarkTheme = React.useMemo(() => {
if (!mounted) {
return true;
}
return (resolvedTheme || Themes.DARK) !== Themes.LIGHT;
}, [mounted, resolvedTheme]);
}, [resolvedTheme]);
return (
<div className="mt-6 flex flex-col gap-6 px-4 sm:px-6 lg:grid lg:grid-cols-12 lg:px-12">
<div className="lg:col-span-6">
<div className="w-full overflow-hidden rounded-xl border border-default-200 bg-content1 shadow-sm dark:border-default-100/60">
<div className="w-full overflow-hidden rounded-xl border border-separator bg-surface shadow-sm">
<div className="w-full overflow-x-auto lg:overflow-visible">
<AsciinemaPlayer
src="/asciinema/intro.cast"
@@ -44,14 +36,14 @@ export default function Home() {
</div>
<div className="lg:col-span-6 lg:ml-2">
<Card className="mx-auto max-w-3xl lg:mx-0">
<CardHeader>
<Card.Header>
<div className="flex items-center">
<SliversIcon className="mr-2" height={28} />
<span className="text-2xl">Sliver Command &amp; Control</span>
</div>
</CardHeader>
<Divider />
<CardBody>
</Card.Header>
<Separator />
<Card.Content>
<p className={isDarkTheme ? "prose dark:prose-invert" : "prose prose-slate"}>
Sliver is a powerful command and control (C2) framework designed
to provide advanced capabilities for covertly managing and
@@ -69,35 +61,39 @@ export default function Home() {
<div className="mt-4 flex w-full gap-3">
<Button
className="flex-1"
color="primary"
variant="shadow"
as={Link}
href="https://github.com/BishopFox/sliver/releases/latest"
target="_blank"
rel="noopener noreferrer"
startContent={<FontAwesomeIcon icon={faDownload} />}
variant="primary"
onPress={() => {
window.open(
"https://github.com/BishopFox/sliver/releases/latest",
"_blank",
"noopener,noreferrer",
);
}}
>
<FontAwesomeIcon icon={faDownload} />
Download Latest Release
</Button>
<Button
className="flex-1"
color="secondary"
className="sliver-armory-ghost flex-1"
variant="ghost"
as={Link}
href="https://github.com/sliverarmory"
target="_blank"
rel="noopener noreferrer"
endContent={<FontAwesomeIcon icon={faExternalLink} />}
onPress={() => {
window.open(
"https://github.com/sliverarmory",
"_blank",
"noopener,noreferrer",
);
}}
>
Visit the Armory
<FontAwesomeIcon icon={faExternalLink} />
</Button>
</div>
</CardBody>
</Card.Content>
</Card>
</div>
<div className="col-span-12 mt-8">
<Divider />
<Separator />
</div>
<div className="col-span-12 mt-8">
+5 -5
View File
@@ -2,7 +2,7 @@ import MarkdownViewer from "@/components/markdown";
import { useSearchContext } from "@/util/search-context";
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Card, CardBody, Divider } from "@heroui/react";
import { Button, Card, Separator } from "@heroui/react";
import { NextPage } from "next";
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/router";
@@ -34,13 +34,13 @@ const SearchPage: NextPage = (props: SearchPageProps) => {
{searchResults.map((doc) => (
<Card key={doc.name}>
<CardBody className="flex flex-col gap-3">
<Card.Content className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-xl">{doc.name}</span>
<div className="flex-1"></div>
<Button
size="sm"
color="secondary"
variant="secondary"
className="w-full sm:w-[150px]"
onPress={() => {
router.push(`/docs?name=${doc.name}`);
@@ -51,12 +51,12 @@ const SearchPage: NextPage = (props: SearchPageProps) => {
</Button>
</div>
<Divider />
<Separator />
<div className="max-h-[200px] overflow-hidden">
<MarkdownViewer markdown={doc.content} />
</div>
</CardBody>
</Card.Content>
</Card>
))}
</div>
+27 -15
View File
@@ -1,7 +1,7 @@
import Youtube from "@/components/youtube";
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Accordion, AccordionItem, Button, Card, CardFooter } from "@heroui/react";
import { Accordion, Button, Card } from "@heroui/react";
import { NextPage } from "next";
import Head from "next/head";
import React from "react";
@@ -103,6 +103,8 @@ const TalksIndexPage: NextPage = () => {
const [footerHidden, setFooterHidden] = React.useState<Record<string, boolean>>(
{}
);
const actionButtonClassName =
"ml-auto h-auto min-h-0 shrink-0 px-0 py-0 text-sm font-semibold [--button-bg-hover:transparent] [--button-bg-pressed:transparent] [--button-bg:transparent] [--button-fg:var(--warning)]";
const hideFooterFor = React.useCallback((url: string) => {
setFooterHidden((prev) => (prev[url] ? prev : { ...prev, [url]: true }));
@@ -115,22 +117,31 @@ const TalksIndexPage: NextPage = () => {
</Head>
<div className="mt-6 px-4 pb-8 sm:px-6 lg:px-12">
<Accordion
selectionMode="multiple"
allowsMultipleExpanded
defaultExpandedKeys={["workshops", "general-tradecraft"]}
showDivider={false}
hideSeparator
className="px-0"
>
{talkSections.map((section) => (
<AccordionItem
<Accordion.Item
key={section.key}
aria-label={section.title}
title={section.title}
subtitle={section.description}
id={section.key}
>
<Accordion.Heading>
<Accordion.Trigger>
<span className="flex flex-col items-start text-left">
<span className="text-lg font-semibold">{section.title}</span>
<span className="text-sm text-muted">{section.description}</span>
</span>
<Accordion.Indicator className="ml-auto" />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-9">
{section.talks.map((talk) => (
<div key={talk.url} className="sm:col-span-1 lg:col-span-3">
<Card isFooterBlurred className="relative z-0 overflow-hidden">
<Card className="relative z-0 gap-0 overflow-hidden p-0">
<Youtube
className="w-full"
url={talk.url}
@@ -139,14 +150,13 @@ const TalksIndexPage: NextPage = () => {
/>
{footerHidden[talk.url] ? null : (
<CardFooter className="absolute bottom-0 z-10 bg-black/40 border-t-1 border-default-600 dark:border-default-100">
<div className="flex w-full items-center gap-2">
<p className="text-xs text-white/80">{talk.description}</p>
<Card.Footer className="absolute bottom-0 left-0 right-0 z-10 border-t border-white/20 bg-black/40 p-3 backdrop-blur-md">
<div className="flex min-h-10 w-full items-center gap-4">
<p className="min-w-0 flex-1 text-sm leading-snug text-white/80">{talk.description}</p>
<Button
variant="ghost"
color="warning"
size="sm"
className="ml-auto"
className={actionButtonClassName}
onPress={() => {
window.open(talk.url, "_blank", "noopener,noreferrer");
}}
@@ -154,13 +164,15 @@ const TalksIndexPage: NextPage = () => {
Watch <FontAwesomeIcon icon={faChevronRight} />
</Button>
</div>
</CardFooter>
</Card.Footer>
)}
</Card>
</div>
))}
</div>
</AccordionItem>
</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</div>
+47 -55
View File
@@ -3,30 +3,24 @@ import LoadingState from "@/components/loading-state";
import { Tutorials } from "@/util/tutorials";
import { PREBUILD_VERSION } from "@/util/__generated__/prebuild-version";
import { fetchTutorials as fetchTutorialsContent } from "@/util/content-fetchers";
import { Themes } from "@/util/themes";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Card,
CardBody,
CardHeader,
Divider,
Input,
Listbox,
ListboxItem,
ListBox,
ScrollShadow,
SearchField,
Separator,
} from "@heroui/react";
import { useQuery } from "@tanstack/react-query";
import Fuse from "fuse.js";
import { NextPage } from "next";
import { useTheme } from "next-themes";
import Head from "next/head";
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/router";
import React from "react";
const TutorialsIndexPage: NextPage = () => {
const { theme } = useTheme();
const router = useRouter();
const { data: tutorials, isLoading } = useQuery({
@@ -75,13 +69,28 @@ const TutorialsIndexPage: NextPage = () => {
return selectedTutorial ? [selectedTutorial, ...visibleTutorials] : visibleTutorials;
}, [name, tutorials, visibleTutorials]);
const listboxClasses = React.useMemo(() => {
if (theme === Themes.DARK) {
return "p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 overflow-visible shadow-small rounded-large";
} else {
return "p-0 gap-0 divide-y divide-default-300/50 dark:divide-default-100/80 bg-content1 overflow-visible rounded-large";
}
}, [theme]);
const listboxClasses =
"p-0 gap-0 divide-y divide-separator bg-surface overflow-visible rounded-lg shadow-sm";
const renderFilterInput = (className?: string) => (
<SearchField
aria-label="Filter tutorials"
className={className}
fullWidth
value={filterValue}
onChange={setFilterValue}
>
<SearchField.Group>
<SearchField.SearchIcon>
<FontAwesomeIcon icon={faSearch} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="Filter..." />
{filterValue.length > 0 ? (
<SearchField.ClearButton aria-label="Clear tutorial filter" />
) : null}
</SearchField.Group>
</SearchField>
);
if (isLoading || !tutorials || (hasNameQueryInPath && !name)) {
return <LoadingState />;
@@ -99,19 +108,10 @@ const TutorialsIndexPage: NextPage = () => {
>
Select a tutorial
</label>
<div className="mt-2">
<Input
placeholder="Filter..."
startContent={<FontAwesomeIcon icon={faSearch} />}
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
isClearable={true}
onClear={() => setFilterValue("")}
/>
</div>
<div className="mt-2">{renderFilterInput()}</div>
<select
id="tutorials-mobile-selector"
className="mt-3 w-full rounded-lg border border-default-200 bg-content1 p-2 text-sm dark:border-default-100/60"
className="mt-3 w-full rounded-lg border border-separator bg-surface p-2 text-sm"
value={name}
onChange={(event) => {
const selectedName = event.target.value;
@@ -132,58 +132,50 @@ const TutorialsIndexPage: NextPage = () => {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 lg:gap-8">
<aside className="hidden lg:block lg:col-span-3">
<div className="sticky top-16 ml-4 flex flex-col gap-3">
<Input
className="mt-2"
placeholder="Filter..."
startContent={<FontAwesomeIcon icon={faSearch} />}
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
isClearable={true}
onClear={() => setFilterValue("")}
/>
<ScrollShadow className="max-h-[calc(100vh-6rem)] sliver-scrollbar overflow-y-auto pr-1 rounded-large">
<Listbox
{renderFilterInput("mt-2")}
<ScrollShadow className="max-h-[calc(100vh-6rem)] sliver-scrollbar overflow-y-auto pr-1 rounded-lg">
<ListBox
aria-label="Toolbox Menu"
className={listboxClasses}
itemClasses={{
base: "px-3 first:rounded-t-large last:rounded-b-large rounded-none gap-3 h-12 data-[hover=true]:bg-default-100/80",
onAction={(key) => {
router.push({
pathname: "/tutorials",
query: { name: String(key) },
});
}}
>
{visibleTutorials.map((tutorial) => (
<ListboxItem
<ListBox.Item
key={tutorial.name}
onClick={() => {
router.push({
pathname: "/tutorials",
query: { name: tutorial.name },
});
}}
id={tutorial.name}
textValue={tutorial.name}
className="h-12 rounded-none px-3 first:rounded-t-lg last:rounded-b-lg"
>
{tutorial.name}
</ListboxItem>
</ListBox.Item>
))}
</Listbox>
</ListBox>
</ScrollShadow>
</div>
</aside>
<div className="px-4 pb-8 lg:col-span-9 lg:px-8">
{name !== "" ? (
<Card className="mt-2">
<CardHeader>
<Card.Header>
<span className="text-3xl">{name}</span>
</CardHeader>
<Divider />
<CardBody>
</Card.Header>
<Separator />
<Card.Content>
<MarkdownViewer
key={name}
markdown={markdown || ""}
/>
</CardBody>
</Card.Content>
</Card>
) : (
<div className="mt-8 text-center text-2xl text-foreground/90">
Welcome to the Sliver Tutorials!
<div className="mt-2 text-xl text-default-500">
<div className="mt-2 text-xl text-muted">
Please select a chapter
</div>
</div>
@@ -59,14 +59,16 @@ First, install the sliver-py extension using pip.
pip3 install sliver-py
```
Since our extension is essentially going to be another client connection to the sliver server, you’ll also need to enable multiplayer mode and generate a new profile
Since our extension is essentially going to be another client connection to the sliver server, you’ll also need to enable multiplayer mode and generate a new profile.
Current Sliver releases default to a WireGuard-protected multiplayer listener. Because this tutorial uses a third-party Python client, we’ll force direct multiplayer mode so the profile only contains the plain gRPC/mTLS settings:
```bash
[server] sliver > multiplayer
[server] sliver > multiplayer --disable-wg
[*] Multiplayer mode enabled!
[server] sliver > new-operator -n tester -l 127.0.0.1
[server] sliver > new-operator -n tester -l 127.0.0.1 --permissions all --disable-wg
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/tester/tools/tester_127.0.0.1.cfg
+36 -4
View File
@@ -42,6 +42,34 @@ echo "Running from $(pwd)"
echo "Using Minisign public key..."
stop_existing_sliver_daemon() {
local existing_sliver_server=""
if test -x /root/sliver-server; then
existing_sliver_server="/root/sliver-server"
elif command -v sliver-server &> /dev/null; then
existing_sliver_server="$(command -v sliver-server)"
fi
if [[ -z "$existing_sliver_server" ]]; then
return 0
fi
echo "Existing Sliver server install detected at $existing_sliver_server"
if command -v systemctl &> /dev/null && test -f /etc/systemd/system/sliver.service; then
if systemctl is-active --quiet sliver; then
echo "Stopping the Sliver systemd service before upgrade..."
systemctl stop sliver
fi
fi
if pgrep -f "${existing_sliver_server} daemon" > /dev/null 2>&1; then
echo "Stopping the running Sliver daemon before upgrade..."
pkill -f "${existing_sliver_server} daemon"
fi
}
# Download and Unpack Sliver Server
ARCH="$(uname -m)"
case "$ARCH" in
@@ -79,6 +107,8 @@ echo "Verifying signatures ..."
minisign -Vm "/root/$SLIVER_SERVER" -x "/root/$SLIVER_SERVER.minisig" -P "$SLIVER_MINISIGN_PUB_KEY"
minisign -Vm "/root/$SLIVER_CLIENT" -x "/root/$SLIVER_CLIENT.minisig" -P "$SLIVER_MINISIGN_PUB_KEY"
stop_existing_sliver_daemon
if test -f "/root/$SLIVER_SERVER"; then
echo "Moving the Sliver server executable to /root/sliver-server..."
mv "/root/$SLIVER_SERVER" /root/sliver-server
@@ -128,8 +158,6 @@ WantedBy=multi-user.target
EOF
chown root:root /etc/systemd/system/sliver.service
chmod 600 /etc/systemd/system/sliver.service
echo "Starting the Sliver service..."
systemctl start sliver # Start the service now
# Generate local configs
echo "Generating local configs ..."
@@ -137,7 +165,7 @@ echo "Generating local configs ..."
# Generate local configs
echo "Generating operator configs ..."
mkdir -p /root/.sliver-client/configs
/root/sliver-server operator --name root --lhost localhost --permissions all --save /root/.sliver-client/configs
/root/sliver-server operator --name root --lhost 127.0.0.1 --permissions all --save /root/.sliver-client/configs
chown -R root:root /root/.sliver-client/
USER_DIRS=(/home/*)
@@ -146,7 +174,11 @@ for USER_DIR in "${USER_DIRS[@]}"; do
if id -u "$USER" >/dev/null 2>&1; then
echo "Generating operator configs for user $USER..."
mkdir -p "$USER_DIR/.sliver-client/configs"
/root/sliver-server operator --name "$USER" --lhost localhost --permissions all --save "$USER_DIR/.sliver-client/configs"
/root/sliver-server operator --name "$USER" --lhost 127.0.0.1 --permissions all --save "$USER_DIR/.sliver-client/configs"
chown -R "$USER":"$(id -gn "$USER")" "$USER_DIR/.sliver-client/"
fi
done
echo "Reloading systemd and starting the Sliver service..."
systemctl daemon-reload
systemctl start sliver
+1 -1
View File
@@ -1 +1 @@
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n![Alt text](/images/Architecture.png)\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions use long-polling connections, which means they constantly maintain a single TCP connection open while communicating with the server. Beacons on the other hand call back periodically and will sleep when not active which can help keep their presence hidden. You could limit callbacks to once every 6 hours for example and only 'activate' the beacon when needed by switching its sleeping time to a shorter duration.\n\nTypically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.\n\nLet’s start with generating and deploying a beacon using `http`.\n\n```asciinema\n{\"src\": \"/asciinema/beacon_generation.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nYou can see the beacon callback times either in the `info` command or using `beacons watch`.\n\n```bash\n[server] sliver > beacons watch\n\n ID Name Transport Username Operating System Last Check-In Next Check-In\n========== =============== =========== ================= ==========Line truncated
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n![Alt text](/images/Architecture.png)\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions use long-polling connections, which means they constantly maintain a single TCP connection open while communicating with the server. Beacons on the other hand call back periodically and will sleep when not active which can help keep their presence hidden. You could limit callbacks to once every 6 hours for example and only 'activate' the beacon when needed by switching its sleeping time to a shorter duration.\n\nTypically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.\n\nLet’s start with generating and deploying a beacon using `http`.\n\n```asciinema\n{\"src\": \"/asciinema/beacon_generation.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nYou can see the beacon callback times either in the `info` command or using `beacons watch`.\n\n```bash\n[server] sliver > beacons watch\n\n ID Name Transport Username Operating System Last Check-In Next Check-In\n========== =============== =========== ================= ==========Line truncated
+16 -6
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "@heroui/styles";
@config "../tailwind.config.js";
@@ -7,12 +8,6 @@
Consolas, "Liberation Mono", "Courier New", monospace;
}
/* Keep layout width stable when page content toggles vertical scrolling. */
html,
body {
scrollbar-gutter: stable both-edges;
}
/*
Keep html as the single scrolling element. Setting overflow on both html
and body breaks sticky sidebar behavior on docs/tutorial pages.
@@ -68,6 +63,21 @@ pre {
background-clip: content-box;
}
.sliver-navbar-glass {
width: 100%;
border-bottom: 1px solid var(--separator);
background-color: color-mix(in oklab, var(--surface) 72%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.18);
backdrop-filter: blur(18px) saturate(1.18);
}
.sliver-armory-ghost {
--button-bg: transparent;
--button-bg-hover: rgb(109 40 217 / 0.16);
--button-bg-pressed: rgb(109 40 217 / 0.24);
--button-fg: #a78bfa;
}
.dark .sliver-scrollbar {
scrollbar-color: rgba(79, 97, 125, 0.8) transparent;
}
+2 -4
View File
@@ -1,11 +1,9 @@
const { heroui } = require("@heroui/theme");
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
@@ -16,5 +14,5 @@ module.exports = {
},
},
darkMode: "class",
plugins: [heroui()],
plugins: [],
};
+19 -11
View File
@@ -840,22 +840,30 @@ func servicesListHandler(data []byte, resp RPCResponse) {
}
serviceInfo, err := service.ListServices(servicesReq.Hostname)
/*
Errors from listing the services are not fatal. The client can
display a message to the user about the issue
We still want other errors like timeouts to be handled in the
normal way.
*/
servicesResp := &sliverpb.Services{
Details: serviceInfo,
Error: err.Error(),
Response: &commonpb.Response{},
}
servicesResp := buildServicesResp(serviceInfo, err)
data, err = proto.Marshal(servicesResp)
resp(data, err)
}
// buildServicesResp packages the result of service.ListServices into a
// sliverpb.Services response. Errors from listing services are not fatal
// (partial results may still be useful to the operator), so a non-nil error
// is reported via the Error string field rather than failing the RPC.
// The nil-check is load-bearing: callers previously called err.Error()
// unconditionally, crashing the implant when ListServices succeeded cleanly.
func buildServicesResp(details []*sliverpb.ServiceDetails, err error) *sliverpb.Services {
errStr := ""
if err != nil {
errStr = err.Error()
}
return &sliverpb.Services{
Details: details,
Error: errStr,
Response: &commonpb.Response{},
}
}
func serviceDetailHandler(data []byte, resp RPCResponse) {
serviceDetailReq := &sliverpb.ServiceDetailReq{}
err := proto.Unmarshal(data, serviceDetailReq)
+48 -9
View File
@@ -34,21 +34,60 @@ import (
"unsafe"
)
// LocalTask - Run a shellcode in the current process
// Will hang the process until shellcode completion
// TODO: actually test this code
// Adapted/stolen from: https://github.com/lesnuages/hershell/blob/master/shell/shell_default.go#L48
//go:linkname localTaskBeforeFork syscall.runtime_BeforeFork
func localTaskBeforeFork()
//go:linkname localTaskAfterFork syscall.runtime_AfterFork
func localTaskAfterFork()
//go:linkname localTaskAfterForkInChild syscall.runtime_AfterForkInChild
func localTaskAfterForkInChild()
// LocalTask - Run shellcode in a forked child; blocks until the child exits.
// Forking isolates the beacon from payloads that exit/execve (issue #2237).
func LocalTask(data []byte, rwxPages bool) error {
dataAddr := uintptr(unsafe.Pointer(&data[0]))
page := getPage(dataAddr)
syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_EXEC)
pageAddr := uintptr(unsafe.Pointer(&page[0]))
pageLen := uintptr(len(page))
dataPtr := unsafe.Pointer(&data)
// funcPtr trick from hershell (https://github.com/lesnuages/hershell)
funcPtr := *(*func())(unsafe.Pointer(&dataPtr))
runtime.LockOSThread()
defer runtime.UnlockOSThread()
go func(fPtr func()) {
fPtr()
}(funcPtr)
localTaskBeforeFork()
// Raw SYS_FORK on Darwin: r0 holds child's pid in BOTH processes; r1==1
// flags the child (libSystem's fork() wrapper hides this).
r0, isChild, errno := syscall.RawSyscall(syscall.SYS_FORK, 0, 0, 0)
if errno != 0 {
localTaskAfterFork()
runtime.UnlockOSThread()
return errno
}
if isChild == 1 {
localTaskAfterForkInChild()
for fd := uintptr(3); fd < 1024; fd++ {
syscall.RawSyscall(syscall.SYS_CLOSE, fd, 0, 0)
}
syscall.RawSyscall(syscall.SYS_MPROTECT, pageAddr, pageLen, uintptr(syscall.PROT_READ|syscall.PROT_EXEC))
funcPtr()
for {
syscall.RawSyscall(syscall.SYS_EXIT, 0, 0, 0)
}
}
localTaskAfterFork()
var ws syscall.WaitStatus
_, err := syscall.Wait4(int(r0), &ws, 0, nil)
runtime.UnlockOSThread()
if err != nil {
return err
}
if ws.Signaled() {
return fmt.Errorf("shellcode child killed by signal %v", ws.Signal())
}
if ws.ExitStatus() != 0 {
return fmt.Errorf("shellcode child exited %d", ws.ExitStatus())
}
return nil
}
@@ -0,0 +1,127 @@
//go:build darwin
package taskrunner
/*
Sliver Implant Framework
Copyright (C) 2019 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 (
"bytes"
"fmt"
"os"
"os/exec"
"runtime"
"testing"
"time"
"unsafe"
)
const localTaskHelperEnv = "SLIVER_LOCALTASK_HELPER"
const localTaskSentinel = "PARENT_ALIVE_AFTER_LOCALTASK"
// darwin/amd64 mov eax,0x2000001 ; xor edi,edi ; syscall — exit(0)
var exitShellcodeDarwinAmd64 = []byte{0xb8, 0x01, 0x00, 0x00, 0x02, 0x31, 0xff, 0x0f, 0x05}
// darwin/amd64 mov eax,0x2000001 ; mov edi,42 ; syscall — exit(42)
var exit42ShellcodeDarwinAmd64 = []byte{0xb8, 0x01, 0x00, 0x00, 0x02, 0xbf, 0x2a, 0x00, 0x00, 0x00, 0x0f, 0x05}
// darwin/arm64 movz x0,#0 ; movz x16,#1 ; svc #0x80 — exit(0)
// Stored as [N]uint32 so the backing storage is 4-byte aligned (ARM64 SIGBUS
// otherwise).
var exitShellcodeDarwinArm64Words = [3]uint32{0xd2800000, 0xd2800030, 0xd4001001}
// darwin/arm64 movz x0,#42 ; movz x16,#1 ; svc #0x80 — exit(42)
var exit42ShellcodeDarwinArm64Words = [3]uint32{0xd2800540, 0xd2800030, 0xd4001001}
func asBytes[T any](words *T) []byte {
return unsafe.Slice((*byte)(unsafe.Pointer(words)), unsafe.Sizeof(*words))
}
func exitShellcode() []byte {
switch runtime.GOARCH {
case "amd64":
return exitShellcodeDarwinAmd64
case "arm64":
return asBytes(&exitShellcodeDarwinArm64Words)
}
return nil
}
func exit42Shellcode() []byte {
switch runtime.GOARCH {
case "amd64":
return exit42ShellcodeDarwinAmd64
case "arm64":
return asBytes(&exit42ShellcodeDarwinArm64Words)
}
return nil
}
func TestMain(m *testing.M) {
switch os.Getenv(localTaskHelperEnv) {
case "exit0":
_ = LocalTask(exitShellcode(), false)
time.Sleep(500 * time.Millisecond)
fmt.Println(localTaskSentinel)
os.Exit(0)
case "exit42":
err := LocalTask(exit42Shellcode(), false)
if err != nil {
fmt.Printf("LOCALTASK_ERR=%s\n", err.Error())
} else {
fmt.Println("LOCALTASK_ERR=<nil>")
}
os.Exit(0)
}
os.Exit(m.Run())
}
func runHelper(t *testing.T, mode string) []byte {
t.Helper()
if exitShellcode() == nil {
t.Skipf("no exit shellcode for GOARCH=%s", runtime.GOARCH)
}
cmd := exec.Command(os.Args[0])
cmd.Env = append(os.Environ(), localTaskHelperEnv+"="+mode)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("helper subprocess (%s) exited abnormally: %v\noutput: %s", mode, err, out)
}
return out
}
func TestLocalTask_ParentSurvives(t *testing.T) {
out := runHelper(t, "exit0")
if !bytes.Contains(out, []byte(localTaskSentinel)) {
t.Fatalf("expected sentinel %q in helper output:\n%s", localTaskSentinel, out)
}
}
// Accepts signal-kill as well as clean non-zero exit: macOS Apple Silicon
// W^X faults mprotected-page execution with SIGBUS regardless of the
// shellcode bytes, which still exercises LocalTask's error path.
func TestLocalTask_ReportsChildFailure(t *testing.T) {
out := runHelper(t, "exit42")
if bytes.Contains(out, []byte("LOCALTASK_ERR=shellcode child exited 42")) {
return
}
if bytes.Contains(out, []byte("LOCALTASK_ERR=shellcode child killed by signal")) {
return
}
t.Fatalf("expected LOCALTASK_ERR for exit-42 or signal kill:\n%s", out)
}
+47 -7
View File
@@ -34,19 +34,59 @@ import (
//{{end}}
)
// LocalTask - Run a shellcode in the current process
// Will hang the process until shellcode completion
//go:linkname localTaskBeforeFork syscall.runtime_BeforeFork
func localTaskBeforeFork()
//go:linkname localTaskAfterFork syscall.runtime_AfterFork
func localTaskAfterFork()
//go:linkname localTaskAfterForkInChild syscall.runtime_AfterForkInChild
func localTaskAfterForkInChild()
// LocalTask - Run shellcode in a forked child; blocks until the child exits.
// Forking isolates the beacon from payloads that exit/execve (issue #2237).
func LocalTask(data []byte, rwxPages bool) error {
dataAddr := uintptr(unsafe.Pointer(&data[0]))
page := getPage(dataAddr)
syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_EXEC)
pageAddr := uintptr(unsafe.Pointer(&page[0]))
pageLen := uintptr(len(page))
dataPtr := unsafe.Pointer(&data)
// funcPtr trick from hershell (https://github.com/lesnuages/hershell)
funcPtr := *(*func())(unsafe.Pointer(&dataPtr))
runtime.LockOSThread()
defer runtime.UnlockOSThread()
go func(fPtr func()) {
fPtr()
}(funcPtr)
localTaskBeforeFork()
// SYS_CLONE(SIGCHLD) = fork(). linux/arm64 has no SYS_FORK.
pid, _, errno := syscall.RawSyscall6(syscall.SYS_CLONE, uintptr(syscall.SIGCHLD), 0, 0, 0, 0, 0)
if errno != 0 {
localTaskAfterFork()
runtime.UnlockOSThread()
return errno
}
if pid == 0 {
localTaskAfterForkInChild()
for fd := uintptr(3); fd < 1024; fd++ {
syscall.RawSyscall(syscall.SYS_CLOSE, fd, 0, 0)
}
syscall.RawSyscall(syscall.SYS_MPROTECT, pageAddr, pageLen, uintptr(syscall.PROT_READ|syscall.PROT_EXEC))
funcPtr()
for {
syscall.RawSyscall(syscall.SYS_EXIT_GROUP, 0, 0, 0)
}
}
localTaskAfterFork()
var ws syscall.WaitStatus
_, err := syscall.Wait4(int(pid), &ws, 0, nil)
runtime.UnlockOSThread()
if err != nil {
return err
}
if ws.Signaled() {
return fmt.Errorf("shellcode child killed by signal %v", ws.Signal())
}
if ws.ExitStatus() != 0 {
return fmt.Errorf("shellcode child exited %d", ws.ExitStatus())
}
return nil
}
@@ -0,0 +1,87 @@
//go:build linux
package taskrunner
/*
Sliver Implant Framework
Copyright (C) 2019 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 (
"bytes"
"fmt"
"os"
"os/exec"
"runtime"
"testing"
"time"
)
const localTaskHelperEnv = "SLIVER_LOCALTASK_HELPER"
const localTaskSentinel = "PARENT_ALIVE_AFTER_LOCALTASK"
// linux/amd64: xor eax,eax ; xor edi,edi ; mov al,0x3c ; syscall — exit(0)
var exitShellcodeAmd64 = []byte{0x31, 0xc0, 0x31, 0xff, 0xb0, 0x3c, 0x0f, 0x05}
// linux/amd64: xor eax,eax ; mov al,0x3c ; mov edi,42 ; syscall — exit(42)
var exit42ShellcodeAmd64 = []byte{0x31, 0xc0, 0xb0, 0x3c, 0xbf, 0x2a, 0x00, 0x00, 0x00, 0x0f, 0x05}
func TestMain(m *testing.M) {
switch os.Getenv(localTaskHelperEnv) {
case "exit0":
_ = LocalTask(exitShellcodeAmd64, false)
time.Sleep(500 * time.Millisecond)
fmt.Println(localTaskSentinel)
os.Exit(0)
case "exit42":
err := LocalTask(exit42ShellcodeAmd64, false)
if err != nil {
fmt.Printf("LOCALTASK_ERR=%s\n", err.Error())
} else {
fmt.Println("LOCALTASK_ERR=<nil>")
}
os.Exit(0)
}
os.Exit(m.Run())
}
func runHelper(t *testing.T, mode string) []byte {
t.Helper()
if runtime.GOARCH != "amd64" {
t.Skipf("exit shellcode is amd64-specific; GOARCH=%s", runtime.GOARCH)
}
cmd := exec.Command(os.Args[0])
cmd.Env = append(os.Environ(), localTaskHelperEnv+"="+mode)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("helper subprocess (%s) exited abnormally: %v\noutput: %s", mode, err, out)
}
return out
}
func TestLocalTask_ParentSurvives(t *testing.T) {
out := runHelper(t, "exit0")
if !bytes.Contains(out, []byte(localTaskSentinel)) {
t.Fatalf("expected sentinel %q in helper output:\n%s", localTaskSentinel, out)
}
}
func TestLocalTask_ReportsChildFailure(t *testing.T) {
out := runHelper(t, "exit42")
if !bytes.Contains(out, []byte("LOCALTASK_ERR=shellcode child exited 42")) {
t.Fatalf("expected exit-42 error in helper output:\n%s", out)
}
}
+4 -4
View File
@@ -52,7 +52,7 @@ const (
outputFlagStr = "output"
permissionsFlagStr = "permissions"
tailscaleFlagStr = "tailscale"
disableWGFlagStr = "disable-wg"
enableWGFlagStr = "enable-wg"
// Cert flags
caTypeFlagStr = "type"
@@ -87,7 +87,7 @@ func init() {
operatorCmd.Flags().StringP(saveFlagStr, "s", "", "save file to ...")
operatorCmd.Flags().StringP(outputFlagStr, "o", "file", "output format (file, stdout)")
operatorCmd.Flags().StringSliceP(permissionsFlagStr, "P", []string{}, "grant permissions to the operator profile (all, builder, crackstation)")
operatorCmd.Flags().Bool(disableWGFlagStr, false, "omit WireGuard tunnel settings from the generated operator config")
operatorCmd.Flags().Bool(enableWGFlagStr, false, "include WireGuard tunnel settings in the generated operator config")
rootCmd.AddCommand(operatorCmd)
// Certs
@@ -104,7 +104,7 @@ func init() {
daemonCmd.Flags().Uint16P(lportFlagStr, "p", daemon.BlankPort, "multiplayer listener port")
daemonCmd.Flags().BoolP(forceFlagStr, "f", false, "force unpack and overwrite static assets")
daemonCmd.Flags().BoolP(tailscaleFlagStr, "t", false, "enable tailscale")
daemonCmd.Flags().Bool(disableWGFlagStr, false, "expose multiplayer directly instead of wrapping it in WireGuard")
daemonCmd.Flags().Bool(enableWGFlagStr, false, "wrap the multiplayer listener in WireGuard")
rootCmd.AddCommand(daemonCmd)
// Builder
@@ -153,7 +153,7 @@ var rootCmd = &cobra.Command{
fmt.Printf("[!] %s\n", err)
}
if serverConfig.DaemonMode {
daemon.Start(daemon.BlankHost, daemon.BlankPort, serverConfig.DaemonConfig.Tailscale, serverConfig.DaemonConfig.DisableWG)
daemon.Start(daemon.BlankHost, daemon.BlankPort, serverConfig.DaemonConfig.Tailscale, serverConfig.DaemonConfig.WireGuardEnabled())
} else {
rcScript, err := clientcli.ReadRCScript(cmd)
if err != nil {
+5 -5
View File
@@ -47,13 +47,13 @@ var daemonCmd = &cobra.Command{
fmt.Printf("Failed to parse --%s flag %s\n", tailscaleFlagStr, err)
return
}
disableWG, err := cmd.Flags().GetBool(disableWGFlagStr)
enableWG, err := cmd.Flags().GetBool(enableWGFlagStr)
if err != nil {
fmt.Printf("Failed to parse --%s flag %s\n", disableWGFlagStr, err)
fmt.Printf("Failed to parse --%s flag %s\n", enableWGFlagStr, err)
return
}
if !cmd.Flags().Changed(disableWGFlagStr) {
disableWG = configs.GetServerConfig().DaemonConfig.DisableWG
if !cmd.Flags().Changed(enableWGFlagStr) {
enableWG = configs.GetServerConfig().DaemonConfig.WireGuardEnabled()
}
appDir := assets.GetRootAppDir()
@@ -87,7 +87,7 @@ var daemonCmd = &cobra.Command{
fmt.Printf("[!] %s\n", err)
}
daemon.Start(lhost, uint16(lport), tailscale, disableWG)
daemon.Start(lhost, uint16(lport), tailscale, enableWG)
},
}
+4 -8
View File
@@ -99,14 +99,10 @@ var operatorCmd = &cobra.Command{
}
certs.SetupCAs()
includeWG := console.DefaultOperatorWireGuardEnabled()
if cmd.Flags().Changed(disableWGFlagStr) {
disableWG, err := cmd.Flags().GetBool(disableWGFlagStr)
if err != nil {
fmt.Printf("Failed to parse --%s flag %s", disableWGFlagStr, err)
return
}
includeWG = !disableWG
includeWG, err := cmd.Flags().GetBool(enableWGFlagStr)
if err != nil {
fmt.Printf("Failed to parse --%s flag %s", enableWGFlagStr, err)
return
}
configJSON, err := console.NewOperatorConfig(name, lhost, lport, permissions, includeWG)
+38 -4
View File
@@ -73,10 +73,32 @@ type GRPCConfig struct {
// DaemonConfig - Configure daemon mode
type DaemonConfig struct {
Host string `json:"host" yaml:"host"`
Port int `json:"port" yaml:"port"`
Tailscale bool `json:"tailscale" yaml:"tailscale"`
DisableWG bool `json:"disable_wg" yaml:"disable_wg"`
Host string `json:"host" yaml:"host"`
Port int `json:"port" yaml:"port"`
Tailscale bool `json:"tailscale" yaml:"tailscale"`
EnableWG *bool `json:"enable_wg,omitempty" yaml:"enable_wg,omitempty"`
LegacyDisableWG *bool `json:"disable_wg,omitempty" yaml:"disable_wg,omitempty"`
}
func (c *DaemonConfig) WireGuardEnabled() bool {
if c == nil {
return false
}
if c.EnableWG != nil {
return *c.EnableWG
}
if c.LegacyDisableWG != nil {
return !*c.LegacyDisableWG
}
return false
}
func (c *DaemonConfig) SetWireGuardEnabled(enabled bool) {
if c == nil {
return
}
c.EnableWG = &enabled
c.LegacyDisableWG = nil
}
// JobConfig - Restart Jobs on Load
@@ -215,6 +237,7 @@ func (c *ServerConfig) Save() error {
if err := c.AI.Save(); err != nil {
return err
}
normalizeServerConfig(c)
}
configPath := GetServerConfigPath()
@@ -296,6 +319,7 @@ func GetServerConfig() *ServerConfig {
defaultPermit := true
config.GRPC.Keepalive.PermitWithoutStream = &defaultPermit
}
normalizeServerConfig(config)
config.AI = getAIConfig(migratedAIConfig)
err := config.Save() // This updates the config with any missing fields
@@ -346,3 +370,13 @@ func getDefaultServerConfig() *ServerConfig {
CXX: map[string]string{},
}
}
func normalizeServerConfig(config *ServerConfig) {
if config == nil {
return
}
if config.DaemonConfig == nil {
config.DaemonConfig = &DaemonConfig{}
}
config.DaemonConfig.SetWireGuardEnabled(config.DaemonConfig.WireGuardEnabled())
}
+45
View File
@@ -21,6 +21,7 @@ daemon:
host: "127.0.0.1"
port: 4444
tailscale: true
enable_wg: true
logs:
level: 5
grpc_unary_payloads: true
@@ -99,6 +100,9 @@ openrouter:
if config.DaemonConfig == nil || config.DaemonConfig.Port != 4444 {
t.Fatalf("expected daemon port %d, got %v", 4444, config.DaemonConfig)
}
if !config.DaemonConfig.WireGuardEnabled() {
t.Fatalf("expected daemon enable_wg true")
}
if config.Logs == nil || config.Logs.Level != 5 {
t.Fatalf("expected logs level %d, got %v", 5, config.Logs)
}
@@ -202,6 +206,47 @@ openrouter:
}
}
func TestServerConfigMigratesLegacyDisableWG(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
configPath := GetServerConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
data := []byte(`daemon_mode: true
daemon:
host: "127.0.0.1"
port: 4444
disable_wg: false
`)
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatalf("failed to write yaml config: %v", err)
}
config := GetServerConfig()
if config.DaemonConfig == nil {
t.Fatalf("expected daemon config")
}
if !config.DaemonConfig.WireGuardEnabled() {
t.Fatalf("expected legacy disable_wg:false to migrate to enable_wg:true")
}
if config.DaemonConfig.LegacyDisableWG != nil {
t.Fatalf("expected legacy disable_wg field to be cleared after normalization")
}
saved, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read migrated config: %v", err)
}
if !strings.Contains(string(saved), "enable_wg: true") {
t.Fatalf("expected migrated config to contain enable_wg: true, got:\n%s", string(saved))
}
if strings.Contains(string(saved), "disable_wg") {
t.Fatalf("expected migrated config to drop legacy disable_wg field, got:\n%s", string(saved))
}
}
func TestServerConfigWritesDefault(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
+3 -36
View File
@@ -39,7 +39,6 @@ import (
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/certs"
"github.com/bishopfox/sliver/server/configs"
"github.com/bishopfox/sliver/server/console/forms"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
@@ -82,11 +81,7 @@ func newOperatorCmd(cmd *cobra.Command, _ []string) {
lport, _ := cmd.Flags().GetUint16("lport")
save, _ := cmd.Flags().GetString("save")
permissions, _ := cmd.Flags().GetStringSlice("permissions")
includeWG := DefaultOperatorWireGuardEnabled()
if cmd.Flags().Changed("disable-wg") {
disableWG, _ := cmd.Flags().GetBool("disable-wg")
includeWG = !disableWG
}
includeWG, _ := cmd.Flags().GetBool("enable-wg")
if save == "" {
save, _ = os.Getwd()
@@ -117,34 +112,6 @@ func newOperatorCmd(cmd *cobra.Command, _ []string) {
fmt.Printf(Info+"Saved new client config to: %s \n", saveTo)
}
// DefaultOperatorWireGuardEnabled returns the default wireguard mode for new
// operator configs based on the current server multiplayer exposure.
func DefaultOperatorWireGuardEnabled() bool {
serverConfig := configs.GetServerConfig()
if serverConfig != nil && serverConfig.DaemonMode && serverConfig.DaemonConfig != nil {
if serverConfig.DaemonConfig.Tailscale {
return false
}
return !serverConfig.DaemonConfig.DisableWG
}
listenerJobs, err := db.ListenerJobs()
if err != nil {
return true
}
for _, listenerJob := range listenerJobs {
if listenerJob == nil || listenerJob.Type != consts.MultiplayerModeStr {
continue
}
config, err := db.ListenerByJobID(listenerJob.JobID)
if err != nil || config == nil || config.MultiConf == nil {
continue
}
return config.MultiConf.WireGuard
}
return true
}
// NewOperatorConfig - Generate a new player/client/operator configuration
func NewOperatorConfig(operatorName string, lhost string, lport uint16, permissions []string, includeWG bool) ([]byte, error) {
if !namePattern.MatchString(operatorName) {
@@ -438,8 +405,8 @@ func startMultiplayerModeCmd(cmd *cobra.Command, _ []string) {
lhost, _ := cmd.Flags().GetString("lhost")
lport, _ := cmd.Flags().GetUint16("lport")
tailscale, _ := cmd.Flags().GetBool("tailscale")
disableWG, _ := cmd.Flags().GetBool("disable-wg")
useWireGuard := !disableWG && !tailscale
enableWG, _ := cmd.Flags().GetBool("enable-wg")
useWireGuard := enableWG && !tailscale
var err error
var jobID int
+244 -1
View File
@@ -4,17 +4,25 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
clientassets "github.com/bishopfox/sliver/client/assets"
clienttransport "github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/server/certs"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
servertransport "github.com/bishopfox/sliver/server/transport"
"google.golang.org/grpc"
"gorm.io/gorm"
)
@@ -22,7 +30,7 @@ func TestNewOperatorConfigWithWireGuardConnectsToWrappedMultiplayer(t *testing.T
certs.SetupCAs()
certs.SetupWGKeys()
certs.SetupMultiplayerWGKeys()
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectAuto)
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
operatorName := uniqueKickOperatorName(t)
t.Cleanup(func() {
@@ -116,6 +124,228 @@ func TestNewOperatorConfigWithWireGuardConnectsToWrappedMultiplayer(t *testing.T
}
}
func TestNewOperatorConfigWithWireGuardConnectsToWrappedMultiplayerRepeatedly(t *testing.T) {
certs.SetupCAs()
certs.SetupWGKeys()
certs.SetupMultiplayerWGKeys()
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
operatorName := uniqueKickOperatorName(t)
t.Cleanup(func() {
_ = removeOperator(operatorName)
_ = revokeOperatorClientCertificate(operatorName)
closeOperatorStreams(operatorName)
})
port := freeUDPPort(t)
configJSON, err := NewOperatorConfig(operatorName, "127.0.0.1", uint16(port), []string{"all"}, true)
if err != nil {
t.Fatalf("generate wireguard operator config: %v", err)
}
config := &clientassets.ClientConfig{}
if err := json.Unmarshal(configJSON, config); err != nil {
t.Fatalf("parse operator config: %v", err)
}
grpcServer, ln, err := servertransport.StartWGWrappedMtlsClientListener("127.0.0.1", uint16(port))
if err != nil {
if strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("wireguard listener bind not permitted in this environment: %v", err)
}
t.Fatalf("start wrapped multiplayer listener: %v", err)
}
defer grpcServer.Stop()
defer ln.Close()
for attempt := 1; attempt <= 5; attempt++ {
rpcClient, conn, err := clienttransport.MTLSConnect(config)
if err != nil {
t.Fatalf("attempt %d: connect operator through wireguard wrapper: %v", attempt, err)
}
if _, err := rpcClient.GetVersion(context.Background(), &commonpb.Empty{}); err != nil {
_ = clienttransport.CloseGRPCConnection(conn)
t.Fatalf("attempt %d: GetVersion over wrapped multiplayer failed: %v", attempt, err)
}
if _, err := rpcClient.GetOperators(context.Background(), &commonpb.Empty{}); err != nil {
_ = clienttransport.CloseGRPCConnection(conn)
t.Fatalf("attempt %d: GetOperators over wrapped multiplayer failed: %v", attempt, err)
}
if err := clienttransport.CloseGRPCConnection(conn); err != nil {
t.Fatalf("attempt %d: close wrapped multiplayer connection: %v", attempt, err)
}
}
}
func TestWrappedMultiplayerWireGuardSupportsUnaryRPCsWithDedicatedCommandConnection(t *testing.T) {
t.Run("events-only", func(t *testing.T) {
runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t, true, false)
})
t.Run("tunnel-only", func(t *testing.T) {
runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t, false, true)
})
t.Run("events-and-tunnel", func(t *testing.T) {
runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t, true, true)
})
}
func runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t *testing.T, useEvents bool, useTunnel bool) {
certs.SetupCAs()
certs.SetupWGKeys()
certs.SetupMultiplayerWGKeys()
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
operatorName := uniqueKickOperatorName(t)
t.Cleanup(func() {
_ = removeOperator(operatorName)
_ = revokeOperatorClientCertificate(operatorName)
closeOperatorStreams(operatorName)
})
port := freeUDPPort(t)
configJSON, err := NewOperatorConfig(operatorName, "127.0.0.1", uint16(port), []string{"all"}, true)
if err != nil {
t.Fatalf("generate wireguard operator config: %v", err)
}
config := &clientassets.ClientConfig{}
if err := json.Unmarshal(configJSON, config); err != nil {
t.Fatalf("parse operator config: %v", err)
}
grpcServer, ln, err := servertransport.StartWGWrappedMtlsClientListener("127.0.0.1", uint16(port))
if err != nil {
if strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("wireguard listener bind not permitted in this environment: %v", err)
}
t.Fatalf("start wrapped multiplayer listener: %v", err)
}
defer grpcServer.Stop()
defer ln.Close()
var streamClient rpcpb.SliverRPCClient
var streamConn *grpc.ClientConn
if useEvents || useTunnel {
streamClient, streamConn, err = clienttransport.MTLSConnect(config)
if err != nil {
t.Fatalf("connect dedicated background operator through wireguard wrapper: %v", err)
}
defer clienttransport.CloseGRPCConnection(streamConn)
}
streamCtx, streamCancel := context.WithCancel(context.Background())
defer streamCancel()
if useEvents {
events, err := streamClient.Events(streamCtx, &commonpb.Empty{})
if err != nil {
t.Fatalf("open events stream: %v", err)
}
go drainWGTestStream(t, "events", func() error {
_, err := events.Recv()
return err
})
}
if useTunnel {
tunnelData, err := streamClient.TunnelData(streamCtx)
if err != nil {
t.Fatalf("open tunnel data stream: %v", err)
}
go drainWGTestStream(t, "tunnel-data", func() error {
_, err := tunnelData.Recv()
return err
})
}
// Let the background streams settle before issuing the unary call. The
// interactive console opens these streams first and only later runs
// unary RPC-backed commands like "operators".
if useEvents || useTunnel {
time.Sleep(2 * time.Second)
}
rpcClient, conn, err := clienttransport.MTLSConnect(config)
if err != nil {
t.Fatalf("connect operator command channel through wireguard wrapper: %v", err)
}
defer clienttransport.CloseGRPCConnection(conn)
callCtx, callCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer callCancel()
if _, err := rpcClient.GetOperators(callCtx, &commonpb.Empty{}); err != nil {
t.Fatalf("GetOperators with dedicated command connection over wrapped multiplayer failed (events=%t tunnel=%t): %v", useEvents, useTunnel, err)
}
}
func TestOperatorCLIWireGuardConfigConnectsToWrappedMultiplayer(t *testing.T) {
certs.SetupCAs()
certs.SetupWGKeys()
certs.SetupMultiplayerWGKeys()
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
port := freeUDPPort(t)
grpcServer, ln, err := servertransport.StartWGWrappedMtlsClientListener("127.0.0.1", uint16(port))
if err != nil {
if strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("wireguard listener bind not permitted in this environment: %v", err)
}
t.Fatalf("start wrapped multiplayer listener: %v", err)
}
defer grpcServer.Stop()
defer ln.Close()
operatorName := uniqueKickOperatorName(t)
t.Cleanup(func() {
_ = removeOperator(operatorName)
_ = revokeOperatorClientCertificate(operatorName)
closeOperatorStreams(operatorName)
})
wd, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
savePath := filepath.Join(t.TempDir(), operatorName+".cfg")
cmd := exec.Command(
"go", "run", "-tags=server,go_sqlite", "./server", "operator",
"--name", operatorName,
"--lhost", "127.0.0.1",
"--lport", strconv.Itoa(port),
"--permissions", "all",
"--enable-wg",
"--save", savePath,
)
cmd.Dir = repoRoot
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generate operator config through CLI: %v\n%s", err, output)
}
configJSON, err := os.ReadFile(savePath)
if err != nil {
t.Fatalf("read generated operator config: %v", err)
}
config := &clientassets.ClientConfig{}
if err := json.Unmarshal(configJSON, config); err != nil {
t.Fatalf("parse operator config: %v", err)
}
if config.WG == nil {
t.Fatal("expected wireguard config block to be present")
}
rpcClient, conn, err := clienttransport.MTLSConnect(config)
if err != nil {
t.Fatalf("connect operator through wireguard wrapper after CLI generation: %v", err)
}
defer clienttransport.CloseGRPCConnection(conn)
if _, err := rpcClient.GetVersion(context.Background(), &commonpb.Empty{}); err != nil {
t.Fatalf("GetVersion over wrapped multiplayer failed: %v", err)
}
}
func TestKickOperatorReleasesWireGuardTunnelIPReservation(t *testing.T) {
certs.SetupCAs()
certs.SetupMultiplayerWGKeys()
@@ -167,3 +397,16 @@ func freeUDPPort(t *testing.T) int {
defer ln.Close()
return ln.LocalAddr().(*net.UDPAddr).Port
}
func drainWGTestStream(t *testing.T, name string, recv func() error) {
t.Helper()
err := recv()
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
return
}
if strings.Contains(err.Error(), "context canceled") {
return
}
t.Errorf("%s stream recv failed: %v", name, err)
}
+4 -5
View File
@@ -75,7 +75,7 @@ func serverOnlyCmds() (commands []*cobra.Command) {
f.StringP("lhost", "L", "", "interface to bind server to")
f.Uint16P("lport", "l", 31337, "tcp listen port")
f.BoolP("tailscale", "T", false, "only expose multiplayer interface over Tailscale (requires TS_AUTHKEY)")
f.Bool("disable-wg", false, "expose multiplayer directly instead of wrapping it in WireGuard")
f.Bool("enable-wg", false, "wrap the multiplayer listener in WireGuard")
})
commands = append(commands, startMultiplayer)
@@ -93,7 +93,7 @@ func serverOnlyCmds() (commands []*cobra.Command) {
f.StringP("save", "s", "", "directory/file in which to save config")
f.StringP("name", "n", "", "operator name")
f.StringSliceP("permissions", "P", []string{}, "grant permissions to the operator profile (all, builder, crackstation)")
f.Bool("disable-wg", false, "omit WireGuard tunnel settings from the generated operator config")
f.Bool("enable-wg", false, "include WireGuard tunnel settings in the generated operator config")
})
command.BindFlagCompletions(newOperator, func(comp *carapace.ActionMap) {
(*comp)["save"] = carapace.ActionDirectories()
@@ -133,9 +133,8 @@ Create a new operator config file, operator configuration files allow
remote machines to connect to the Sliver server. They are most commonly
used for allowing remote operators to connect in "Multiplayer Mode."
By default, operator configs now include a WireGuard wrapper for the
multiplayer listener; use --disable-wg if the listener is being exposed
directly instead.
Operator configs use direct multiplayer mTLS by default. Add --enable-wg
when the multiplayer listener is wrapped in WireGuard.
To generate a profile for a remote operator, you need to specify the
"all" permission to grant the profile access to all gRPC APIs:
+6 -3
View File
@@ -28,7 +28,8 @@ const (
)
// Event - An event is fired when there's a state change involving a
// session, job, or client.
//
// session, job, or client.
type Event struct {
Session *Session
Job *Job
@@ -62,7 +63,10 @@ func (broker *eventBroker) Start() {
case sub := <-broker.subscribe:
subscribers[sub] = struct{}{}
case sub := <-broker.unsubscribe:
delete(subscribers, sub)
if _, ok := subscribers[sub]; ok {
delete(subscribers, sub)
close(sub)
}
case event := <-broker.publish:
for sub := range subscribers {
sub <- event
@@ -86,7 +90,6 @@ func (broker *eventBroker) Subscribe() chan Event {
// Unsubscribe - Remove a subscription channel
func (broker *eventBroker) Unsubscribe(events chan Event) {
broker.unsubscribe <- events
close(events)
}
// Publish - Push a message to all subscribers
+2 -2
View File
@@ -41,7 +41,7 @@ var (
)
// Start - Start as daemon process
func Start(host string, port uint16, tailscale bool, disableWG bool) {
func Start(host string, port uint16, tailscale bool, enableWG bool) {
var (
ln net.Listener
err error
@@ -59,7 +59,7 @@ func Start(host string, port uint16, tailscale bool, disableWG bool) {
daemonLog.Infof("Starting Sliver daemon %s:%d ...", host, port)
if tailscale {
_, ln, err = transport.StartTsNetClientListener(host, port)
} else if !disableWG {
} else if enableWG {
_, ln, err = transport.StartWGWrappedMtlsClientListener(host, port)
} else {
_, ln, err = transport.StartMtlsClientListener(host, port)
+32 -8
View File
@@ -17,6 +17,7 @@ import (
"github.com/bishopfox/sliver/protobuf/rpcpb"
serverai "github.com/bishopfox/sliver/server/ai"
"github.com/bishopfox/sliver/server/configs"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"google.golang.org/grpc/codes"
@@ -26,6 +27,8 @@ import (
"gorm.io/gorm"
)
const aiConversationEventStreamTimeout = 30 * time.Second
func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *testing.T) {
setupAIRPCTestEnv(t)
@@ -69,9 +72,9 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
client, cleanup := newBufnetRPCClient(t)
defer cleanup()
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
defer cancelStream()
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
eventStream, err := startAIEventStream(t, client, streamCtx)
if err != nil {
t.Fatalf("start events stream: %v", err)
}
@@ -242,9 +245,9 @@ func TestSaveAIConversationMessageCompletesOpenAIWithoutExplicitBaseURL(t *testi
client, cleanup := newBufnetRPCClient(t)
defer cleanup()
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
defer cancelStream()
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
eventStream, err := startAIEventStream(t, client, streamCtx)
if err != nil {
t.Fatalf("start events stream: %v", err)
}
@@ -332,9 +335,9 @@ func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *t
client, cleanup := newBufnetRPCClient(t)
defer cleanup()
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
defer cancelStream()
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
eventStream, err := startAIEventStream(t, client, streamCtx)
if err != nil {
t.Fatalf("start events stream: %v", err)
}
@@ -622,9 +625,9 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
client, cleanup := newBufnetRPCClient(t)
defer cleanup()
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
defer cancelStream()
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
eventStream, err := startAIEventStream(t, client, streamCtx)
if err != nil {
t.Fatalf("start events stream: %v", err)
}
@@ -933,6 +936,27 @@ func saveOpenAICompletionConfig(t *testing.T, model string, thinkingLevel string
}
}
func startAIEventStream(t *testing.T, client rpcpb.SliverRPCClient, streamCtx context.Context) (rpcpb.SliverRPC_EventsClient, error) {
t.Helper()
before := len(core.Clients.ActiveOperators())
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
if err != nil {
return nil, err
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if len(core.Clients.ActiveOperators()) > before {
return eventStream, nil
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("event stream did not subscribe before deadline")
return nil, nil
}
func waitForAIConversationEvent(t *testing.T, eventStream rpcpb.SliverRPC_EventsClient, conversationID string) {
t.Helper()
+19 -6
View File
@@ -25,6 +25,7 @@ import (
"fmt"
"net"
"runtime/debug"
"strings"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/server/certs"
@@ -89,21 +90,33 @@ func StartMtlsClientServer(ln net.Listener) (*grpc.Server, error) {
grpcServer := grpc.NewServer(options...)
rpcpb.RegisterSliverRPCServer(grpcServer, rpc.NewServer())
go func() {
panicked := true
defer func() {
if panicked {
mtlsLog.Errorf("stacktrace from panic: %s", string(debug.Stack()))
if r := recover(); r != nil {
mtlsLog.Errorf("gRPC server panic: %v\n%s", r, string(debug.Stack()))
}
}()
if err := grpcServer.Serve(ln); err != nil {
if err := grpcServer.Serve(ln); err != nil && !isExpectedGRPCServerExit(err) {
mtlsLog.Warnf("gRPC server exited with error: %v", err)
} else {
panicked = false
}
}()
return grpcServer, nil
}
func isExpectedGRPCServerExit(err error) bool {
if err == nil {
return true
}
if errors.Is(err, grpc.ErrServerStopped) || errors.Is(err, net.ErrClosed) {
return true
}
// The gVisor-backed listener used by the WireGuard multiplayer transport can
// surface this accept error during normal shutdown on Windows.
errString := err.Error()
return strings.Contains(errString, "use of closed network connection") ||
strings.Contains(errString, "endpoint is in invalid state")
}
// getOperatorServerTLSConfig - Generate the TLS configuration, we do now allow the end user
// to specify any TLS parameters, we choose sensible defaults instead
func getOperatorServerTLSConfig(host string) *tls.Config {
+57 -10
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"strings"
"sync"
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/server/certs"
@@ -19,19 +20,25 @@ import (
)
const (
multiplayerWireGuardMTU = 1420
multiplayerWireGuardMTU = 1420
multiplayerPeerSyncInterval = time.Second
)
type wireGuardWrappedClientListener struct {
net.Listener
dev *device.Device
events chan core.Event
closed chan struct{}
once sync.Once
}
func (l *wireGuardWrappedClientListener) Close() error {
var err error
l.once.Do(func() {
if l.closed != nil {
close(l.closed)
l.closed = nil
}
if l.events != nil {
core.EventBroker.Unsubscribe(l.events)
l.events = nil
@@ -47,23 +54,49 @@ func (l *wireGuardWrappedClientListener) Close() error {
}
func (l *wireGuardWrappedClientListener) processPeerEvents() {
if l == nil || l.events == nil || l.dev == nil {
if l == nil || l.events == nil || l.dev == nil || l.closed == nil {
return
}
for event := range l.events {
switch event.EventType {
case consts.MultiplayerWireGuardNewPeer, consts.MultiplayerWireGuardRemoved:
if len(event.Data) == 0 {
continue
ticker := time.NewTicker(multiplayerPeerSyncInterval)
defer ticker.Stop()
for {
select {
case <-l.closed:
return
case <-ticker.C:
if err := l.syncPeersFromDB(); err != nil {
mtlsLog.Errorf("Failed to resync multiplayer wireguard peers from the database: %v", err)
}
if err := l.dev.IpcSetOperation(bufio.NewReader(bytes.NewReader(event.Data))); err != nil {
mtlsLog.Errorf("Failed to update multiplayer wireguard config: %v", err)
case event, ok := <-l.events:
if !ok {
return
}
switch event.EventType {
case consts.MultiplayerWireGuardNewPeer, consts.MultiplayerWireGuardRemoved:
if len(event.Data) == 0 {
continue
}
if err := l.dev.IpcSetOperation(bufio.NewReader(bytes.NewReader(event.Data))); err != nil {
mtlsLog.Errorf("Failed to update multiplayer wireguard config: %v", err)
}
}
}
}
}
func (l *wireGuardWrappedClientListener) syncPeersFromDB() error {
if l == nil || l.dev == nil {
return nil
}
peerConfig, err := operatorWGPeerConfig(true)
if err != nil {
return err
}
return l.dev.IpcSetOperation(bufio.NewReader(peerConfig))
}
// StartWGWrappedMtlsClientListener exposes the multiplayer mTLS listener only
// inside a WireGuard tunnel while preserving the existing gRPC/mTLS auth stack.
func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, net.Listener, error) {
@@ -95,10 +128,12 @@ func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, n
fmt.Fprintf(wgConf, "private_key=%s\n", privateKey)
fmt.Fprintf(wgConf, "listen_port=%d\n", port)
if err := appendOperatorWGPeers(wgConf); err != nil {
peerConfig, err := operatorWGPeerConfig(false)
if err != nil {
dev.Close()
return nil, nil, err
}
wgConf.Write(peerConfig.Bytes())
if err := dev.IpcSetOperation(bufio.NewReader(wgConf)); err != nil {
dev.Close()
return nil, nil, err
@@ -118,6 +153,7 @@ func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, n
Listener: innerListener,
dev: dev,
events: core.EventBroker.Subscribe(),
closed: make(chan struct{}),
}
go wrappedListener.processPeerEvents()
@@ -129,6 +165,17 @@ func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, n
return grpcServer, wrappedListener, nil
}
func operatorWGPeerConfig(replace bool) (*bytes.Buffer, error) {
dst := bytes.NewBuffer(nil)
if replace {
fmt.Fprint(dst, "replace_peers=true\n")
}
if err := appendOperatorWGPeers(dst); err != nil {
return nil, err
}
return dst, nil
}
func appendOperatorWGPeers(dst *bytes.Buffer) error {
peers, err := certs.GetOperatorWGPeers()
if err != nil && err != certs.ErrWGPeerDoesNotExist {