mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-09 09:59:43 -10:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80273462bd | ||
|
|
fafe360672 | ||
|
|
d9b6f41940 | ||
|
|
f1ccf0eac3 | ||
|
|
d2aa924bc4 | ||
|
|
46861cd428 | ||
|
|
1c1825f8f0 | ||
|
|
a943995040 | ||
|
|
f0d2a2cf24 | ||
|
|
27ac612c82 | ||
|
|
baedb563f3 | ||
|
|
45c7914602 | ||
|
|
92d85cb8ff | ||
|
|
f5e831844a | ||
|
|
ab2fc7dc4a | ||
|
|
5e377019e4 | ||
|
|
88a4bebca0 | ||
|
|
2daccb5bd3 | ||
|
|
e2a009d4ac | ||
|
|
eda51f826a | ||
|
|
f83dc42a64 | ||
|
|
17e8c996a6 | ||
|
|
2b667f0e7e | ||
|
|
73bcf40bfd | ||
|
|
b635adcb3c | ||
|
|
2b92397e07 | ||
|
|
c23067a0e0 | ||
|
|
3aefc39c08 | ||
|
|
abd8c83df8 | ||
|
|
6cce9426f2 | ||
|
|
2e742863a1 | ||
|
|
dc13a5bb93 | ||
|
|
1f2946eb87 | ||
|
|
5eeda4c02b | ||
|
|
55a3386313 | ||
|
|
b34589a002 | ||
|
|
4486b17b49 | ||
|
|
377ee5ca7c | ||
|
|
4765f40854 | ||
|
|
e239d85821 | ||
|
|
dec419b284 | ||
|
|
6d51a23cc9 | ||
|
|
3d6eec47be | ||
|
|
464fb7008c | ||
|
|
ffa5f89f5f | ||
|
|
f19cbdc957 | ||
|
|
83bfc8d8e6 | ||
|
|
365bb3596e | ||
|
|
9ee96c2ce6 | ||
|
|
e776e266d0 | ||
|
|
d080c8a646 | ||
|
|
cb6402245e | ||
|
|
e4357277ec | ||
|
|
5dc2ab1490 | ||
|
|
9f034a9956 | ||
|
|
f700535997 | ||
|
|
31bca034d8 | ||
|
|
fac4ad5fb7 | ||
|
|
c128d33514 | ||
|
|
4a37262bcf | ||
|
|
a2295112bd | ||
|
|
834dff0f6e | ||
|
|
f4a0431fdd | ||
|
|
d072b328c0 | ||
|
|
435bbcbd7e | ||
|
|
84dc28e584 | ||
|
|
639592cb02 | ||
|
|
8c7a1a6b40 | ||
|
|
06274c4061 | ||
|
|
593925b4a5 | ||
|
|
255477e572 | ||
|
|
0acc6911d8 | ||
|
|
4ca026906b | ||
|
|
97a249e28e | ||
|
|
9f331687d3 | ||
|
|
14d68d6fcd | ||
|
|
05c5334fea | ||
|
|
d277a14c57 | ||
|
|
a7b5dbe6ee | ||
|
|
c188dc1e69 | ||
|
|
9fc9d1762e | ||
|
|
4ef86448d0 | ||
|
|
4cd010afb9 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
* @BishopFox/red-team
|
||||
* @BishopFox/bf-red-team
|
||||
@@ -2,8 +2,8 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -21,11 +21,22 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Find Semver Tag
|
||||
- name: Validate Release Tag
|
||||
id: tag
|
||||
run: |
|
||||
git fetch --prune --tags -f
|
||||
tag=$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
git fetch --prune --tags -f origin
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
|
||||
if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Tag $tag is not a release semver tag."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/master"; then
|
||||
echo "Tag $tag does not point to a commit on origin/master."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release:
|
||||
@@ -41,10 +52,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Go 1.25.8
|
||||
- name: Go 1.26.2
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.2"
|
||||
|
||||
- name: Install Minisign
|
||||
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
# Setup go environment
|
||||
- name: Go 1.25.8
|
||||
- name: Go 1.26.2
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.2"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.25.8
|
||||
- name: Go 1.26.2
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.2"
|
||||
|
||||
- name: OS Packages
|
||||
run: |
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.25.8
|
||||
- name: Go 1.26.2
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.2"
|
||||
|
||||
- name: Install make
|
||||
run: choco install make -y --no-progress
|
||||
@@ -115,10 +115,10 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.25.8
|
||||
- name: Go 1.26.2
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.2"
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
@@ -153,10 +153,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Go 1.25.8
|
||||
- name: Go 1.26.2
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version: "1.26.2"
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/crush
|
||||
|
||||
/examples
|
||||
|
||||
/.vscode
|
||||
/.vscode/*
|
||||
/.devcontainer
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
bspinner "charm.land/bubbles/v2/spinner"
|
||||
"github.com/bishopfox/sliver/client/transport"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
minConnectSpinnerDuration = 900 * time.Millisecond
|
||||
minConnectStatusDuration = 350 * time.Millisecond
|
||||
)
|
||||
|
||||
type connectResult struct {
|
||||
rpc rpcpb.SliverRPCClient
|
||||
conn *grpc.ClientConn
|
||||
err error
|
||||
}
|
||||
|
||||
func connectWithSpinner(out io.Writer, target string, connect func(transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error)) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
|
||||
statusCh := make(chan string, 8)
|
||||
resultCh := make(chan connectResult, 1)
|
||||
|
||||
go func() {
|
||||
rpc, conn, err := connect(func(status string) {
|
||||
sendStatus(statusCh, status)
|
||||
})
|
||||
resultCh <- connectResult{rpc: rpc, conn: conn, err: err}
|
||||
}()
|
||||
|
||||
currentStatus := ""
|
||||
currentStatusSince := time.Time{}
|
||||
queuedStatus := ""
|
||||
lastWidth := 0
|
||||
spinner := bspinner.New(bspinner.WithSpinner(bspinner.Line))
|
||||
ticker := time.NewTicker(spinner.Spinner.FPS)
|
||||
defer ticker.Stop()
|
||||
startedAt := time.Now()
|
||||
var pendingResult *connectResult
|
||||
|
||||
setStatus := func(status string) {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" || status == currentStatus {
|
||||
return
|
||||
}
|
||||
currentStatus = status
|
||||
currentStatusSince = time.Now()
|
||||
}
|
||||
|
||||
stageStatus := func(status string) {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return
|
||||
}
|
||||
if currentStatus == "" || time.Since(currentStatusSince) >= minConnectStatusDuration {
|
||||
queuedStatus = ""
|
||||
setStatus(status)
|
||||
return
|
||||
}
|
||||
queuedStatus = status
|
||||
}
|
||||
|
||||
flushQueuedStatus := func() {
|
||||
if queuedStatus == "" || time.Since(currentStatusSince) < minConnectStatusDuration {
|
||||
return
|
||||
}
|
||||
nextStatus := queuedStatus
|
||||
queuedStatus = ""
|
||||
setStatus(nextStatus)
|
||||
}
|
||||
|
||||
canReturnPendingResult := func() bool {
|
||||
if pendingResult == nil {
|
||||
return false
|
||||
}
|
||||
if pendingResult.err != nil {
|
||||
return true
|
||||
}
|
||||
if time.Since(startedAt) < minConnectSpinnerDuration {
|
||||
return false
|
||||
}
|
||||
if queuedStatus != "" {
|
||||
return false
|
||||
}
|
||||
if !currentStatusSince.IsZero() && time.Since(currentStatusSince) < minConnectStatusDuration {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
render := func() {
|
||||
line := fmt.Sprintf("%s %s", spinner.View(), formatConnectSpinnerMessage(target, currentStatus))
|
||||
lastWidth = writeSpinnerLine(out, line, lastWidth)
|
||||
}
|
||||
|
||||
render()
|
||||
for {
|
||||
select {
|
||||
case status := <-statusCh:
|
||||
stageStatus(status)
|
||||
spinner, _ = spinner.Update(spinner.Tick())
|
||||
render()
|
||||
|
||||
case result := <-resultCh:
|
||||
pendingResult = &result
|
||||
|
||||
case <-ticker.C:
|
||||
spinner, _ = spinner.Update(spinner.Tick())
|
||||
flushQueuedStatus()
|
||||
render()
|
||||
if canReturnPendingResult() {
|
||||
result := *pendingResult
|
||||
pendingResult = nil
|
||||
clearSpinnerLine(out, lastWidth)
|
||||
return result.rpc, result.conn, result.err
|
||||
}
|
||||
}
|
||||
|
||||
if pendingResult != nil && pendingResult.err != nil {
|
||||
result := *pendingResult
|
||||
pendingResult = nil
|
||||
clearSpinnerLine(out, lastWidth)
|
||||
return result.rpc, result.conn, result.err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendStatus(statusCh chan string, status string) {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return
|
||||
}
|
||||
statusCh <- status
|
||||
}
|
||||
|
||||
func formatConnectSpinnerMessage(target string, status string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
status = strings.TrimSpace(status)
|
||||
|
||||
if target == "" {
|
||||
if status == "" {
|
||||
return "Connecting ..."
|
||||
}
|
||||
return fmt.Sprintf("Connecting (%s) ...", status)
|
||||
}
|
||||
if status == "" {
|
||||
return fmt.Sprintf("Connecting to %s ...", target)
|
||||
}
|
||||
return fmt.Sprintf("Connecting to %s (%s) ...", target, status)
|
||||
}
|
||||
|
||||
func writeSpinnerLine(out io.Writer, line string, lastWidth int) int {
|
||||
width := utf8.RuneCountInString(line)
|
||||
padding := ""
|
||||
if lastWidth > width {
|
||||
padding = strings.Repeat(" ", lastWidth-width)
|
||||
}
|
||||
fmt.Fprintf(out, "\r%s%s", line, padding)
|
||||
if width > lastWidth {
|
||||
return width
|
||||
}
|
||||
return lastWidth
|
||||
}
|
||||
|
||||
func clearSpinnerLine(out io.Writer, lastWidth int) {
|
||||
if lastWidth <= 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", lastWidth))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/transport"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func TestFormatConnectSpinnerMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
status string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "target only",
|
||||
target: "127.0.0.1:31337",
|
||||
want: "Connecting to 127.0.0.1:31337 ...",
|
||||
},
|
||||
{
|
||||
name: "target with status",
|
||||
target: "127.0.0.1:31337",
|
||||
status: "wireguard",
|
||||
want: "Connecting to 127.0.0.1:31337 (wireguard) ...",
|
||||
},
|
||||
{
|
||||
name: "status without target",
|
||||
status: "grpc/mtls",
|
||||
want: "Connecting (grpc/mtls) ...",
|
||||
},
|
||||
{
|
||||
name: "trim whitespace",
|
||||
target: " 127.0.0.1:31337 ",
|
||||
status: " grpc/mtls over wireguard ",
|
||||
want: "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := formatConnectSpinnerMessage(test.target, test.status); got != test.want {
|
||||
t.Fatalf("expected %q, got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithSpinnerOutput(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
spinnerDelay := 2 * (100 * time.Millisecond)
|
||||
|
||||
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
|
||||
statusFn("wireguard")
|
||||
time.Sleep(spinnerDelay)
|
||||
statusFn("grpc/mtls over wireguard")
|
||||
time.Sleep(spinnerDelay)
|
||||
return nil, nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connectWithSpinner returned error: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
|
||||
t.Fatalf("expected wireguard status in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
|
||||
t.Fatalf("expected grpc over wireguard status in output, got %q", got)
|
||||
}
|
||||
frameCount := 0
|
||||
for _, frame := range []string{"|", "/", "-", "\\"} {
|
||||
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337") {
|
||||
frameCount++
|
||||
}
|
||||
}
|
||||
if frameCount < 2 {
|
||||
t.Fatalf("expected multiple spinner frames in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
|
||||
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
|
||||
statusFn("grpc/mtls")
|
||||
return nil, nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connectWithSpinner returned error: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
frameCount := 0
|
||||
for _, frame := range []string{"|", "/", "-", "\\"} {
|
||||
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337 (grpc/mtls) ...") {
|
||||
frameCount++
|
||||
}
|
||||
}
|
||||
if frameCount < 2 {
|
||||
t.Fatalf("expected multiple spinner frames for fast success, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
|
||||
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
|
||||
statusFn("wireguard")
|
||||
statusFn("grpc/mtls over wireguard")
|
||||
return nil, nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connectWithSpinner returned error: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
|
||||
t.Fatalf("expected fast success output to include wireguard status, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
|
||||
t.Fatalf("expected fast success output to include grpc over wireguard status, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "Connected to 127.0.0.1:31337") {
|
||||
t.Fatalf("did not expect past-tense success output, got %q", got)
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+141
-44
@@ -1,17 +1,40 @@
|
||||
package ai
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2026 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/termio"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var openAIProgramTTY = tea.OpenTTY
|
||||
|
||||
// AICmd launches the AI conversation TUI.
|
||||
func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
|
||||
if con == nil || con.Rpc == nil {
|
||||
@@ -23,24 +46,72 @@ func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
|
||||
|
||||
listenerID, listener := con.CreateEventListener()
|
||||
defer con.RemoveEventListener(listenerID)
|
||||
restoreEventNotifications := con.SuppressEventNotifications()
|
||||
defer restoreEventNotifications()
|
||||
|
||||
model := newAIModel(con, buildAIContext(con), listener)
|
||||
model.showExperimentalWarningModal()
|
||||
|
||||
width, height := 100, 30
|
||||
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
|
||||
width, height = w, h
|
||||
}
|
||||
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
opts := []tea.ProgramOption{
|
||||
tea.WithWindowSize(width, height),
|
||||
tea.WithColorProfile(colorprofile.TrueColor),
|
||||
)
|
||||
}
|
||||
ttyOpts, cleanup := configureAIProgramTTY()
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
opts = append(opts, ttyOpts...)
|
||||
|
||||
program := tea.NewProgram(model, opts...)
|
||||
if _, err := program.Run(); err != nil {
|
||||
con.PrintErrorf("AI TUI error: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Console logging can swap stdout/stderr to pipes so session output can be tee'd
|
||||
// to disk. Bubble Tea needs a real TTY output to enable terminal features like
|
||||
// mouse reporting, so bind the AI TUI back to the interactive terminal when
|
||||
// stdout is no longer the controlling TTY.
|
||||
func configureAIProgramTTY() ([]tea.ProgramOption, func()) {
|
||||
stdinTTY := isAITTY(os.Stdin)
|
||||
stdoutTTY := isAITTY(os.Stdout)
|
||||
|
||||
switch {
|
||||
case !stdinTTY || stdoutTTY:
|
||||
return nil, nil
|
||||
|
||||
case isAITTY(termio.InteractiveInput()) && isAITTY(termio.InteractiveOutput()):
|
||||
return []tea.ProgramOption{
|
||||
tea.WithInput(termio.InteractiveInput()),
|
||||
tea.WithOutput(termio.InteractiveOutput()),
|
||||
}, nil
|
||||
|
||||
default:
|
||||
inTTY, outTTY, err := openAIProgramTTY()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []tea.ProgramOption{
|
||||
tea.WithInput(inTTY),
|
||||
tea.WithOutput(outTTY),
|
||||
}, func() {
|
||||
_ = inTTY.Close()
|
||||
if outTTY != inTTY {
|
||||
_ = outTTY.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isAITTY(file *os.File) bool {
|
||||
return file != nil && term.IsTerminal(int(file.Fd()))
|
||||
}
|
||||
|
||||
type aiContext struct {
|
||||
target aiTargetSummary
|
||||
connection aiConnectionSummary
|
||||
@@ -48,13 +119,15 @@ type aiContext struct {
|
||||
}
|
||||
|
||||
type aiTargetSummary struct {
|
||||
Label string
|
||||
Host string
|
||||
OS string
|
||||
Arch string
|
||||
C2 string
|
||||
Mode string
|
||||
Details []string
|
||||
SessionID string
|
||||
BeaconID string
|
||||
Label string
|
||||
Host string
|
||||
OS string
|
||||
Arch string
|
||||
C2 string
|
||||
Mode string
|
||||
Details []string
|
||||
}
|
||||
|
||||
type aiConnectionSummary struct {
|
||||
@@ -64,16 +137,64 @@ type aiConnectionSummary struct {
|
||||
State string
|
||||
}
|
||||
|
||||
func defaultAITargetSummary() aiTargetSummary {
|
||||
return aiTargetSummary{
|
||||
Label: "No active target",
|
||||
Host: "Select a session or beacon with `use` or ctrl+s",
|
||||
Mode: "offline preview",
|
||||
C2: "n/a",
|
||||
OS: "unknown",
|
||||
Arch: "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
func aiSessionTargetSummary(session *clientpb.Session) aiTargetSummary {
|
||||
if session == nil {
|
||||
return defaultAITargetSummary()
|
||||
}
|
||||
|
||||
return aiTargetSummary{
|
||||
SessionID: session.ID,
|
||||
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
|
||||
Host: fallback(session.Hostname, "<unknown host>"),
|
||||
OS: fallback(session.OS, "unknown"),
|
||||
Arch: fallback(session.Arch, "unknown"),
|
||||
C2: fallback(session.ActiveC2, "unknown"),
|
||||
Mode: "interactive session",
|
||||
Details: []string{
|
||||
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
|
||||
fmt.Sprintf("PID: %d", session.PID),
|
||||
fmt.Sprintf("Remote: %s", fallback(session.RemoteAddress, "<unknown>")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func aiBeaconTargetSummary(beacon *clientpb.Beacon) aiTargetSummary {
|
||||
if beacon == nil {
|
||||
return defaultAITargetSummary()
|
||||
}
|
||||
|
||||
return aiTargetSummary{
|
||||
BeaconID: beacon.ID,
|
||||
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
|
||||
Host: fallback(beacon.Hostname, "<unknown host>"),
|
||||
OS: fallback(beacon.OS, "unknown"),
|
||||
Arch: fallback(beacon.Arch, "unknown"),
|
||||
C2: fallback(beacon.ActiveC2, "unknown"),
|
||||
Mode: "asynchronous beacon",
|
||||
Details: []string{
|
||||
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
|
||||
fmt.Sprintf("PID: %d", beacon.PID),
|
||||
fmt.Sprintf("Remote: %s", fallback(beacon.RemoteAddress, "<unknown>")),
|
||||
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
|
||||
fmt.Sprintf("Next checkin: %s", formatUnix(beacon.NextCheckin)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildAIContext(con *console.SliverClient) aiContext {
|
||||
ctx := aiContext{
|
||||
target: aiTargetSummary{
|
||||
Label: "No active target",
|
||||
Host: "Select a session or beacon with `use`",
|
||||
Mode: "offline preview",
|
||||
C2: "n/a",
|
||||
OS: "unknown",
|
||||
Arch: "unknown",
|
||||
},
|
||||
target: defaultAITargetSummary(),
|
||||
connection: aiConnectionSummary{
|
||||
Profile: "<disconnected>",
|
||||
Server: "<unknown>",
|
||||
@@ -96,33 +217,9 @@ func buildAIContext(con *console.SliverClient) aiContext {
|
||||
session, beacon := con.ActiveTarget.Get()
|
||||
switch {
|
||||
case session != nil:
|
||||
ctx.target = aiTargetSummary{
|
||||
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
|
||||
Host: fallback(session.Hostname, "<unknown host>"),
|
||||
OS: fallback(session.OS, "unknown"),
|
||||
Arch: fallback(session.Arch, "unknown"),
|
||||
C2: fallback(session.ActiveC2, "unknown"),
|
||||
Mode: "interactive session",
|
||||
Details: []string{
|
||||
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
|
||||
fmt.Sprintf("PID: %d", session.PID),
|
||||
fmt.Sprintf("Remote: %s", fallback(session.RemoteAddress, "<unknown>")),
|
||||
},
|
||||
}
|
||||
ctx.target = aiSessionTargetSummary(session)
|
||||
case beacon != nil:
|
||||
ctx.target = aiTargetSummary{
|
||||
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
|
||||
Host: fallback(beacon.Hostname, "<unknown host>"),
|
||||
OS: fallback(beacon.OS, "unknown"),
|
||||
Arch: fallback(beacon.Arch, "unknown"),
|
||||
C2: fallback(beacon.ActiveC2, "unknown"),
|
||||
Mode: "asynchronous beacon",
|
||||
Details: []string{
|
||||
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
|
||||
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
|
||||
fmt.Sprintf("Next checkin: %s", formatUnix(beacon.NextCheckin)),
|
||||
},
|
||||
}
|
||||
ctx.target = aiBeaconTargetSummary(beacon)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
package ai
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2026 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/command/help"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
|
||||
+136
-34
@@ -1,5 +1,23 @@
|
||||
package ai
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2026 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
@@ -49,7 +67,7 @@ func TestLoadAIStateCmdLoadsConversationOverRPC(t *testing.T) {
|
||||
rpcClient, cleanup := newAITestRPCClient(t, server)
|
||||
defer cleanup()
|
||||
|
||||
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, "")
|
||||
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")
|
||||
msg := cmd()
|
||||
|
||||
loaded, ok := msg.(aiLoadedMsg)
|
||||
@@ -87,24 +105,27 @@ func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
|
||||
providersResp: &clientpb.AIProviderConfigs{
|
||||
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
|
||||
Config: &clientpb.AIConfigSummary{
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
Valid: true,
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
SystemPrompt: "Stay concise.",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
conversationsResp: &clientpb.AIConversations{},
|
||||
saveConversationResp: &clientpb.AIConversation{
|
||||
ID: "conv-created",
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
Title: "New conversation",
|
||||
ID: "conv-created",
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
Title: "New conversation",
|
||||
SystemPrompt: "Stay concise.",
|
||||
},
|
||||
conversationByID: map[string]*clientpb.AIConversation{
|
||||
"conv-created": {
|
||||
ID: "conv-created",
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
Title: "New conversation",
|
||||
ID: "conv-created",
|
||||
Provider: "openai",
|
||||
Model: "gpt-test",
|
||||
Title: "New conversation",
|
||||
SystemPrompt: "Stay concise.",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -112,7 +133,7 @@ func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
|
||||
rpcClient, cleanup := newAITestRPCClient(t, server)
|
||||
defer cleanup()
|
||||
|
||||
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, "")()
|
||||
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")()
|
||||
|
||||
loaded, ok := msg.(aiLoadedMsg)
|
||||
if !ok {
|
||||
@@ -134,6 +155,9 @@ func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
|
||||
if request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetTitle() != "New conversation" {
|
||||
t.Fatalf("unexpected SaveAIConversation request: %+v", request)
|
||||
}
|
||||
if request.GetSystemPrompt() != "Stay concise." {
|
||||
t.Fatalf("unexpected SaveAIConversation system prompt: %q", request.GetSystemPrompt())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
|
||||
@@ -157,9 +181,11 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
|
||||
|
||||
msg := submitPromptCmd(
|
||||
&console.SliverClient{Rpc: rpcClient},
|
||||
aiTargetSummary{SessionID: "session-1"},
|
||||
nil,
|
||||
"openai",
|
||||
"gpt-test",
|
||||
"Stay concise.",
|
||||
"Explain the workflow.\n\nWith details.",
|
||||
)()
|
||||
|
||||
@@ -179,6 +205,12 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
|
||||
if server.saveConversationReqs[0].GetTitle() != "Explain the workflow." {
|
||||
t.Fatalf("unexpected conversation title: %q", server.saveConversationReqs[0].GetTitle())
|
||||
}
|
||||
if server.saveConversationReqs[0].GetTargetSessionID() != "session-1" {
|
||||
t.Fatalf("expected target session to be forwarded, got %+v", server.saveConversationReqs[0])
|
||||
}
|
||||
if server.saveConversationReqs[0].GetSystemPrompt() != "Stay concise." {
|
||||
t.Fatalf("unexpected conversation system prompt: %q", server.saveConversationReqs[0].GetSystemPrompt())
|
||||
}
|
||||
if len(server.saveMessageReqs) != 1 {
|
||||
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
|
||||
}
|
||||
@@ -206,9 +238,11 @@ func TestSubmitPromptCmdUsesExistingConversationSettings(t *testing.T) {
|
||||
|
||||
msg := submitPromptCmd(
|
||||
&console.SliverClient{Rpc: rpcClient},
|
||||
aiTargetSummary{},
|
||||
&clientpb.AIConversation{ID: "conv-1", Provider: "anthropic", Model: "claude-test", Title: "Thread"},
|
||||
"openai",
|
||||
"gpt-test",
|
||||
"Stay concise.",
|
||||
"hello",
|
||||
)()
|
||||
|
||||
@@ -262,10 +296,16 @@ func TestDeleteConversationCmdSendsDeleteRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
|
||||
conversation := &clientpb.AIConversation{ID: "conv-1", OperatorName: "alice"}
|
||||
data, err := proto.Marshal(conversation)
|
||||
eventPayload := &clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: "conv-1",
|
||||
OperatorName: "alice",
|
||||
},
|
||||
}
|
||||
data, err := proto.Marshal(eventPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal conversation: %v", err)
|
||||
t.Fatalf("marshal conversation event: %v", err)
|
||||
}
|
||||
|
||||
listener := make(chan *clientpb.Event, 2)
|
||||
@@ -278,8 +318,27 @@ func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected aiConversationEventMsg, got %T", msg)
|
||||
}
|
||||
if aiEvent.conversation == nil || aiEvent.conversation.GetID() != "conv-1" {
|
||||
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.conversation)
|
||||
if aiEvent.event == nil || aiEvent.event.GetConversation() == nil || aiEvent.event.GetConversation().GetID() != "conv-1" {
|
||||
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAIConversationEventCmdReturnsToastMessages(t *testing.T) {
|
||||
listener := make(chan *clientpb.Event, 1)
|
||||
listener <- &clientpb.Event{
|
||||
EventType: consts.ClientToastEvent,
|
||||
Data: []byte("Session alpha connected"),
|
||||
Err: "info",
|
||||
}
|
||||
|
||||
msg := waitForAIConversationEventCmd(listener)()
|
||||
|
||||
toast, ok := msg.(aiToastMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected aiToastMsg, got %T", msg)
|
||||
}
|
||||
if toast.level != "info" || toast.message != "Session alpha connected" {
|
||||
t.Fatalf("unexpected toast payload: %+v", toast)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +352,7 @@ func TestWaitForAIConversationEventCmdReturnsClosedMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIModelReloadsSharedConversationEvents(t *testing.T) {
|
||||
func TestAIModelAppliesSharedConversationEventsWithoutReload(t *testing.T) {
|
||||
listener := make(chan *clientpb.Event)
|
||||
close(listener)
|
||||
|
||||
@@ -301,25 +360,30 @@ func TestAIModelReloadsSharedConversationEvents(t *testing.T) {
|
||||
connection: aiConnectionSummary{Operator: "alice"},
|
||||
}, listener)
|
||||
model.loading = false
|
||||
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "First"}}
|
||||
|
||||
updated, cmd := model.Update(aiConversationEventMsg{conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
OperatorName: "bob",
|
||||
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
OperatorName: "bob",
|
||||
Title: "Shared thread",
|
||||
},
|
||||
}})
|
||||
|
||||
nextModel := updated.(*aiModel)
|
||||
if !nextModel.loading {
|
||||
t.Fatal("expected shared conversation event to trigger a reload")
|
||||
if nextModel.loading {
|
||||
t.Fatal("did not expect shared conversation event to trigger a reload")
|
||||
}
|
||||
if nextModel.status != "Conversation updated on the server. Refreshing..." {
|
||||
t.Fatalf("unexpected reload status: %q", nextModel.status)
|
||||
if conversationIndexByID(nextModel.conversations, "conv-2") < 0 {
|
||||
t.Fatalf("expected shared conversation to be merged locally, got %+v", nextModel.conversations)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected shared conversation event to schedule a refresh")
|
||||
t.Fatal("expected shared conversation event to continue listening for events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIModelReloadsDeleteEventsWhileAwaitingResponse(t *testing.T) {
|
||||
func TestAIModelAppliesDeleteEventsWhileAwaitingResponse(t *testing.T) {
|
||||
listener := make(chan *clientpb.Event)
|
||||
close(listener)
|
||||
|
||||
@@ -332,22 +396,36 @@ func TestAIModelReloadsDeleteEventsWhileAwaitingResponse(t *testing.T) {
|
||||
ID: "conv-2",
|
||||
OperatorName: "alice",
|
||||
UpdatedAt: 100,
|
||||
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "still waiting"},
|
||||
},
|
||||
}
|
||||
model.conversations = []*clientpb.AIConversation{
|
||||
{ID: "conv-2", Title: "Current"},
|
||||
{ID: "conv-1", Title: "Fallback"},
|
||||
}
|
||||
|
||||
updated, cmd := model.Update(aiConversationEventMsg{conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
OperatorName: "bob",
|
||||
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
|
||||
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
|
||||
ConversationID: "conv-2",
|
||||
Conversation: &clientpb.AIConversation{
|
||||
ID: "conv-2",
|
||||
},
|
||||
}})
|
||||
|
||||
nextModel := updated.(*aiModel)
|
||||
if !nextModel.loading {
|
||||
t.Fatal("expected delete tombstone to reload the conversation")
|
||||
if nextModel.loading {
|
||||
t.Fatal("did not expect delete tombstone to trigger a reload")
|
||||
}
|
||||
if nextModel.currentConversation != nil {
|
||||
t.Fatalf("expected deleted conversation to be cleared, got %+v", nextModel.currentConversation)
|
||||
}
|
||||
if conversationIndexByID(nextModel.conversations, "conv-2") >= 0 {
|
||||
t.Fatalf("expected deleted conversation to be removed, got %+v", nextModel.conversations)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected delete tombstone to schedule a refresh")
|
||||
t.Fatal("expected delete tombstone to continue listening for events")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,9 +439,13 @@ type aiRPCServer struct {
|
||||
conversationByID map[string]*clientpb.AIConversation
|
||||
saveConversationResp *clientpb.AIConversation
|
||||
saveMessageResp *clientpb.AIConversationMessage
|
||||
sessionsResp *clientpb.Sessions
|
||||
beaconsResp *clientpb.Beacons
|
||||
|
||||
getProvidersCalls int
|
||||
getConversationsCalls int
|
||||
getSessionsCalls int
|
||||
getBeaconsCalls int
|
||||
getConversationReqs []*clientpb.AIConversationReq
|
||||
deleteConversationReqs []*clientpb.AIConversationReq
|
||||
saveConversationReqs []*clientpb.AIConversation
|
||||
@@ -394,6 +476,26 @@ func (s *aiRPCServer) GetAIConversations(context.Context, *commonpb.Empty) (*cli
|
||||
return proto.Clone(s.conversationsResp).(*clientpb.AIConversations), nil
|
||||
}
|
||||
|
||||
func (s *aiRPCServer) GetSessions(context.Context, *commonpb.Empty) (*clientpb.Sessions, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.getSessionsCalls++
|
||||
if s.sessionsResp == nil {
|
||||
return &clientpb.Sessions{}, nil
|
||||
}
|
||||
return proto.Clone(s.sessionsResp).(*clientpb.Sessions), nil
|
||||
}
|
||||
|
||||
func (s *aiRPCServer) GetBeacons(context.Context, *commonpb.Empty) (*clientpb.Beacons, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.getBeaconsCalls++
|
||||
if s.beaconsResp == nil {
|
||||
return &clientpb.Beacons{}, nil
|
||||
}
|
||||
return proto.Clone(s.beaconsResp).(*clientpb.Beacons), nil
|
||||
}
|
||||
|
||||
func (s *aiRPCServer) GetAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*clientpb.AIConversation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
+3036
-255
File diff suppressed because it is too large.
Load diff
+1569
-14
File diff suppressed because it is too large.
Load diff
@@ -164,7 +164,7 @@ func ArmoryCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
cacheEntry, ok := value.(pkgCacheEntry)
|
||||
if !ok {
|
||||
// Something is wrong with this entry
|
||||
pkgCache.Delete(value)
|
||||
pkgCache.Delete(key)
|
||||
return true
|
||||
}
|
||||
if cacheEntry.ArmoryConfig.PublicKey != index.ArmoryConfig.PublicKey {
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
package armory
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2026 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"github.com/rsteube/carapace"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
package armory
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2026 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -66,6 +66,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
flags.Bind("", false, consoleCmd, func(f *pflag.FlagSet) {
|
||||
f.String("transport", string(defaultConfig.Transport), "mcp transport (http or sse)")
|
||||
f.String("url", "", "mcp server URL (defaults to local server settings)")
|
||||
f.String("auth-token", "", "MCP auth token (defaults to the local mcp.yaml token)")
|
||||
})
|
||||
flags.BindFlagCompletions(consoleCmd, func(comp *carapace.ActionMap) {
|
||||
(*comp)["transport"] = carapace.ActionValues("http", "sse")
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
clientmcp "github.com/bishopfox/sliver/client/mcp"
|
||||
"github.com/bishopfox/sliver/client/version"
|
||||
mcpclient "github.com/mark3labs/mcp-go/client"
|
||||
mcptransport "github.com/mark3labs/mcp-go/client/transport"
|
||||
mcpapi "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -36,7 +38,23 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
}
|
||||
}
|
||||
|
||||
client, err := newConsoleClient(transport, targetURL)
|
||||
authToken, _ := cmd.Flags().GetString("auth-token")
|
||||
if authToken == "" {
|
||||
status := clientmcp.GetStatus()
|
||||
if status.AuthToken != "" {
|
||||
authToken = status.AuthToken
|
||||
} else {
|
||||
authInfo, loadErr := clientmcp.LoadAuthInfo()
|
||||
if loadErr == nil {
|
||||
authToken = authInfo.Token
|
||||
} else if !errors.Is(loadErr, os.ErrNotExist) {
|
||||
con.PrintErrorf("%s\n", loadErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client, err := newConsoleClient(transport, targetURL, authToken)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
@@ -68,12 +86,19 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
runConsoleREPL(ctx, client, cmd.InOrStdin(), cmd.OutOrStdout())
|
||||
}
|
||||
|
||||
func newConsoleClient(transport clientmcp.Transport, targetURL string) (*mcpclient.Client, error) {
|
||||
func newConsoleClient(transport clientmcp.Transport, targetURL string, authToken string) (*mcpclient.Client, error) {
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
return nil, fmt.Errorf("mcp auth token is required (use --auth-token or start the local MCP server first)")
|
||||
}
|
||||
headers := map[string]string{
|
||||
clientmcp.AuthHeaderName(): authToken,
|
||||
}
|
||||
|
||||
switch transport {
|
||||
case clientmcp.TransportHTTP:
|
||||
return mcpclient.NewStreamableHttpClient(targetURL)
|
||||
return mcpclient.NewStreamableHttpClient(targetURL, mcptransport.WithHTTPHeaders(headers))
|
||||
case clientmcp.TransportSSE:
|
||||
return mcpclient.NewSSEMCPClient(targetURL)
|
||||
return mcpclient.NewSSEMCPClient(targetURL, mcpclient.WithHeaders(headers))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported transport %q", transport)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ func McpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
} else {
|
||||
con.PrintErrorf("Endpoint: %s\n", err)
|
||||
}
|
||||
if status.AuthHeader != "" {
|
||||
con.Printf("Auth Header: %s\n", status.AuthHeader)
|
||||
}
|
||||
if status.AuthToken != "" {
|
||||
con.Printf("Auth Token: %s\n", status.AuthToken)
|
||||
}
|
||||
|
||||
if status.Running && !status.StartedAt.IsZero() {
|
||||
uptime := time.Since(status.StartedAt).Truncate(time.Second)
|
||||
|
||||
@@ -43,4 +43,11 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if err == nil {
|
||||
con.PrintInfof("Endpoint: %s\n", endpoint)
|
||||
}
|
||||
status := slivermcp.GetStatus()
|
||||
if status.AuthHeader != "" {
|
||||
con.PrintInfof("Auth Header: %s\n", status.AuthHeader)
|
||||
}
|
||||
if status.AuthToken != "" {
|
||||
con.PrintInfof("Auth Token: %s\n", status.AuthToken)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+108
-14
@@ -29,6 +29,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
@@ -94,11 +95,15 @@ 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
|
||||
connectionHooksApplied bool
|
||||
logCommandHookApplied bool
|
||||
suppressedEventNotifs atomic.Int32
|
||||
|
||||
// These writers are always safe to use (never error); the underlying stream
|
||||
// can be swapped on server switch to avoid breaking io.MultiWriter/io.Copy.
|
||||
@@ -324,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
|
||||
@@ -334,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
|
||||
@@ -352,8 +362,20 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
|
||||
switch event.EventType {
|
||||
|
||||
case consts.CanaryEvent:
|
||||
con.PrintEventErrorf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
|
||||
sessions := con.GetSessionsByName(event.Session.Name)
|
||||
if con.eventNotificationsSuppressed() {
|
||||
affected := make([]string, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
affected = append(affected, strings.Split(session.ID, "-")[0])
|
||||
}
|
||||
message := fmt.Sprintf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
|
||||
if len(affected) > 0 {
|
||||
message += " Affected sessions: " + strings.Join(affected, ", ")
|
||||
}
|
||||
con.emitToastEvent("error", message)
|
||||
break
|
||||
}
|
||||
con.PrintEventErrorf("%s %s has been burned (DNS Canary)", StyleBold.Render("WARNING:"), event.Session.Name)
|
||||
for _, session := range sessions {
|
||||
shortID := strings.Split(session.ID, "-")[0]
|
||||
con.PrintErrorf("\t🔥 Session %s is affected", shortID)
|
||||
@@ -361,8 +383,20 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
|
||||
|
||||
case consts.WatchtowerEvent:
|
||||
msg := string(event.Data)
|
||||
con.PrintEventErrorf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
|
||||
sessions := con.GetSessionsByName(event.Session.Name)
|
||||
if con.eventNotificationsSuppressed() {
|
||||
affected := make([]string, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
affected = append(affected, strings.Split(session.ID, "-")[0])
|
||||
}
|
||||
message := fmt.Sprintf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
|
||||
if len(affected) > 0 {
|
||||
message += " Affected sessions: " + strings.Join(affected, ", ")
|
||||
}
|
||||
con.emitToastEvent("error", message)
|
||||
break
|
||||
}
|
||||
con.PrintEventErrorf("%s %s has been burned (seen on %s)", StyleBold.Render("WARNING:"), event.Session.Name, msg)
|
||||
for _, session := range sessions {
|
||||
shortID := strings.Split(session.ID, "-")[0]
|
||||
con.PrintErrorf("\t🔥 Session %s is affected", shortID)
|
||||
@@ -370,42 +404,42 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
|
||||
|
||||
case consts.JoinedEvent:
|
||||
if con.Settings.UserConnect {
|
||||
con.PrintInfof("%s has joined the game", event.Client.Operator.Name)
|
||||
con.emitConsoleNotification("info", false, "%s has joined the game", event.Client.Operator.Name)
|
||||
}
|
||||
case consts.LeftEvent:
|
||||
if con.Settings.UserConnect {
|
||||
con.PrintInfof("%s left the game", event.Client.Operator.Name)
|
||||
con.emitConsoleNotification("info", false, "%s left the game", event.Client.Operator.Name)
|
||||
}
|
||||
|
||||
case consts.JobStoppedEvent:
|
||||
job := event.Job
|
||||
con.PrintErrorf("Job #%d stopped (%s/%s)", job.ID, job.Protocol, job.Name)
|
||||
con.emitConsoleNotification("error", false, "Job #%d stopped (%s/%s)", job.ID, job.Protocol, job.Name)
|
||||
|
||||
case consts.SessionOpenedEvent:
|
||||
session := event.Session
|
||||
currentTime := time.Now().Format(time.RFC1123)
|
||||
shortID := strings.Split(session.ID, "-")[0]
|
||||
con.PrintEventInfof("Session %s %s - %s (%s) - %s/%s - %v",
|
||||
con.emitConsoleNotification("info", true, "Session %s %s - %s (%s) - %s/%s - %v",
|
||||
shortID, session.Name, session.RemoteAddress, session.Hostname, session.OS, session.Arch, currentTime)
|
||||
|
||||
case consts.SessionUpdateEvent:
|
||||
session := event.Session
|
||||
currentTime := time.Now().Format(time.RFC1123)
|
||||
shortID := strings.Split(session.ID, "-")[0]
|
||||
con.PrintInfof("Session %s has been updated - %v", shortID, currentTime)
|
||||
con.emitConsoleNotification("info", false, "Session %s has been updated - %v", shortID, currentTime)
|
||||
|
||||
case consts.SessionClosedEvent:
|
||||
session := event.Session
|
||||
currentTime := time.Now().Format(time.RFC1123)
|
||||
shortID := strings.Split(session.ID, "-")[0]
|
||||
con.PrintEventErrorf("Lost session %s %s - %s (%s) - %s/%s - %v",
|
||||
con.emitConsoleNotification("error", true, "Lost session %s %s - %s (%s) - %s/%s - %v",
|
||||
shortID, session.Name, session.RemoteAddress, session.Hostname, session.OS, session.Arch, currentTime)
|
||||
activeSession := con.ActiveTarget.GetSession()
|
||||
core.GetTunnels().CloseForSession(session.ID)
|
||||
core.CloseCursedProcesses(session.ID)
|
||||
if activeSession != nil && activeSession.ID == session.ID {
|
||||
con.ActiveTarget.Set(nil, nil)
|
||||
con.PrintErrorf("Active session disconnected")
|
||||
con.emitConsoleNotification("error", false, "Active session disconnected")
|
||||
}
|
||||
|
||||
case consts.BeaconRegisteredEvent:
|
||||
@@ -413,7 +447,7 @@ func (con *SliverClient) startEventLoop(ctx context.Context) {
|
||||
proto.Unmarshal(event.Data, beacon)
|
||||
currentTime := time.Now().Format(time.RFC1123)
|
||||
shortID := strings.Split(beacon.ID, "-")[0]
|
||||
con.PrintEventInfof("Beacon %s %s - %s (%s) - %s/%s - %v",
|
||||
con.emitConsoleNotification("info", true, "Beacon %s %s - %s (%s) - %s/%s - %v",
|
||||
shortID, beacon.Name, beacon.RemoteAddress, beacon.Hostname, beacon.OS, beacon.Arch, currentTime)
|
||||
|
||||
case consts.BeaconTaskResultEvent:
|
||||
@@ -433,6 +467,66 @@ func (con *SliverClient) CreateEventListener() (string, <-chan *clientpb.Event)
|
||||
return listenerID.String(), listener
|
||||
}
|
||||
|
||||
// SuppressEventNotifications redirects console event messages to local listeners instead of stdout.
|
||||
func (con *SliverClient) SuppressEventNotifications() func() {
|
||||
if con == nil {
|
||||
return func() {}
|
||||
}
|
||||
con.suppressedEventNotifs.Add(1)
|
||||
return func() {
|
||||
if con == nil {
|
||||
return
|
||||
}
|
||||
if con.suppressedEventNotifs.Add(-1) < 0 {
|
||||
con.suppressedEventNotifs.Store(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (con *SliverClient) eventNotificationsSuppressed() bool {
|
||||
return con != nil && con.suppressedEventNotifs.Load() > 0
|
||||
}
|
||||
|
||||
func (con *SliverClient) emitToastEvent(level string, message string) {
|
||||
if con == nil || con.EventListeners == nil || strings.TrimSpace(message) == "" {
|
||||
return
|
||||
}
|
||||
con.triggerEventListeners(&clientpb.Event{
|
||||
EventType: consts.ClientToastEvent,
|
||||
Data: []byte(message),
|
||||
Err: strings.TrimSpace(level),
|
||||
})
|
||||
}
|
||||
|
||||
func (con *SliverClient) emitConsoleNotification(level string, emphasized bool, format string, args ...any) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
if con.eventNotificationsSuppressed() {
|
||||
con.emitToastEvent(level, message)
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "error", "warn", "warning":
|
||||
if emphasized {
|
||||
con.PrintEventErrorf("%s", message)
|
||||
} else {
|
||||
con.PrintErrorf("%s", message)
|
||||
}
|
||||
case "success":
|
||||
if emphasized {
|
||||
con.PrintEventSuccessf("%s", message)
|
||||
} else {
|
||||
con.PrintSuccessf("%s", message)
|
||||
}
|
||||
default:
|
||||
if emphasized {
|
||||
con.PrintEventInfof("%s", message)
|
||||
} else {
|
||||
con.PrintInfof("%s", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveEventListener - removes an event listener given its id.
|
||||
func (con *SliverClient) RemoveEventListener(listenerID string) {
|
||||
value, ok := con.EventListeners.LoadAndDelete(listenerID)
|
||||
@@ -477,10 +571,10 @@ func (con *SliverClient) triggerReactions(event *clientpb.Event) {
|
||||
|
||||
for _, reaction := range reactions {
|
||||
for _, line := range reaction.Commands {
|
||||
con.PrintInfof("%s '%s'", StyleBold.Render("Execute reaction:"), line)
|
||||
con.emitConsoleNotification("info", false, "%s '%s'", StyleBold.Render("Execute reaction:"), line)
|
||||
err := con.App.ActiveMenu().RunCommandLine(context.Background(), line)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Reaction command error: %s\n", err)
|
||||
con.emitConsoleNotification("error", false, "Reaction command error: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package console
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
)
|
||||
|
||||
func TestDrainStdoutPipeBufferStripsSyncFrames(t *testing.T) {
|
||||
@@ -102,3 +105,33 @@ func TestSyncOutputWaitsForPipeDrain(t *testing.T) {
|
||||
t.Fatalf("unexpected final output %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressEventNotificationsRedirectsMessagesToListeners(t *testing.T) {
|
||||
con := &SliverClient{
|
||||
EventListeners: &sync.Map{},
|
||||
printf: func(string, ...any) (int, error) { return 0, nil },
|
||||
}
|
||||
|
||||
listenerID, listener := con.CreateEventListener()
|
||||
defer con.RemoveEventListener(listenerID)
|
||||
|
||||
restore := con.SuppressEventNotifications()
|
||||
defer restore()
|
||||
|
||||
con.emitConsoleNotification("info", true, "Session %s connected", "alpha")
|
||||
|
||||
select {
|
||||
case event := <-listener:
|
||||
if event.GetEventType() != consts.ClientToastEvent {
|
||||
t.Fatalf("expected toast event, got %q", event.GetEventType())
|
||||
}
|
||||
if got := string(event.GetData()); got != "Session alpha connected" {
|
||||
t.Fatalf("unexpected toast payload %q", got)
|
||||
}
|
||||
if got := event.GetErr(); got != "info" {
|
||||
t.Fatalf("unexpected toast level %q", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for redirected event notification")
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,9 @@ const (
|
||||
|
||||
// AIConversationEvent - Fires whenever an AI conversation or message changes.
|
||||
AIConversationEvent = "ai-conversation"
|
||||
|
||||
// ClientToastEvent - Local-only event used to surface console notifications inside TUIs.
|
||||
ClientToastEvent = "client-toast"
|
||||
)
|
||||
|
||||
// Commands.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
authConfigFileName = "mcp.yaml"
|
||||
authHeaderName = "Authorization"
|
||||
minAuthTokenLength = 8
|
||||
authTokenBytes = 16
|
||||
sseMessagePath = "/message"
|
||||
)
|
||||
|
||||
// AuthInfo describes the persisted MCP HTTP/SSE authentication settings.
|
||||
type AuthInfo struct {
|
||||
Header string
|
||||
Token string
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// AuthHeaderName returns the required header name for MCP HTTP/SSE requests.
|
||||
func AuthHeaderName() string {
|
||||
return authHeaderName
|
||||
}
|
||||
|
||||
// AuthConfigPath returns the MCP auth config path in the client app directory.
|
||||
func AuthConfigPath() string {
|
||||
rootDir, _ := filepath.Abs(assets.GetRootAppDir())
|
||||
return filepath.Join(rootDir, authConfigFileName)
|
||||
}
|
||||
|
||||
type authConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
// LoadAuthInfo loads the persisted MCP auth configuration without creating it.
|
||||
func LoadAuthInfo() (AuthInfo, error) {
|
||||
cfg, exists, err := loadAuthConfig()
|
||||
if err != nil {
|
||||
return AuthInfo{}, err
|
||||
}
|
||||
if !exists {
|
||||
return AuthInfo{}, os.ErrNotExist
|
||||
}
|
||||
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||
if err := validateAuthToken(cfg.Token); err != nil {
|
||||
return AuthInfo{}, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
|
||||
}
|
||||
return AuthInfo{
|
||||
Header: authHeaderName,
|
||||
Token: cfg.Token,
|
||||
ConfigPath: AuthConfigPath(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveAuthInfo loads the persisted auth token or creates and saves one on first use.
|
||||
func ResolveAuthInfo() (AuthInfo, bool, error) {
|
||||
cfg, exists, err := loadAuthConfig()
|
||||
if err != nil {
|
||||
return AuthInfo{}, false, err
|
||||
}
|
||||
if exists {
|
||||
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||
if err := validateAuthToken(cfg.Token); err != nil {
|
||||
return AuthInfo{}, false, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
|
||||
}
|
||||
return AuthInfo{
|
||||
Header: authHeaderName,
|
||||
Token: cfg.Token,
|
||||
ConfigPath: AuthConfigPath(),
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
token, err := generateAuthToken()
|
||||
if err != nil {
|
||||
return AuthInfo{}, false, err
|
||||
}
|
||||
cfg = &authConfig{Token: token}
|
||||
if err := saveAuthConfig(cfg); err != nil {
|
||||
return AuthInfo{}, false, err
|
||||
}
|
||||
return AuthInfo{
|
||||
Header: authHeaderName,
|
||||
Token: token,
|
||||
ConfigPath: AuthConfigPath(),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func loadAuthConfig() (*authConfig, bool, error) {
|
||||
data, err := os.ReadFile(AuthConfigPath())
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cfg := &authConfig{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
func saveAuthConfig(cfg *authConfig) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(AuthConfigPath(), data, 0o600)
|
||||
}
|
||||
|
||||
func generateAuthToken() (string, error) {
|
||||
tokenBytes := make([]byte, authTokenBytes)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("generate mcp auth token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(tokenBytes), nil
|
||||
}
|
||||
|
||||
func validateAuthToken(token string) error {
|
||||
if len(strings.TrimSpace(token)) < minAuthTokenLength {
|
||||
return fmt.Errorf("token must be at least %d characters", minAuthTokenLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authMiddleware(token string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !authTokenMatches(r.Header.Get(authHeaderName), token) {
|
||||
http.Error(w, fmt.Sprintf("missing or invalid %s header", authHeaderName), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func authTokenMatches(actual string, expected string) bool {
|
||||
if len(expected) == 0 || len(actual) != len(expected) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
type authenticatedHTTPTransport struct {
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func newAuthenticatedTransport(cfg Config, handler http.Handler, token string) serverTransport {
|
||||
mux := http.NewServeMux()
|
||||
authenticatedHandler := authMiddleware(token, handler)
|
||||
|
||||
switch cfg.Transport {
|
||||
case TransportHTTP:
|
||||
mux.Handle("/mcp", authenticatedHandler)
|
||||
case TransportSSE:
|
||||
mux.Handle("/sse", authenticatedHandler)
|
||||
mux.Handle(sseMessagePath, authenticatedHandler)
|
||||
}
|
||||
|
||||
return &authenticatedHTTPTransport{
|
||||
server: &http.Server{
|
||||
Handler: mux,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *authenticatedHTTPTransport) Start(addr string) error {
|
||||
if t.server.Addr == "" {
|
||||
t.server.Addr = addr
|
||||
} else if t.server.Addr != addr {
|
||||
return fmt.Errorf("conflicting listen address: %q vs %q", t.server.Addr, addr)
|
||||
}
|
||||
return t.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (t *authenticatedHTTPTransport) Shutdown(ctx context.Context) error {
|
||||
return t.server.Shutdown(ctx)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAuthInfoCreatesAndPersistsToken(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
|
||||
authInfo, created, err := ResolveAuthInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAuthInfo returned error: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatalf("expected first ResolveAuthInfo call to create a token")
|
||||
}
|
||||
if authInfo.Header != AuthHeaderName() {
|
||||
t.Fatalf("expected header %q, got %q", AuthHeaderName(), authInfo.Header)
|
||||
}
|
||||
if len(authInfo.Token) != authTokenBytes*2 {
|
||||
t.Fatalf("expected %d hex characters, got %d", authTokenBytes*2, len(authInfo.Token))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(AuthConfigPath())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read persisted auth config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "token: "+authInfo.Token) {
|
||||
t.Fatalf("persisted config did not contain generated token: %s", string(data))
|
||||
}
|
||||
|
||||
authInfoAgain, createdAgain, err := ResolveAuthInfo()
|
||||
if err != nil {
|
||||
t.Fatalf("second ResolveAuthInfo returned error: %v", err)
|
||||
}
|
||||
if createdAgain {
|
||||
t.Fatalf("expected second ResolveAuthInfo call to reuse the existing token")
|
||||
}
|
||||
if authInfoAgain.Token != authInfo.Token {
|
||||
t.Fatalf("expected persisted token %q, got %q", authInfo.Token, authInfoAgain.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthInfoRejectsInvalidExistingToken(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
|
||||
if err := os.WriteFile(AuthConfigPath(), []byte("token: short\n"), 0o600); err != nil {
|
||||
t.Fatalf("failed to seed auth config: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := ResolveAuthInfo()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid token error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least 8 characters") {
|
||||
t.Fatalf("expected length validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareRequiresExactToken(t *testing.T) {
|
||||
const token = "0123456789abcdef"
|
||||
|
||||
handler := authMiddleware(token, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
header string
|
||||
statusCode int
|
||||
}{
|
||||
{name: "missing", statusCode: http.StatusUnauthorized},
|
||||
{name: "wrong", header: "wrong-token", statusCode: http.StatusUnauthorized},
|
||||
{name: "prefixed", header: "Bearer " + token, statusCode: http.StatusUnauthorized},
|
||||
{name: "exact", header: token, statusCode: http.StatusNoContent},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
if testCase.header != "" {
|
||||
req.Header.Set(AuthHeaderName(), testCase.header)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != testCase.statusCode {
|
||||
t.Fatalf("expected status %d, got %d", testCase.statusCode, recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -20,10 +20,13 @@ var (
|
||||
|
||||
// Status describes the running state of the MCP server.
|
||||
type Status struct {
|
||||
Running bool
|
||||
StartedAt time.Time
|
||||
Config Config
|
||||
LastError string
|
||||
Running bool
|
||||
StartedAt time.Time
|
||||
Config Config
|
||||
LastError string
|
||||
AuthHeader string
|
||||
AuthToken string
|
||||
AuthConfigPath string
|
||||
}
|
||||
|
||||
type serverTransport interface {
|
||||
@@ -42,6 +45,7 @@ type mcpManager struct {
|
||||
done chan struct{}
|
||||
logger *log.Logger
|
||||
logFile *os.File
|
||||
authInfo AuthInfo
|
||||
}
|
||||
|
||||
func newManager() *mcpManager {
|
||||
@@ -93,10 +97,13 @@ func (m *mcpManager) status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return Status{
|
||||
Running: m.running,
|
||||
StartedAt: m.startedAt,
|
||||
Config: m.cfg,
|
||||
LastError: m.lastErr,
|
||||
Running: m.running,
|
||||
StartedAt: m.startedAt,
|
||||
Config: m.cfg,
|
||||
LastError: m.lastErr,
|
||||
AuthHeader: m.authInfo.Header,
|
||||
AuthToken: m.authInfo.Token,
|
||||
AuthConfigPath: m.authInfo.ConfigPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +112,10 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
authInfo, _, err := ResolveAuthInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.running {
|
||||
@@ -120,9 +131,9 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
var transport serverTransport
|
||||
switch cfg.Transport {
|
||||
case TransportHTTP:
|
||||
transport = mcpserver.NewStreamableHTTPServer(mcpServer.server)
|
||||
transport = newAuthenticatedTransport(cfg, mcpserver.NewStreamableHTTPServer(mcpServer.server), authInfo.Token)
|
||||
case TransportSSE:
|
||||
transport = mcpserver.NewSSEServer(mcpServer.server)
|
||||
transport = newAuthenticatedTransport(cfg, mcpserver.NewSSEServer(mcpServer.server), authInfo.Token)
|
||||
default:
|
||||
m.mu.Unlock()
|
||||
logFile.Close()
|
||||
@@ -138,11 +149,12 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
|
||||
m.done = make(chan struct{})
|
||||
m.logger = logger
|
||||
m.logFile = logFile
|
||||
m.authInfo = authInfo
|
||||
addr := cfg.ListenAddress
|
||||
done := m.done
|
||||
m.mu.Unlock()
|
||||
|
||||
logger.Printf("starting mcp server transport=%s listen=%s", cfg.Transport, cfg.ListenAddress)
|
||||
logger.Printf("starting mcp server transport=%s listen=%s auth_header=%s auth_config=%s", cfg.Transport, cfg.ListenAddress, authInfo.Header, authInfo.ConfigPath)
|
||||
go m.run(addr, transport, done)
|
||||
return nil
|
||||
}
|
||||
@@ -161,6 +173,7 @@ func (m *mcpManager) run(addr string, transport serverTransport, done chan struc
|
||||
m.running = false
|
||||
m.transport = nil
|
||||
m.server = nil
|
||||
m.authInfo = AuthInfo{}
|
||||
logFile := m.logFile
|
||||
m.logFile = nil
|
||||
m.logger = nil
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
# Sliver AI Agentic Loop
|
||||
|
||||
## Goal
|
||||
|
||||
Port the example agentic loop from `examples/codex/codex-rs` into Sliver's AI chat so the server can:
|
||||
|
||||
- run a multi-step Responses API turn
|
||||
- emit turn and item lifecycle events similar to Codex
|
||||
- persist reasoning and tool-call items for sync and restoration
|
||||
- keep those non-chat items out of the model context window
|
||||
- render the new items in the client TUI
|
||||
|
||||
## How The Codex Example Loop Works
|
||||
|
||||
The Codex example splits the loop into three layers.
|
||||
|
||||
### 1. Provider/Protocol Layer
|
||||
|
||||
The protocol layer receives raw model events such as:
|
||||
|
||||
- turn started / completed / failed
|
||||
- reasoning deltas and completed reasoning items
|
||||
- command/tool begin and end notifications
|
||||
- assistant messages
|
||||
|
||||
These provider events are fine-grained and often arrive as partial updates.
|
||||
|
||||
### 2. Event Processor Layer
|
||||
|
||||
`event_processor_with_jsonl_output.rs` converts the raw provider stream into a stable thread event model.
|
||||
|
||||
The important behaviors are:
|
||||
|
||||
- a turn gets a lifecycle: `turn.started`, then `turn.completed` or `turn.failed`
|
||||
- each logical item gets a stable id
|
||||
- begin/end pairs are folded into a single item lifecycle
|
||||
- reasoning is emitted as its own item type
|
||||
- tool calls and command executions are emitted as their own item types
|
||||
- active items can be updated in place before completion
|
||||
|
||||
This is the key abstraction that makes the TUI and persisted history simple.
|
||||
|
||||
### 3. TUI Layer
|
||||
|
||||
The Codex TUI consumes those stable events rather than reloading the whole thread.
|
||||
|
||||
That lets it:
|
||||
|
||||
- append new items incrementally
|
||||
- update an existing in-flight item in place
|
||||
- keep the transcript stable while a turn runs
|
||||
- restore the same item types from stored history later
|
||||
|
||||
## Sliver Port
|
||||
|
||||
Sliver ports the same shape, adapted to its existing RPC and database model.
|
||||
|
||||
### Conversation State
|
||||
|
||||
`AIConversation` now stores:
|
||||
|
||||
- `ActiveTurnID`
|
||||
- `TurnState`
|
||||
- `TargetSessionID`
|
||||
- `TargetBeaconID`
|
||||
|
||||
The turn fields let clients restore "waiting", "completed", and "failed" state without inferring it from the last chat message.
|
||||
|
||||
The target fields bind a conversation to the active session or beacon so server-side tools can default to the conversation target.
|
||||
|
||||
### Message State
|
||||
|
||||
`AIConversationMessage` now stores:
|
||||
|
||||
- `Kind`: chat, reasoning, or tool call
|
||||
- `Visibility`: context or UI-only
|
||||
- `IncludeInContext`: whether the block should be replayed into the provider context window
|
||||
- `State`: in progress, completed, or failed
|
||||
- `TurnID`
|
||||
- `ItemID`
|
||||
- tool call metadata: name, arguments, result, error text, tool call id
|
||||
|
||||
This keeps one ordered transcript table while still distinguishing chat context from UI-only telemetry.
|
||||
|
||||
### UI-Only Persistence
|
||||
|
||||
Reasoning, tool-call, and intermediate assistant chat items can be stored as normal conversation messages with `Visibility = UI_ONLY`.
|
||||
|
||||
That gives Sliver:
|
||||
|
||||
- cross-client sync through the existing conversation storage path
|
||||
- transcript restoration after reconnect or TUI restart
|
||||
- ordered replay of an entire turn, including intermediate items
|
||||
|
||||
At the same time, the model context builder keys off `IncludeInContext`, so UI visibility and provider visibility are now independent.
|
||||
|
||||
### Event Model
|
||||
|
||||
Sliver now publishes `AIConversationEvent` payloads instead of raw `AIConversation` snapshots.
|
||||
|
||||
Supported event types:
|
||||
|
||||
- `CONVERSATION_UPDATED`
|
||||
- `CONVERSATION_DELETED`
|
||||
- `TURN_STARTED`
|
||||
- `TURN_COMPLETED`
|
||||
- `TURN_FAILED`
|
||||
- `MESSAGE_STARTED`
|
||||
- `MESSAGE_UPDATED`
|
||||
- `MESSAGE_COMPLETED`
|
||||
|
||||
Each event can carry:
|
||||
|
||||
- a conversation summary
|
||||
- a single message/item
|
||||
- the turn id
|
||||
- optional error text
|
||||
|
||||
This mirrors the Codex pattern of stable turn and item lifecycle events.
|
||||
|
||||
## Server Turn Flow
|
||||
|
||||
For each new user message:
|
||||
|
||||
1. The user chat message is saved as a normal context-visible chat item.
|
||||
2. The server resolves runtime provider/model settings.
|
||||
3. A new turn id is generated and persisted with `TURN_STARTED`.
|
||||
4. If the runtime supports the Responses API loop, Sliver runs the agentic loop.
|
||||
5. Reasoning items are persisted as completed UI-only reasoning blocks.
|
||||
6. Tool calls are persisted as in-progress UI-only tool blocks, executed locally, then updated in place to completed or failed.
|
||||
7. Intermediate assistant chat blocks on tool-use turns are persisted as UI-only chat items with `IncludeInContext = false`.
|
||||
8. Tool outputs are fed back into the Responses API with `previous_response_id`.
|
||||
9. When the model returns final assistant text, that assistant message is persisted as a normal context-visible chat item with `IncludeInContext = true`.
|
||||
10. The conversation turn state is cleared with `TURN_COMPLETED`.
|
||||
|
||||
If anything fails:
|
||||
|
||||
- a UI-only failed system message is persisted
|
||||
- the conversation is marked `TURN_FAILED`
|
||||
- the failure remains visible in the transcript without polluting future model context
|
||||
|
||||
## Tool Surface
|
||||
|
||||
The first port exposes a safe read-only tool set:
|
||||
|
||||
- `list_sessions_and_beacons`
|
||||
- `fs_ls`
|
||||
- `fs_cat`
|
||||
- `fs_pwd`
|
||||
|
||||
Why read-only first:
|
||||
|
||||
- it proves the loop, storage, eventing, and TUI behavior
|
||||
- it avoids surprising destructive remote actions in the first server-side tool release
|
||||
|
||||
Filesystem tools accept explicit `session_id` or `beacon_id`, but when omitted they fall back to the conversation target.
|
||||
|
||||
Beacon-backed tools wait for task completion on the server before emitting the completed item update.
|
||||
|
||||
## Context Filtering
|
||||
|
||||
Only chat messages with:
|
||||
|
||||
- `Kind = CHAT`
|
||||
- `IncludeInContext = true`
|
||||
|
||||
are included in future provider requests.
|
||||
|
||||
That means these items are excluded from the model context window:
|
||||
|
||||
- reasoning blocks
|
||||
- tool call blocks
|
||||
- UI-only intermediate assistant chat blocks
|
||||
- failed system status blocks
|
||||
|
||||
This is the main rule that preserves transcript richness without degrading prompt quality.
|
||||
|
||||
## TUI Behavior
|
||||
|
||||
The AI TUI now consumes `AIConversationEvent` incrementally.
|
||||
|
||||
Instead of reloading the whole conversation for every server event, it:
|
||||
|
||||
- merges conversation summaries into the sidebar
|
||||
- upserts messages in place by message id or item id
|
||||
- updates waiting state from `TurnState`
|
||||
- removes deleted conversations immediately
|
||||
|
||||
Reasoning and tool-call blocks render as dedicated dark-grey transcript boxes.
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning blocks show:
|
||||
|
||||
- the `Reasoning` label
|
||||
- the persisted reasoning text
|
||||
- item state metadata
|
||||
|
||||
### Tool Call Blocks
|
||||
|
||||
Tool blocks show:
|
||||
|
||||
- `Tool: <name>`
|
||||
- arguments
|
||||
- result
|
||||
- error text when present
|
||||
- item state metadata
|
||||
|
||||
These blocks are restored exactly like chat messages because they are stored in the same ordered transcript.
|
||||
|
||||
## Restore And Sync
|
||||
|
||||
Because UI-only items live in the database:
|
||||
|
||||
- another Sliver client can attach to the same conversation and see the full turn history
|
||||
- reconnecting the TUI restores reasoning and tool-call blocks
|
||||
- turn state survives restart, so "in progress" and "failed" status can be reconstructed from persisted conversation metadata
|
||||
|
||||
## Differences From Codex
|
||||
|
||||
This port intentionally differs from the example in a few places.
|
||||
|
||||
- Sliver currently uses non-streaming Responses API requests, so reasoning items arrive as completed items rather than token deltas.
|
||||
- Tool execution is currently a bounded read-only server-side function set, not the full Codex local tool surface.
|
||||
- Sliver persists intermediate items in its conversation DB rather than only in a transient thread runtime.
|
||||
|
||||
The important invariant is still the same:
|
||||
|
||||
- turn lifecycle is explicit
|
||||
- item lifecycle is explicit
|
||||
- UI-only items are persisted and replayable
|
||||
- only context-visible chat reaches the model
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
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>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
|
||||
Generated
+1261
-4018
File diff suppressed because it is too large.
Load diff
@@ -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.15.0",
|
||||
"next": "^16.2.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
<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()}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
Sliver allows an operator to extend the local client console and its features by adding new commands based on third party tools. The easiest way to install an alias or extension is using the [armory](/docs?name=Armory).
|
||||
|
||||
## Server AI Access
|
||||
|
||||
Aliases and extensions used from the client console still live under `~/.sliver-client/aliases` and `~/.sliver-client/extensions`.
|
||||
|
||||
The server-side AI agent uses a separate store under `~/.sliver/ai/aliases` and `~/.sliver/ai/extensions`. To make a package available to the AI agent, copy the unpacked package directory into the matching server-side path so the manifest and payload artifacts are present together. For example:
|
||||
|
||||
- `~/.sliver/ai/aliases/Rubeus/alias.json`
|
||||
- `~/.sliver/ai/aliases/Rubeus/Rubeus.exe`
|
||||
- `~/.sliver/ai/extensions/nanodump/extension.json`
|
||||
- `~/.sliver/ai/extensions/nanodump/nanodump.x64.o`
|
||||
|
||||
There is currently no dedicated UI for this workflow. The AI agent can search and execute only the packages that have been copied into these server-side directories.
|
||||
|
||||
#### Aliases Command Parsing
|
||||
|
||||
**⚠️ IMPORTANT:** It's important to understand that all alias commands have certain Sliver shell flags (those that appear in `--help`). Your Sliver shell commands are lexically parsed by the Sliver shell first, and only unnamed positional arguments are passed to the alias code. This means you may need to escape certain arguments in order for them to be correctly parsed.
|
||||
|
||||
@@ -5,8 +5,8 @@ This document describes the technical design of Sliver.
|
||||
There are four major components to the Sliver ecosystem:
|
||||
|
||||
- **Server Console -** The server console is the main interface, which is started when you run the `sliver-server` executable. The server console is a superset of the client console. All code is shared between the client/server consoles except server-specific commands related to client (i.e., operator) management. The server console talks over an in-memory gRPC interface to the server.
|
||||
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network (i.e., multiplayer mode) over mutual TLS (mTLS).
|
||||
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the "mutliplayer" gRPC network listener.
|
||||
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network in multiplayer mode, either directly over mutual TLS (mTLS) or, by default, through a WireGuard-protected wrapper that carries the same gRPC/mTLS service.
|
||||
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the multiplayer gRPC network listener.
|
||||
- **Implant -** The implant is the actual malicious code run on the target system you want remote access to.
|
||||
|
||||
```
|
||||
@@ -17,7 +17,8 @@ There are four major components to the Sliver ecosystem:
|
||||
└─────────┘ └───────────────┘
|
||||
▲
|
||||
│
|
||||
│gRPC/mTLS
|
||||
│WireGuard (default)
|
||||
│or direct gRPC/mTLS
|
||||
│
|
||||
┌────┴────┐
|
||||
│ Sliver │
|
||||
@@ -25,4 +26,4 @@ There are four major components to the Sliver ecosystem:
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
By implementing all functionality over this gRPC interface, and only differing the in-memory/mTLS connection types the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
|
||||
By implementing all functionality over this gRPC interface, and only differing the in-memory or remote transport types, the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
|
||||
@@ -4,6 +4,8 @@ The armory downloads packages from `github.com` and `api.github.com` so you'll n
|
||||
|
||||
Aliases and extensions are installed on the "sliver client"-side, and thus are not shared among operators in [multiplayer mode](/docs?name=Multi-player+Mode).
|
||||
|
||||
The server-side AI agent uses a separate store under `~/.sliver/ai/aliases` and `~/.sliver/ai/extensions`. If you want the AI agent to discover and execute an alias or extension, copy the unpacked package directory (the manifest plus its artifacts) into the matching server-side path. There is currently no dedicated UI or install command for the AI store.
|
||||
|
||||
You can also use `armory install all` to install _everything_ if you really want to.
|
||||
|
||||
## The Official Armory
|
||||
@@ -44,6 +46,8 @@ You remove packages installed from the `armory` using the `aliases rm` and `exte
|
||||
|
||||
Installed alias and extension files are stored in `~/.sliver-client/` by default if you want to manually remove a package simply delete its corresponding directory and restart the client.
|
||||
|
||||
If you also copied a package into the server-side AI store, remove that copy separately from `~/.sliver/ai/aliases` or `~/.sliver/ai/extensions`.
|
||||
|
||||
## Private Armories
|
||||
|
||||
Sliver has experimental support for self-hosted private armories, but I have not gotten around to testing and writing the documentation for these, so you'll have to read through the source code to figure out how they work for now. I'll eventually update this documentation and release a reference implementation. If you do play around with this, just know the design is subject to change before becoming a real feature.
|
||||
@@ -6,7 +6,7 @@ BOF support is provided via the [COFF Loader](https://github.com/sliverarmory/CO
|
||||
|
||||
The easiest way to install a BOF extension, for example [`nanodump`](https://github.com/sliverarmory/nanodump), is using the [armory](/docs?name=Armory) package manager:
|
||||
|
||||
**IMPORTANT:** BOF Extensions are installed per-sliver client, they are not stored on the server. Thus extensions are not shared across operators, each operator must install the extension to use it.
|
||||
**IMPORTANT:** BOF extensions are still installed per-sliver client for normal operator use, so they are not shared across operators automatically. If you want the server-side AI agent to use a BOF, copy the unpacked BOF directory into `~/.sliver/ai/extensions` on the server as well, and copy any dependency such as `coff-loader` into that same server-side AI store.
|
||||
|
||||
```
|
||||
sliver > armory install nanodump
|
||||
@@ -114,6 +114,8 @@ Looking at the script we can see the BOF requires a single integer argument. The
|
||||
|
||||
Once the manifest is defined load it into your client using `extensions load`, locally loaded extensions do not need to be cryptographically signed. The paths in the manifest should be relative to the manifest file, parent directories are not allowed.
|
||||
|
||||
If you want the server-side AI agent to discover and run the same BOF, copy that unpacked directory into `~/.sliver/ai/extensions/<name>/` so the `extension.json` file and `.o` artifact are available on the server.
|
||||
|
||||
More details can be found on the [Aliases & Extensions](/docs?name=Aliases+and+Extensions) page.
|
||||
|
||||
**IMPORTANT:** For BOF extensions to be properly detected by the Sliver client, the `path` must use the `.o` file extension.
|
||||
@@ -12,6 +12,7 @@ daemon:
|
||||
host: ""
|
||||
port: 31337
|
||||
tailscale: false
|
||||
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
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ sliver > mcp start --transport http
|
||||
|
||||
[*] Starting MCP server (http) on 127.0.0.1:8080
|
||||
[*] Endpoint: http://127.0.0.1:8080/mcp
|
||||
[*] Auth Header: Authorization
|
||||
[*] Auth Token: 6f90c3b3c6058fa59f570e281f3f8d39
|
||||
```
|
||||
|
||||
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server. Authentication is not yet supported for MCP over HTTP/SSE.
|
||||
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server.
|
||||
|
||||
On first HTTP/SSE use, Sliver generates a random 128-bit token and saves it to `~/.sliver-client/mcp.yaml`. Every MCP HTTP/SSE request must include that token exactly in the `Authorization` header. If `mcp.yaml` already exists, the listener will only start when the token is at least 8 characters long.
|
||||
@@ -1,4 +1,6 @@
|
||||
Multiplayer-mode allows multiple operators (players) to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script) which will configure the server as a systemd service. However, any Sliver server binary supports multiplayer mode.
|
||||
Multiplayer mode allows multiple operators to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script), which configures the server as a systemd service. However, any `sliver-server` binary supports multiplayer mode.
|
||||
|
||||
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`.
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,20 +9,31 @@ if [[ "$EUID" -ne 0 ]];then
|
||||
exit
|
||||
fi
|
||||
|
||||
# Only include minisign in the package list when it isn't already on PATH.
|
||||
# The minisign apt package isn't in the default Ubuntu 22.04 repos, but
|
||||
# users often install it manually to /usr/local/bin first; without this
|
||||
# guard the apt-get call below fails the whole script (issue #2239).
|
||||
MINISIGN_PKG=(minisign)
|
||||
if command -v minisign &> /dev/null; then
|
||||
MINISIGN_PKG=()
|
||||
fi
|
||||
|
||||
# Determine OS type
|
||||
# Debian-based OS (Debian, Ubuntu, etc)
|
||||
if command -v apt-get &> /dev/null; then
|
||||
echo "Installing dependencies using apt..."
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -yqq \
|
||||
minisign curl build-essential git
|
||||
INSTALLER=(apt-get install -yqq)
|
||||
"${MINISIGN_PKG[@]}" curl build-essential git
|
||||
INSTALLER=(apt-get install -yqq)
|
||||
elif command -v yum &> /dev/null; then # Redhat-based OS (Fedora, CentOS, RHEL)
|
||||
echo "Installing dependencies using yum..."
|
||||
yum -y install minisign curl make git
|
||||
INSTALLER=(yum -y)
|
||||
yum -y install "${MINISIGN_PKG[@]}" curl make git
|
||||
INSTALLER=(yum -y)
|
||||
elif command -v pacman &>/dev/null; then # Arch-based (Manjaro, Garuda, Blackarch)
|
||||
echo "Installing dependencies using pacman..."
|
||||
pacman --noconfirm -S minisign
|
||||
echo "Installing dependencies using pacman..."
|
||||
if [[ ${#MINISIGN_PKG[@]} -gt 0 ]]; then
|
||||
pacman --noconfirm -S "${MINISIGN_PKG[@]}"
|
||||
fi
|
||||
INSTALLER=(pacman --noconfirm -S)
|
||||
else
|
||||
echo "Unsupported OS, exiting"
|
||||
@@ -31,9 +42,9 @@ fi
|
||||
|
||||
# Verify if necessary tools are installed
|
||||
for cmd in curl awk minisign; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
echo "$cmd could not be found, installing..."
|
||||
${INSTALLER[@]} "$cmd"
|
||||
${INSTALLER[@]} "$cmd"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -42,6 +53,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 +118,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 +169,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 +176,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 +185,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 @@
|
||||
{"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\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\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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -10,7 +10,6 @@ replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-202
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/fantasy v0.8.1
|
||||
charm.land/glamour/v2 v2.0.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.2
|
||||
@@ -44,6 +43,7 @@ require (
|
||||
github.com/moloch--/sgn v0.0.5
|
||||
github.com/ncruces/go-sqlite3 v0.30.4
|
||||
github.com/nikoksr/notify v1.5.0
|
||||
github.com/openai/openai-go/v2 v2.7.1
|
||||
github.com/reeflective/console v0.1.25
|
||||
github.com/reeflective/readline v1.1.4
|
||||
github.com/rsteube/carapace v0.50.2
|
||||
@@ -59,13 +59,13 @@ require (
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/xlab/treeprint v1.2.0
|
||||
github.com/yiya1989/sshkrb5 v0.0.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
google.golang.org/api v0.269.0
|
||||
@@ -101,7 +101,6 @@ require (
|
||||
github.com/RocketChat/Rocket.Chat.Go.SDK v0.0.0-20250718055228-285ecf400b48 // indirect
|
||||
github.com/appleboy/go-fcm v1.2.6 // indirect
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
@@ -125,11 +124,10 @@ require (
|
||||
github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
|
||||
github.com/charmbracelet/x/json v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
@@ -154,8 +152,6 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc // indirect
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
@@ -166,12 +162,9 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/gregdel/pushover v1.4.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.3 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.9 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.6.10 // indirect
|
||||
github.com/kaptinlin/messageformat-go v0.4.9 // indirect
|
||||
github.com/kevinburke/go-types v0.0.0-20240719050749-165e75e768f7 // indirect
|
||||
github.com/kevinburke/rest v0.0.0-20250718180114-1a15e4f2364f // indirect
|
||||
github.com/kevinburke/twilio-go v0.0.0-20250718182727-5fe7adc01f29 // indirect
|
||||
@@ -186,7 +179,6 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oapi-codegen/runtime v1.1.2 // indirect
|
||||
github.com/openai/openai-go/v2 v2.7.1 // indirect
|
||||
github.com/pires/go-proxyproto v0.8.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
@@ -195,7 +187,7 @@ require (
|
||||
github.com/sendgrid/rest v2.6.9+incompatible // indirect
|
||||
github.com/sendgrid/sendgrid-go v3.16.1+incompatible // indirect
|
||||
github.com/silenceper/wechat/v2 v2.1.11 // indirect
|
||||
github.com/slack-go/slack v0.17.3 // indirect
|
||||
github.com/slack-go/slack v0.24.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
@@ -223,7 +215,6 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genai v1.49.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
gopkg.in/go-playground/validator.v9 v9.31.0 // indirect
|
||||
@@ -239,7 +230,7 @@ require (
|
||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0 // indirect
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
|
||||
@@ -281,7 +272,7 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jezek/xgb v1.1.1 // indirect
|
||||
@@ -326,7 +317,7 @@ require (
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
|
||||
@@ -8,8 +8,6 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/fantasy v0.8.1 h1:2zQovDrd26g+/X2+/6bMomTX54HmZWNNHWeorDO0WRo=
|
||||
charm.land/fantasy v0.8.1/go.mod h1:KJ8vjy9FH7G2aeR/fL+os2uFHkQ4js2+UJVbsUKCXYM=
|
||||
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
|
||||
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
@@ -122,8 +120,6 @@ github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 h1:cIAK2NNf2yafdgpF
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4/go.mod h1:SalMPBCab3yuID8nIhLfzwoBV+lBRyaC7NhuN8qL8xE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
@@ -186,10 +182,10 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
@@ -208,8 +204,6 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZx
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
|
||||
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
@@ -276,8 +270,6 @@ github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYC
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 h1:uh1GSejOhVPRQmoXZxY82TiewZB8QXiaP1skL7Nun3Y=
|
||||
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7/go.mod h1:ncTaGuXc5v7AuiVekeJ0Nwh8Bf4cudukoj0qM/15UZE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -354,8 +346,6 @@ github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaEL
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
|
||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
@@ -364,8 +354,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
|
||||
@@ -467,8 +455,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
|
||||
@@ -494,14 +482,6 @@ github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5G
|
||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk8Q=
|
||||
github.com/kaptinlin/go-i18n v0.2.3/go.mod h1:O+Ax4HkMO0Jt4OaP4E4WCx0PAADeWkwk8Jgt9bjAU1w=
|
||||
github.com/kaptinlin/jsonpointer v0.4.9 h1:o//bYf4PCvnMJIIX8bIg77KB6DO3wBPAabRyPRKh680=
|
||||
github.com/kaptinlin/jsonpointer v0.4.9/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
|
||||
github.com/kaptinlin/jsonschema v0.6.10 h1:CYded7nrwVu7pU1GaIjtd9dSzgqZjh7+LTKFaWqS08I=
|
||||
github.com/kaptinlin/jsonschema v0.6.10/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8=
|
||||
github.com/kaptinlin/messageformat-go v0.4.9 h1:FR5j5n4aL4nG0afKn9vvANrKxLu7HjmbhJnw5ogIwAQ=
|
||||
github.com/kaptinlin/messageformat-go v0.4.9/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
|
||||
@@ -551,8 +531,6 @@ github.com/mailgun/mailgun-go/v5 v5.8.1 h1:QoRa2oBqLq+8GTYgM4id/cKW/WySQRka3PtnW
|
||||
github.com/mailgun/mailgun-go/v5 v5.8.1/go.mod h1:qNTXXuJi9/myqpDLI8Mbn54WCXdto1kEHm6I2/WWYQQ=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
|
||||
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
|
||||
github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
@@ -645,8 +623,6 @@ github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zp
|
||||
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
@@ -698,8 +674,8 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/slack-go/slack v0.24.0 h1:oMz5WcCBVTkxpBxeA8QVxxK70R+XV/a0qNka6TRNGHQ=
|
||||
github.com/slack-go/slack v0.24.0/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs=
|
||||
github.com/sliverarmory/beignet v0.0.3 h1:ssR6AK9lirVulHZhQMyMmVAi0FcFwWxlh9LwKCJQ4X0=
|
||||
github.com/sliverarmory/beignet v0.0.3/go.mod h1:xXfTKBJqFBY/pakRFwJ+5+oGiSlg2CiCVUg/76kW1H4=
|
||||
github.com/sliverarmory/malasada v0.0.3 h1:b2KG2NhQVfBIIrbP8rGJwmdh7o8LH6NKYKH1DF4No1U=
|
||||
@@ -868,10 +844,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
@@ -890,8 +864,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20171115151908-9dfe39835686/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -913,10 +887,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@@ -933,8 +905,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -975,8 +945,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -986,10 +956,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -1003,10 +971,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -1023,8 +989,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -1045,8 +1011,6 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
|
||||
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
@@ -1054,8 +1018,6 @@ google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0Ij
|
||||
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
@@ -1063,8 +1025,6 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -119,17 +119,20 @@ func pivotStopListenerHandler(envelope *pb.Envelope, connection *transports.Conn
|
||||
}
|
||||
|
||||
func pivotPeerEnvelopeHandler(envelope *pb.Envelope, connection *transports.Connection) {
|
||||
sent, err := pivots.SendToPeer(envelope)
|
||||
nextPeerID, sent, err := pivots.SendToPeer(envelope)
|
||||
if !sent {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("Send to peer failed, report peer failure upstream ...")
|
||||
// {{end}}
|
||||
if nextPeerID == 0 {
|
||||
return
|
||||
}
|
||||
errStr := ""
|
||||
if err != nil {
|
||||
errStr = err.Error()
|
||||
}
|
||||
data, _ := proto.Marshal(&pb.PivotPeerFailure{
|
||||
PeerID: pivots.MyPeerID,
|
||||
PeerID: nextPeerID,
|
||||
Type: pb.PeerFailureType_SEND_FAILURE,
|
||||
Err: errStr,
|
||||
})
|
||||
|
||||
@@ -145,14 +145,14 @@ func StopListener(id uint32) {
|
||||
}
|
||||
|
||||
// SendToPeer - Forward an envelope to a peer
|
||||
func SendToPeer(envelope *pb.Envelope) (bool, error) {
|
||||
func SendToPeer(envelope *pb.Envelope) (int64, bool, error) {
|
||||
pivotPeerEnvelope := &pb.PivotPeerEnvelope{}
|
||||
err := proto.Unmarshal(envelope.Data, pivotPeerEnvelope)
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("Failed to unmarshal peer envelope: %s", err)
|
||||
// {{end}}
|
||||
return false, err
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
@@ -164,7 +164,7 @@ func SendToPeer(envelope *pb.Envelope) (bool, error) {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("Failed to find next peer id: %s", err)
|
||||
// {{end}}
|
||||
return false, err
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
sent := false // Controls iteration of outer loop
|
||||
@@ -183,9 +183,9 @@ func SendToPeer(envelope *pb.Envelope) (bool, error) {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("Failed to find peer with id %d", nextPeerID)
|
||||
// {{end}}
|
||||
return false, errors.New("peer not found")
|
||||
return nextPeerID, false, errors.New("peer not found")
|
||||
}
|
||||
return sent, nil
|
||||
return nextPeerID, sent, nil
|
||||
}
|
||||
|
||||
func findNextPeerID(pivotPeerEnvelope *pb.PivotPeerEnvelope) (int64, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package version
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2021 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type linuxVersionInfo struct {
|
||||
Sysname string
|
||||
Release string
|
||||
Version string
|
||||
Machine string
|
||||
}
|
||||
|
||||
func readLinuxOSRelease() string {
|
||||
paths := []string{"/etc/os-release", "/usr/lib/os-release"}
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if osRelease := buildLinuxOSRelease(parseLinuxOSRelease(string(data))); osRelease != "" {
|
||||
return osRelease
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseLinuxOSRelease(content string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok || key == "" {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = unescapeLinuxOSReleaseValue(unquoteLinuxOSReleaseValue(strings.TrimSpace(value)))
|
||||
values[key] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func unquoteLinuxOSReleaseValue(s string) string {
|
||||
if len(s) < 2 {
|
||||
return s
|
||||
}
|
||||
if (s[0] == '"' || s[0] == '\'') && s[0] == s[len(s)-1] {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func unescapeLinuxOSReleaseValue(s string) string {
|
||||
return strings.NewReplacer(
|
||||
`\$`, `$`,
|
||||
`\"`, `"`,
|
||||
`\'`, `'`,
|
||||
`\\`, `\`,
|
||||
"\\`", "`",
|
||||
).Replace(s)
|
||||
}
|
||||
|
||||
func buildLinuxOSRelease(values map[string]string) string {
|
||||
name := values["NAME"]
|
||||
version := values["VERSION"]
|
||||
if version == "" {
|
||||
version = values["VERSION_ID"]
|
||||
}
|
||||
if name != "" && version != "" {
|
||||
return fmt.Sprintf("%s %s", name, version)
|
||||
}
|
||||
if values["PRETTY_NAME"] != "" {
|
||||
return values["PRETTY_NAME"]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func normalizeLinuxArch(arch string) string {
|
||||
arch = strings.TrimSpace(arch)
|
||||
if arch == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(arch) {
|
||||
case "amd64":
|
||||
return "x86_64"
|
||||
case "i386", "i486", "i586", "i686", "x86":
|
||||
return "x86"
|
||||
default:
|
||||
return arch
|
||||
}
|
||||
}
|
||||
|
||||
func formatLinuxDetailedVersion(osRelease string, versionInfo linuxVersionInfo) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if osRelease != "" {
|
||||
parts = append(parts, osRelease)
|
||||
} else if versionInfo.Sysname != "" {
|
||||
parts = append(parts, versionInfo.Sysname)
|
||||
}
|
||||
|
||||
kernelParts := make([]string, 0, 2)
|
||||
if versionInfo.Release != "" {
|
||||
kernelParts = append(kernelParts, versionInfo.Release)
|
||||
}
|
||||
if versionInfo.Version != "" {
|
||||
kernelParts = append(kernelParts, versionInfo.Version)
|
||||
}
|
||||
if len(kernelParts) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("kernel %s", strings.Join(kernelParts, " ")))
|
||||
}
|
||||
|
||||
if arch := normalizeLinuxArch(versionInfo.Machine); arch != "" {
|
||||
parts = append(parts, arch)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseLinuxOSRelease(t *testing.T) {
|
||||
content := "# comment\n" +
|
||||
"NAME=\"Ubuntu\"\n" +
|
||||
"VERSION=\"24.04.2 LTS (Noble Numbat)\"\n" +
|
||||
"PRETTY_NAME=\"Ubuntu 24.04.2 LTS\"\n" +
|
||||
"SPECIAL=\"quoted \\\"value\\\" \\$HOME \\`uname\\`\"\n"
|
||||
|
||||
values := parseLinuxOSRelease(content)
|
||||
|
||||
if got, want := values["NAME"], "Ubuntu"; got != want {
|
||||
t.Fatalf("NAME = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := values["VERSION"], "24.04.2 LTS (Noble Numbat)"; got != want {
|
||||
t.Fatalf("VERSION = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := values["PRETTY_NAME"], "Ubuntu 24.04.2 LTS"; got != want {
|
||||
t.Fatalf("PRETTY_NAME = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := values["SPECIAL"], "quoted \"value\" $HOME `uname`"; got != want {
|
||||
t.Fatalf("SPECIAL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinuxOSRelease(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers name and version",
|
||||
values: map[string]string{
|
||||
"NAME": "Ubuntu",
|
||||
"VERSION": "24.04.2 LTS (Noble Numbat)",
|
||||
"PRETTY_NAME": "Ubuntu 24.04.2 LTS",
|
||||
},
|
||||
want: "Ubuntu 24.04.2 LTS (Noble Numbat)",
|
||||
},
|
||||
{
|
||||
name: "falls back to version id",
|
||||
values: map[string]string{
|
||||
"NAME": "Alpine Linux",
|
||||
"VERSION_ID": "3.21.3",
|
||||
},
|
||||
want: "Alpine Linux 3.21.3",
|
||||
},
|
||||
{
|
||||
name: "falls back to pretty name",
|
||||
values: map[string]string{
|
||||
"PRETTY_NAME": "Fedora Linux 41 (Workstation Edition)",
|
||||
},
|
||||
want: "Fedora Linux 41 (Workstation Edition)",
|
||||
},
|
||||
{
|
||||
name: "falls back to name",
|
||||
values: map[string]string{
|
||||
"NAME": "Linux",
|
||||
},
|
||||
want: "Linux",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := buildLinuxOSRelease(testCase.values); got != testCase.want {
|
||||
t.Fatalf("buildLinuxOSRelease() = %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLinuxDetailedVersion(t *testing.T) {
|
||||
version := formatLinuxDetailedVersion("Ubuntu 24.04.2 LTS (Noble Numbat)", linuxVersionInfo{
|
||||
Release: "6.8.0-57-generic",
|
||||
Version: "#59-Ubuntu SMP PREEMPT_DYNAMIC Fri Feb 14 18:29:04 UTC 2025",
|
||||
Machine: "amd64",
|
||||
})
|
||||
|
||||
want := "Ubuntu 24.04.2 LTS (Noble Numbat) kernel 6.8.0-57-generic #59-Ubuntu SMP PREEMPT_DYNAMIC Fri Feb 14 18:29:04 UTC 2025 x86_64"
|
||||
if version != want {
|
||||
t.Fatalf("formatLinuxDetailedVersion() = %q, want %q", version, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLinuxDetailedVersionFallback(t *testing.T) {
|
||||
version := formatLinuxDetailedVersion("", linuxVersionInfo{
|
||||
Sysname: "Linux",
|
||||
Release: "6.12.0",
|
||||
Machine: "i686",
|
||||
})
|
||||
|
||||
want := "Linux kernel 6.12.0 x86"
|
||||
if version != want {
|
||||
t.Fatalf("formatLinuxDetailedVersion() = %q, want %q", version, want)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin && !(windows && (amd64 || 386)) && !(linux && (amd64 || 386))
|
||||
//go:build !darwin && !linux && !(windows && (amd64 || 386))
|
||||
|
||||
package version
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build amd64 || 386
|
||||
//go:build linux
|
||||
|
||||
package version
|
||||
|
||||
@@ -21,132 +21,36 @@ package version
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"bytes"
|
||||
//{{if .Config.Debug}}
|
||||
"log"
|
||||
//{{end}}
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func getString(input [65]int8) string {
|
||||
var buf [65]byte
|
||||
for i, b := range input {
|
||||
buf[i] = byte(b)
|
||||
func linuxNullTerminatedString(input []byte) string {
|
||||
if index := bytes.IndexByte(input, 0); index >= 0 {
|
||||
input = input[:index]
|
||||
}
|
||||
ver := string(buf[:])
|
||||
if i := strings.Index(ver, "\x00"); i != -1 {
|
||||
ver = ver[:i]
|
||||
}
|
||||
return ver
|
||||
}
|
||||
|
||||
func readOSRelease() string {
|
||||
paths := []string{"/etc/os-release", "/usr/lib/os-release"}
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return buildOSRelease(parseOSRelease(string(data)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseOSRelease(content string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok || key == "" {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = unescape(unquote(strings.TrimSpace(value)))
|
||||
values[key] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
if len(s) < 2 {
|
||||
return s
|
||||
}
|
||||
if (s[0] == '"' || s[0] == '\'') && s[0] == s[len(s)-1] {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func unescape(s string) string {
|
||||
return strings.NewReplacer(
|
||||
`\$`, `$`,
|
||||
`\"`, `"`,
|
||||
`\'`, `'`,
|
||||
`\\`, `\`,
|
||||
"\\`", "`",
|
||||
).Replace(s)
|
||||
}
|
||||
|
||||
func buildOSRelease(values map[string]string) string {
|
||||
name := values["NAME"]
|
||||
version := values["VERSION"]
|
||||
if version == "" {
|
||||
version = values["VERSION_ID"]
|
||||
}
|
||||
if name != "" && version != "" {
|
||||
return fmt.Sprintf("%s %s", name, version)
|
||||
}
|
||||
return values["PRETTY_NAME"]
|
||||
}
|
||||
|
||||
func normalizeArch(arch string) string {
|
||||
arch = strings.TrimSpace(arch)
|
||||
if arch == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(arch) {
|
||||
case "amd64":
|
||||
return "x86_64"
|
||||
case "i386", "i486", "i586", "i686", "x86":
|
||||
return "x86"
|
||||
default:
|
||||
return arch
|
||||
}
|
||||
}
|
||||
|
||||
func formatDetailedVersion(osRelease, kernel, arch string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if osRelease != "" {
|
||||
parts = append(parts, osRelease)
|
||||
}
|
||||
if kernel != "" {
|
||||
parts = append(parts, fmt.Sprintf("kernel %s", kernel))
|
||||
}
|
||||
if arch != "" {
|
||||
parts = append(parts, arch)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
return string(input)
|
||||
}
|
||||
|
||||
// GetVersion returns the os version information
|
||||
func GetVersion() string {
|
||||
osRelease := readOSRelease()
|
||||
var uname syscall.Utsname
|
||||
if err := syscall.Uname(&uname); err != nil {
|
||||
osRelease := readLinuxOSRelease()
|
||||
var uname unix.Utsname
|
||||
if err := unix.Uname(&uname); err != nil {
|
||||
//{{if .Config.Debug}}
|
||||
log.Printf("error getting OS version: %v", err)
|
||||
//{{end}}
|
||||
return osRelease
|
||||
}
|
||||
if osRelease == "" {
|
||||
osRelease = getString(uname.Sysname)
|
||||
}
|
||||
kernel := getString(uname.Release)
|
||||
arch := normalizeArch(getString(uname.Machine))
|
||||
return formatDetailedVersion(osRelease, kernel, arch)
|
||||
|
||||
return formatLinuxDetailedVersion(osRelease, linuxVersionInfo{
|
||||
Sysname: linuxNullTerminatedString(uname.Sysname[:]),
|
||||
Release: linuxNullTerminatedString(uname.Release[:]),
|
||||
Version: linuxNullTerminatedString(uname.Version[:]),
|
||||
Machine: linuxNullTerminatedString(uname.Machine[:]),
|
||||
})
|
||||
}
|
||||
+1000
-357
File diff suppressed because it is too large.
Load diff
@@ -1426,6 +1426,7 @@ message AIConfigSummary {
|
||||
string ThinkingLevel = 3;
|
||||
bool Valid = 4;
|
||||
string Error = 5;
|
||||
string SystemPrompt = 6;
|
||||
}
|
||||
|
||||
message AIConversationReq {
|
||||
@@ -1437,6 +1438,49 @@ message AIConversations {
|
||||
repeated AIConversation Conversations = 1;
|
||||
}
|
||||
|
||||
enum AIConversationTurnState {
|
||||
AI_TURN_STATE_IDLE = 0;
|
||||
AI_TURN_STATE_IN_PROGRESS = 1;
|
||||
AI_TURN_STATE_FAILED = 2;
|
||||
}
|
||||
|
||||
enum AIConversationMessageKind {
|
||||
AI_MESSAGE_KIND_CHAT = 0;
|
||||
AI_MESSAGE_KIND_REASONING = 1;
|
||||
AI_MESSAGE_KIND_TOOL_CALL = 2;
|
||||
}
|
||||
|
||||
enum AIConversationMessageVisibility {
|
||||
AI_MESSAGE_VISIBILITY_CONTEXT = 0;
|
||||
AI_MESSAGE_VISIBILITY_UI_ONLY = 1;
|
||||
}
|
||||
|
||||
enum AIConversationMessageState {
|
||||
AI_MESSAGE_STATE_COMPLETED = 0;
|
||||
AI_MESSAGE_STATE_IN_PROGRESS = 1;
|
||||
AI_MESSAGE_STATE_FAILED = 2;
|
||||
}
|
||||
|
||||
enum AIConversationEventType {
|
||||
AI_CONVERSATION_EVENT_TYPE_UNKNOWN = 0;
|
||||
AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED = 1;
|
||||
AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED = 2;
|
||||
AI_CONVERSATION_EVENT_TYPE_TURN_STARTED = 3;
|
||||
AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED = 4;
|
||||
AI_CONVERSATION_EVENT_TYPE_TURN_FAILED = 5;
|
||||
AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED = 6;
|
||||
AI_CONVERSATION_EVENT_TYPE_MESSAGE_UPDATED = 7;
|
||||
AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED = 8;
|
||||
}
|
||||
|
||||
message AIContextWindowUsage {
|
||||
int64 InputTokens = 1;
|
||||
int64 OutputTokens = 2;
|
||||
int64 TotalTokens = 3;
|
||||
int64 ContextWindowTokens = 4;
|
||||
bool ContextWindowTokensEstimated = 5;
|
||||
}
|
||||
|
||||
message AIConversation {
|
||||
string ID = 1;
|
||||
int64 CreatedAt = 2;
|
||||
@@ -1448,6 +1492,12 @@ message AIConversation {
|
||||
string Summary = 8;
|
||||
string SystemPrompt = 9;
|
||||
repeated AIConversationMessage Messages = 10;
|
||||
string ActiveTurnID = 11;
|
||||
AIConversationTurnState TurnState = 12;
|
||||
string TargetSessionID = 13;
|
||||
string TargetBeaconID = 14;
|
||||
string ThinkingLevel = 15;
|
||||
AIContextWindowUsage ContextWindowUsage = 16;
|
||||
}
|
||||
|
||||
message AIConversationMessages {
|
||||
@@ -1468,6 +1518,26 @@ message AIConversationMessage {
|
||||
string Content = 10;
|
||||
string ProviderMessageID = 11;
|
||||
string FinishReason = 12;
|
||||
AIConversationMessageKind Kind = 13;
|
||||
AIConversationMessageVisibility Visibility = 14;
|
||||
AIConversationMessageState State = 15;
|
||||
string TurnID = 16;
|
||||
string ItemID = 17;
|
||||
string ToolCallID = 18;
|
||||
string ToolName = 19;
|
||||
string ToolArguments = 20;
|
||||
string ToolResult = 21;
|
||||
string ErrorText = 22;
|
||||
optional bool IncludeInContext = 23;
|
||||
}
|
||||
|
||||
message AIConversationEvent {
|
||||
AIConversationEventType EventType = 1;
|
||||
string ConversationID = 2;
|
||||
string TurnID = 3;
|
||||
string ErrorText = 4;
|
||||
AIConversation Conversation = 5;
|
||||
AIConversationMessage Message = 6;
|
||||
}
|
||||
|
||||
// watchtower
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.34.0
|
||||
// protoc v7.34.1
|
||||
// source: commonpb/common.proto
|
||||
|
||||
package commonpb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.34.0
|
||||
// protoc v7.34.1
|
||||
// source: dnspb/dns.proto
|
||||
|
||||
package dnspb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.34.0
|
||||
// protoc v7.34.1
|
||||
// source: rpcpb/services.proto
|
||||
|
||||
package rpcpb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v7.34.0
|
||||
// - protoc v7.34.1
|
||||
// source: rpcpb/services.proto
|
||||
|
||||
package rpcpb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.34.0
|
||||
// protoc v7.34.1
|
||||
// source: sliverpb/sliver.proto
|
||||
|
||||
package sliverpb
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
openai "github.com/openai/openai-go/v2"
|
||||
openairesponses "github.com/openai/openai-go/v2/responses"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
const maxAgenticLoopIterations = 100
|
||||
|
||||
// AgenticToolDefinition describes a custom function tool exposed to the model.
|
||||
type AgenticToolDefinition struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters map[string]any
|
||||
}
|
||||
|
||||
// AgenticToolExecutor executes model-requested tools for a conversation turn.
|
||||
type AgenticToolExecutor interface {
|
||||
ToolDefinitions() []AgenticToolDefinition
|
||||
CallTool(context.Context, string, string) (string, error)
|
||||
}
|
||||
|
||||
// AgenticEventSink persists and publishes non-context-window UI items.
|
||||
type AgenticEventSink interface {
|
||||
ChatMessage(context.Context, AgenticChatMessage) error
|
||||
ReasoningItem(context.Context, AgenticReasoningItem) error
|
||||
ToolCallStarted(context.Context, AgenticToolCall) error
|
||||
ToolCallCompleted(context.Context, AgenticToolCallResult) error
|
||||
}
|
||||
|
||||
// AgenticChatMessage is a provider-emitted assistant or system chat block.
|
||||
type AgenticChatMessage struct {
|
||||
ItemID string
|
||||
Role string
|
||||
Content string
|
||||
Status string
|
||||
UIOnly bool
|
||||
IncludeInContext bool
|
||||
}
|
||||
|
||||
// AgenticReasoningItem is a completed reasoning item emitted by the provider.
|
||||
type AgenticReasoningItem struct {
|
||||
ItemID string
|
||||
Summary string
|
||||
Content string
|
||||
EncryptedContent string
|
||||
Status string
|
||||
}
|
||||
|
||||
// AgenticToolCall is a provider-emitted function tool call.
|
||||
type AgenticToolCall struct {
|
||||
ItemID string
|
||||
CallID string
|
||||
Name string
|
||||
Arguments string
|
||||
Status string
|
||||
}
|
||||
|
||||
// AgenticToolCallResult is the resolved tool call state after local execution.
|
||||
type AgenticToolCallResult struct {
|
||||
AgenticToolCall
|
||||
Output string
|
||||
Error error
|
||||
}
|
||||
|
||||
// SupportsAgenticConversation reports whether the configured runtime can run the
|
||||
// Responses API tool loop used by the Sliver AI chat.
|
||||
func SupportsAgenticConversation(runtime *RuntimeConfig) bool {
|
||||
if runtime == nil || !runtime.UseResponsesAPI {
|
||||
return false
|
||||
}
|
||||
|
||||
switch NormalizeProviderName(runtime.Provider) {
|
||||
case ProviderOpenAI, ProviderOpenAICompat, ProviderOpenRouter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteConversationAgentic runs a Codex-style Responses API loop that emits
|
||||
// reasoning and tool-call items between the user prompt and the final assistant
|
||||
// reply. The returned completion is the assistant message that should be stored
|
||||
// in the conversation context window.
|
||||
func CompleteConversationAgentic(
|
||||
ctx context.Context,
|
||||
runtime *RuntimeConfig,
|
||||
conversation *clientpb.AIConversation,
|
||||
tools AgenticToolExecutor,
|
||||
sink AgenticEventSink,
|
||||
) (*Completion, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
if conversation == nil {
|
||||
return nil, fmt.Errorf("AI conversation is required")
|
||||
}
|
||||
if !SupportsAgenticConversation(runtime) {
|
||||
return nil, fmt.Errorf("AI provider %q does not support the agentic Responses API loop", runtime.Provider)
|
||||
}
|
||||
|
||||
systemPrompt, messages, err := conversationHistory(conversation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := newOpenAIClient(openAIRequestOptions(runtime)...)
|
||||
currentInput := buildResponseInput(systemPrompt, messages)
|
||||
toolParams := appendOpenAIResponseTools(runtime, buildAgenticToolParams(tools))
|
||||
previousResponseID := ""
|
||||
var maxUsage *ContextWindowUsage
|
||||
|
||||
for step := 0; step < maxAgenticLoopIterations; step++ {
|
||||
params := openairesponses.ResponseNewParams{
|
||||
Input: openairesponses.ResponseNewParamsInputUnion{
|
||||
OfInputItemList: currentInput,
|
||||
},
|
||||
Model: shared.ResponsesModel(runtime.Model),
|
||||
}
|
||||
if previousResponseID != "" {
|
||||
params.PreviousResponseID = openai.String(previousResponseID)
|
||||
}
|
||||
if len(toolParams) > 0 {
|
||||
params.Tools = toolParams
|
||||
params.ParallelToolCalls = openai.Bool(false)
|
||||
}
|
||||
if runtime.MaxOutputTokens > 0 {
|
||||
params.MaxOutputTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
}
|
||||
if runtime.Temperature != nil {
|
||||
params.Temperature = openai.Float(*runtime.Temperature)
|
||||
}
|
||||
if runtime.TopP != nil {
|
||||
params.TopP = openai.Float(*runtime.TopP)
|
||||
}
|
||||
if reasoning, ok := responseReasoningParam(runtime); ok {
|
||||
params.Reasoning = reasoning
|
||||
}
|
||||
|
||||
response, err := client.responses.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, formatOpenAIError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
maxUsage = mergeContextWindowUsage(maxUsage, resolveContextWindowUsage(
|
||||
ctx,
|
||||
runtime,
|
||||
fallbackString(response.Model, runtime.Model),
|
||||
response.Usage.InputTokens,
|
||||
response.Usage.OutputTokens,
|
||||
response.Usage.TotalTokens,
|
||||
))
|
||||
previousResponseID = strings.TrimSpace(response.ID)
|
||||
nextInput := make(openairesponses.ResponseInputParam, 0)
|
||||
finalTexts := make([]string, 0, len(response.Output))
|
||||
chatItems := make([]AgenticChatMessage, 0)
|
||||
|
||||
for _, item := range response.Output {
|
||||
switch output := item.AsAny().(type) {
|
||||
case openairesponses.ResponseReasoningItem:
|
||||
if sink != nil {
|
||||
if err := sink.ReasoningItem(ctx, AgenticReasoningItem{
|
||||
ItemID: strings.TrimSpace(output.ID),
|
||||
Summary: responseReasoningSummary(output),
|
||||
Content: responseReasoningContent(output),
|
||||
EncryptedContent: strings.TrimSpace(output.EncryptedContent),
|
||||
Status: strings.TrimSpace(string(output.Status)),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
case openairesponses.ResponseFunctionToolCall:
|
||||
toolCall := AgenticToolCall{
|
||||
ItemID: strings.TrimSpace(output.ID),
|
||||
CallID: strings.TrimSpace(output.CallID),
|
||||
Name: strings.TrimSpace(output.Name),
|
||||
Arguments: strings.TrimSpace(output.Arguments),
|
||||
Status: strings.TrimSpace(string(output.Status)),
|
||||
}
|
||||
if sink != nil {
|
||||
if err := sink.ToolCallStarted(ctx, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
toolOutput, toolErr := agenticToolOutput(ctx, tools, toolCall)
|
||||
if sink != nil {
|
||||
if err := sink.ToolCallCompleted(ctx, AgenticToolCallResult{
|
||||
AgenticToolCall: toolCall,
|
||||
Output: toolOutput,
|
||||
Error: toolErr,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
nextInput = append(nextInput, openairesponses.ResponseInputItemParamOfFunctionCallOutput(toolCall.CallID, toolOutput))
|
||||
|
||||
case openairesponses.ResponseOutputMessage:
|
||||
if text := strings.TrimSpace(responseOutputMessageText(output)); text != "" {
|
||||
chatItems = append(chatItems, AgenticChatMessage{
|
||||
ItemID: strings.TrimSpace(output.ID),
|
||||
Role: strings.TrimSpace(string(output.Role)),
|
||||
Content: text,
|
||||
Status: strings.TrimSpace(string(output.Status)),
|
||||
IncludeInContext: true,
|
||||
})
|
||||
finalTexts = append(finalTexts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(nextInput) > 0 {
|
||||
if sink != nil {
|
||||
for _, chatItem := range chatItems {
|
||||
chatItem.UIOnly = true
|
||||
chatItem.IncludeInContext = false
|
||||
if err := sink.ChatMessage(ctx, chatItem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
currentInput = nextInput
|
||||
continue
|
||||
}
|
||||
|
||||
finalText := joinTextBlocks(finalTexts)
|
||||
if finalText == "" {
|
||||
finalText = strings.TrimSpace(response.OutputText())
|
||||
}
|
||||
if finalText == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
return &Completion{
|
||||
Provider: runtime.Provider,
|
||||
Model: fallbackString(response.Model, runtime.Model),
|
||||
Content: finalText,
|
||||
ProviderMessageID: strings.TrimSpace(response.ID),
|
||||
FinishReason: responseFinishReason(response),
|
||||
ContextWindowUsage: maxUsage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("AI agent loop exceeded %d provider steps", maxAgenticLoopIterations)
|
||||
}
|
||||
|
||||
func buildAgenticToolParams(tools AgenticToolExecutor) []openairesponses.ToolUnionParam {
|
||||
if tools == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
definitions := tools.ToolDefinitions()
|
||||
if len(definitions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
params := make([]openairesponses.ToolUnionParam, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
name := strings.TrimSpace(definition.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
tool := openairesponses.ToolParamOfFunction(name, normalizeOpenAIStrictSchema(definition.Parameters), true)
|
||||
if description := strings.TrimSpace(definition.Description); description != "" && tool.OfFunction != nil {
|
||||
tool.OfFunction.Description = openai.String(description)
|
||||
}
|
||||
params = append(params, tool)
|
||||
}
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func normalizeOpenAIStrictSchema(schema map[string]any) map[string]any {
|
||||
if len(schema) == 0 {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"required": []string{},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
cloned, ok := cloneJSONValue(schema).(map[string]any)
|
||||
if !ok || cloned == nil {
|
||||
return schema
|
||||
}
|
||||
|
||||
normalizeOpenAIStrictSchemaNode(cloned)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeOpenAIStrictSchemaNode(node map[string]any) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if properties, ok := schemaProperties(node); ok {
|
||||
required := schemaRequiredSet(node["required"])
|
||||
names := make([]string, 0, len(properties))
|
||||
for name, rawProperty := range properties {
|
||||
names = append(names, name)
|
||||
|
||||
property, ok := rawProperty.(map[string]any)
|
||||
if !ok || property == nil {
|
||||
continue
|
||||
}
|
||||
if !required[name] {
|
||||
makeJSONSchemaNullable(property)
|
||||
}
|
||||
normalizeOpenAIStrictSchemaNode(property)
|
||||
}
|
||||
sort.Strings(names)
|
||||
node["required"] = names
|
||||
if _, exists := node["additionalProperties"]; !exists {
|
||||
node["additionalProperties"] = false
|
||||
}
|
||||
}
|
||||
|
||||
if items, ok := node["items"].(map[string]any); ok {
|
||||
normalizeOpenAIStrictSchemaNode(items)
|
||||
}
|
||||
|
||||
for _, key := range []string{"anyOf", "allOf", "oneOf"} {
|
||||
variants, ok := node[key].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, rawVariant := range variants {
|
||||
variant, ok := rawVariant.(map[string]any)
|
||||
if !ok || variant == nil {
|
||||
continue
|
||||
}
|
||||
normalizeOpenAIStrictSchemaNode(variant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func schemaProperties(node map[string]any) (map[string]any, bool) {
|
||||
if node == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
rawProperties, ok := node["properties"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
properties, ok := rawProperties.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return properties, true
|
||||
}
|
||||
|
||||
func schemaRequiredSet(raw any) map[string]bool {
|
||||
required := map[string]bool{}
|
||||
switch values := raw.(type) {
|
||||
case []string:
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
required[value] = true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, rawValue := range values {
|
||||
value, ok := rawValue.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
required[value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
func makeJSONSchemaNullable(node map[string]any) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch value := node["type"].(type) {
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && value != "null" {
|
||||
node["type"] = []string{value, "null"}
|
||||
}
|
||||
case []string:
|
||||
if !stringSliceContains(value, "null") {
|
||||
node["type"] = append(append([]string(nil), value...), "null")
|
||||
}
|
||||
case []any:
|
||||
if !anyStringSliceContains(value, "null") {
|
||||
node["type"] = append(append([]any(nil), value...), "null")
|
||||
}
|
||||
default:
|
||||
if _, ok := node["anyOf"]; ok {
|
||||
appendSchemaVariant(node, "anyOf", map[string]any{"type": "null"})
|
||||
} else if _, ok := node["properties"]; ok {
|
||||
node["type"] = []string{"object", "null"}
|
||||
} else if _, ok := node["items"]; ok {
|
||||
node["type"] = []string{"array", "null"}
|
||||
}
|
||||
}
|
||||
|
||||
if enum, ok := nullableEnum(node["enum"]); ok {
|
||||
node["enum"] = enum
|
||||
}
|
||||
}
|
||||
|
||||
func appendSchemaVariant(node map[string]any, key string, variant map[string]any) {
|
||||
if node == nil || variant == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rawVariants, ok := node[key].([]any)
|
||||
if !ok {
|
||||
node[key] = []any{variant}
|
||||
return
|
||||
}
|
||||
for _, rawVariant := range rawVariants {
|
||||
existing, ok := rawVariant.(map[string]any)
|
||||
if !ok || existing == nil {
|
||||
continue
|
||||
}
|
||||
if len(existing) == len(variant) {
|
||||
matched := true
|
||||
for variantKey, variantValue := range variant {
|
||||
if existing[variantKey] != variantValue {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
node[key] = append(rawVariants, variant)
|
||||
}
|
||||
|
||||
func nullableEnum(raw any) (any, bool) {
|
||||
switch values := raw.(type) {
|
||||
case []any:
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
return values, true
|
||||
}
|
||||
}
|
||||
return append(append([]any(nil), values...), nil), true
|
||||
case []string:
|
||||
enum := make([]any, 0, len(values)+1)
|
||||
for _, value := range values {
|
||||
enum = append(enum, value)
|
||||
}
|
||||
enum = append(enum, nil)
|
||||
return enum, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anyStringSliceContains(values []any, needle string) bool {
|
||||
for _, rawValue := range values {
|
||||
value, ok := rawValue.(string)
|
||||
if ok && strings.TrimSpace(value) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, nested := range typed {
|
||||
cloned[key] = cloneJSONValue(nested)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for idx, nested := range typed {
|
||||
cloned[idx] = cloneJSONValue(nested)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
cloned := make([]string, len(typed))
|
||||
copy(cloned, typed)
|
||||
return cloned
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
func responseOutputMessageText(message openairesponses.ResponseOutputMessage) string {
|
||||
parts := make([]string, 0, len(message.Content))
|
||||
for _, content := range message.Content {
|
||||
switch part := content.AsAny().(type) {
|
||||
case openairesponses.ResponseOutputText:
|
||||
if text := strings.TrimSpace(part.Text); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
case openairesponses.ResponseOutputRefusal:
|
||||
if refusal := strings.TrimSpace(part.Refusal); refusal != "" {
|
||||
parts = append(parts, refusal)
|
||||
}
|
||||
}
|
||||
}
|
||||
return joinTextBlocks(parts)
|
||||
}
|
||||
|
||||
func responseReasoningSummary(item openairesponses.ResponseReasoningItem) string {
|
||||
summaries := make([]string, 0, len(item.Summary))
|
||||
for _, summary := range item.Summary {
|
||||
if text := strings.TrimSpace(summary.Text); text != "" {
|
||||
summaries = append(summaries, text)
|
||||
}
|
||||
}
|
||||
return joinTextBlocks(summaries)
|
||||
}
|
||||
|
||||
func responseReasoningContent(item openairesponses.ResponseReasoningItem) string {
|
||||
content := make([]string, 0, len(item.Content))
|
||||
for _, block := range item.Content {
|
||||
if text := strings.TrimSpace(block.Text); text != "" {
|
||||
content = append(content, text)
|
||||
}
|
||||
}
|
||||
return joinTextBlocks(content)
|
||||
}
|
||||
|
||||
func agenticToolOutput(ctx context.Context, tools AgenticToolExecutor, toolCall AgenticToolCall) (string, error) {
|
||||
if tools == nil {
|
||||
err := fmt.Errorf("tool executor is not configured")
|
||||
return agenticToolErrorJSON(err), err
|
||||
}
|
||||
|
||||
output, err := tools.CallTool(ctx, toolCall.Name, toolCall.Arguments)
|
||||
if err != nil {
|
||||
return agenticToolErrorJSON(err), err
|
||||
}
|
||||
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
empty, marshalErr := json.Marshal(map[string]any{"ok": true})
|
||||
if marshalErr != nil {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
return string(empty), nil
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func agenticToolErrorJSON(err error) string {
|
||||
payload := map[string]any{
|
||||
"ok": false,
|
||||
"error": "tool call failed",
|
||||
}
|
||||
if err != nil && strings.TrimSpace(err.Error()) != "" {
|
||||
payload["error"] = truncateForError(err.Error())
|
||||
}
|
||||
data, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
return `{"ok":false,"error":"tool call failed"}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
)
|
||||
|
||||
type staticAgenticToolExecutor struct {
|
||||
definitions []AgenticToolDefinition
|
||||
}
|
||||
|
||||
func (s staticAgenticToolExecutor) ToolDefinitions() []AgenticToolDefinition {
|
||||
return s.definitions
|
||||
}
|
||||
|
||||
func (staticAgenticToolExecutor) CallTool(_ context.Context, _ string, _ string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestBuildAgenticToolParamsNormalizesStrictOpenAISchemas(t *testing.T) {
|
||||
params := buildAgenticToolParams(staticAgenticToolExecutor{
|
||||
definitions: []AgenticToolDefinition{
|
||||
{
|
||||
Name: "fs_ls",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"session_id": map[string]any{"type": "string"},
|
||||
"beacon_id": map[string]any{"type": "string"},
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(params) != 1 || params[0].OfFunction == nil {
|
||||
t.Fatalf("expected one function tool param, got %+v", params)
|
||||
}
|
||||
|
||||
parameters := params[0].OfFunction.Parameters
|
||||
required := schemaRequiredSet(parameters["required"])
|
||||
for _, field := range []string{"session_id", "beacon_id", "path"} {
|
||||
if !required[field] {
|
||||
t.Fatalf("expected strict schema to require %q, got %#v", field, parameters["required"])
|
||||
}
|
||||
}
|
||||
|
||||
properties, ok := parameters["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected strict schema properties map, got %#v", parameters["properties"])
|
||||
}
|
||||
for _, field := range []string{"session_id", "beacon_id", "path"} {
|
||||
property, ok := properties[field].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected property %q to be a schema object, got %#v", field, properties[field])
|
||||
}
|
||||
typeValues, ok := property["type"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected property %q type to be []string, got %#v", field, property["type"])
|
||||
}
|
||||
if !stringSliceContains(typeValues, "string") || !stringSliceContains(typeValues, "null") {
|
||||
t.Fatalf("expected property %q to allow string and null, got %#v", field, typeValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationAgenticRequestsReasoningSummaryForOpenAI(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
Body string
|
||||
}
|
||||
|
||||
requests := make(chan capturedRequest, 1)
|
||||
restoreClient := SetHTTPClientForTests(&http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
|
||||
requests <- capturedRequest{Body: string(payload)}
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "resp_123",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "Done."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
completion, err := CompleteConversationAgentic(context.Background(), &RuntimeConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
Model: "gpt-5.4",
|
||||
UseResponsesAPI: true,
|
||||
ThinkingLevel: "high",
|
||||
APIKey: "openai-key",
|
||||
}, &clientpb.AIConversation{
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Say hi."},
|
||||
},
|
||||
}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("complete agentic conversation: %v", err)
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
for _, fragment := range []string{
|
||||
`"type":"web_search"`,
|
||||
`"search_context_size":"medium"`,
|
||||
`"effort":"high"`,
|
||||
`"summary":"auto"`,
|
||||
} {
|
||||
if !strings.Contains(request.Body, fragment) {
|
||||
t.Fatalf("expected agentic request body to contain %q, got %s", fragment, request.Body)
|
||||
}
|
||||
}
|
||||
if completion.Content != "Done." {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -130,12 +130,13 @@ func SafeConfigSummary() *clientpb.AIConfigSummary {
|
||||
func SafeConfigSummaryFromConfig(cfg *configs.ServerConfig) *clientpb.AIConfigSummary {
|
||||
summary := &clientpb.AIConfigSummary{}
|
||||
if cfg != nil && cfg.AI != nil {
|
||||
summary.Model = strings.TrimSpace(cfg.AI.Model)
|
||||
summary.ThinkingLevel = strings.TrimSpace(cfg.AI.ThinkingLevel)
|
||||
summary.SystemPrompt = strings.TrimSpace(cfg.AI.SystemPrompt)
|
||||
}
|
||||
|
||||
provider, providerConfig := selectedProviderConfig(cfg)
|
||||
summary.Provider = provider
|
||||
summary.Model = configuredDefaultModel(provider, selectedAIConfig(cfg), providerConfig)
|
||||
|
||||
switch {
|
||||
case cfg == nil || cfg.AI == nil:
|
||||
|
||||
+20
-2
@@ -10,9 +10,9 @@ func TestSafeConfigSummaryFromConfigUsesExplicitConfiguredProvider(t *testing.T)
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
Model: "gpt-test",
|
||||
ThinkingLevel: "high",
|
||||
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key"},
|
||||
SystemPrompt: "Stay concise.",
|
||||
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key", Models: []string{"gpt-test", "gpt-test-mini"}},
|
||||
Anthropic: &configs.AIProviderConfig{APIKey: "anthropic-key"},
|
||||
},
|
||||
}
|
||||
@@ -30,6 +30,24 @@ func TestSafeConfigSummaryFromConfigUsesExplicitConfiguredProvider(t *testing.T)
|
||||
if summary.GetThinkingLevel() != "high" {
|
||||
t.Fatalf("expected thinking level %q, got %q", "high", summary.GetThinkingLevel())
|
||||
}
|
||||
if summary.GetSystemPrompt() != "Stay concise." {
|
||||
t.Fatalf("expected system prompt %q, got %q", "Stay concise.", summary.GetSystemPrompt())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeConfigSummaryFromConfigFallsBackToLegacySharedModel(t *testing.T) {
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
Model: "legacy-model",
|
||||
OpenAI: &configs.AIProviderConfig{APIKey: "openai-key"},
|
||||
},
|
||||
}
|
||||
|
||||
summary := SafeConfigSummaryFromConfig(cfg)
|
||||
if summary.GetModel() != "legacy-model" {
|
||||
t.Fatalf("expected legacy model %q, got %q", "legacy-model", summary.GetModel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeConfigSummaryFromConfigFallsBackToConfiguredProvider(t *testing.T) {
|
||||
|
||||
+84
-374
@@ -8,14 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
fantasygoogle "charm.land/fantasy/providers/google"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
|
||||
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/bishopfox/sliver/util/clientpbutil"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,8 +19,6 @@ const (
|
||||
defaultGoogleModel = "gemini-2.5-pro"
|
||||
defaultOpenRouterModel = "openai/gpt-5"
|
||||
defaultCompletionTimeout = 2 * time.Minute
|
||||
|
||||
anthropicThinkingBudgetLow = 1024
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: defaultCompletionTimeout}
|
||||
@@ -68,11 +61,12 @@ type RuntimeConfig struct {
|
||||
|
||||
// Completion contains the persisted assistant response details.
|
||||
type Completion struct {
|
||||
Provider string
|
||||
Model string
|
||||
Content string
|
||||
ProviderMessageID string
|
||||
FinishReason string
|
||||
Provider string
|
||||
Model string
|
||||
Content string
|
||||
ProviderMessageID string
|
||||
FinishReason string
|
||||
ContextWindowUsage *ContextWindowUsage
|
||||
}
|
||||
|
||||
type providerMessage struct {
|
||||
@@ -98,6 +92,9 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
|
||||
if conversation != nil {
|
||||
runtime.Provider = NormalizeProviderName(conversation.GetProvider())
|
||||
runtime.Model = strings.TrimSpace(conversation.GetModel())
|
||||
if thinkingLevel := NormalizeThinkingLevelValue(conversation.GetThinkingLevel()); thinkingLevel != "" {
|
||||
runtime.ThinkingLevel = thinkingLevel
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.Provider == "" {
|
||||
@@ -113,11 +110,8 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
|
||||
applyProviderRuntimeConfig(runtime, providerConfig)
|
||||
}
|
||||
|
||||
if runtime.Model == "" && cfg != nil && cfg.AI != nil {
|
||||
runtime.Model = strings.TrimSpace(cfg.AI.Model)
|
||||
}
|
||||
if runtime.Model == "" {
|
||||
runtime.Model = defaultModelForProvider(runtime.Provider)
|
||||
runtime.Model = configuredDefaultModel(runtime.Provider, selectedAIConfig(cfg), providerConfig)
|
||||
}
|
||||
runtime.UseResponsesAPI = useResponsesAPI(runtime.Provider, providerConfig)
|
||||
|
||||
@@ -129,7 +123,9 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
|
||||
case !runtimeProviderConfigured(runtime):
|
||||
return runtime, errors.New(missingProviderConfigError(runtime.Provider))
|
||||
case runtime.Model == "":
|
||||
return runtime, fmt.Errorf("server AI provider %q is missing a model; update `ai.model` or choose a provider default", runtime.Provider)
|
||||
return runtime, fmt.Errorf("server AI provider %q is missing a model; update `ai.%s.models` or choose a provider default", runtime.Provider, providerConfigKey(runtime.Provider))
|
||||
case !completionDriverAvailable(runtime):
|
||||
return runtime, missingDriverError(runtime)
|
||||
default:
|
||||
return runtime, nil
|
||||
}
|
||||
@@ -149,229 +145,15 @@ func CompleteConversation(ctx context.Context, runtime *RuntimeConfig, conversat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider, err := newFantasyProvider(runtime)
|
||||
driver, err := selectCompletionDriver(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, runtime.Model)
|
||||
if err != nil {
|
||||
return nil, formatFantasyError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
response, err := model.Generate(ctx, buildFantasyCall(runtime, systemPrompt, messages))
|
||||
if err != nil {
|
||||
return nil, formatFantasyError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
content := extractFantasyResponseText(response)
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
finishReason := strings.TrimSpace(string(response.FinishReason))
|
||||
if finishReason == "" || finishReason == string(fantasy.FinishReasonUnknown) {
|
||||
finishReason = "completed"
|
||||
}
|
||||
|
||||
return &Completion{
|
||||
Provider: fallbackString(model.Provider(), runtime.Provider),
|
||||
Model: fallbackString(model.Model(), runtime.Model),
|
||||
Content: content,
|
||||
ProviderMessageID: extractProviderMessageID(response),
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newFantasyProvider(runtime *RuntimeConfig) (fantasy.Provider, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
|
||||
headers := providerHeaders(runtime)
|
||||
|
||||
switch runtime.Provider {
|
||||
case ProviderAnthropic:
|
||||
opts := []fantasyanthropic.Option{
|
||||
fantasyanthropic.WithName(ProviderAnthropic),
|
||||
fantasyanthropic.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyanthropic.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasyanthropic.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyanthropic.WithHeaders(headers))
|
||||
}
|
||||
if runtime.Project != "" && runtime.Location != "" {
|
||||
opts = append(opts, fantasyanthropic.WithVertex(runtime.Project, runtime.Location))
|
||||
}
|
||||
if runtime.SkipAuth {
|
||||
opts = append(opts, fantasyanthropic.WithSkipAuth(true))
|
||||
}
|
||||
if runtime.UseBedrock {
|
||||
opts = append(opts, fantasyanthropic.WithBedrock())
|
||||
}
|
||||
return fantasyanthropic.New(opts...)
|
||||
case ProviderGoogle:
|
||||
opts := []fantasygoogle.Option{
|
||||
fantasygoogle.WithName(ProviderGoogle),
|
||||
fantasygoogle.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasygoogle.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasygoogle.WithHeaders(headers))
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasygoogle.WithGeminiAPIKey(runtime.APIKey))
|
||||
} else if runtime.Project != "" && runtime.Location != "" {
|
||||
opts = append(opts, fantasygoogle.WithVertex(runtime.Project, runtime.Location))
|
||||
}
|
||||
if runtime.SkipAuth {
|
||||
opts = append(opts, fantasygoogle.WithSkipAuth(true))
|
||||
}
|
||||
return fantasygoogle.New(opts...)
|
||||
case ProviderOpenAI:
|
||||
opts := []fantasyopenai.Option{
|
||||
fantasyopenai.WithName(ProviderOpenAI),
|
||||
fantasyopenai.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyopenai.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasyopenai.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyopenai.WithHeaders(headers))
|
||||
}
|
||||
if runtime.Organization != "" {
|
||||
opts = append(opts, fantasyopenai.WithOrganization(runtime.Organization))
|
||||
}
|
||||
if runtime.Project != "" {
|
||||
opts = append(opts, fantasyopenai.WithProject(runtime.Project))
|
||||
}
|
||||
if runtime.UseResponsesAPI {
|
||||
opts = append(opts, fantasyopenai.WithUseResponsesAPI())
|
||||
}
|
||||
return fantasyopenai.New(opts...)
|
||||
case ProviderOpenAICompat:
|
||||
opts := []fantasyopenaicompat.Option{
|
||||
fantasyopenaicompat.WithName(ProviderOpenAICompat),
|
||||
fantasyopenaicompat.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyopenaicompat.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
opts = append(opts, fantasyopenaicompat.WithBaseURL(runtime.BaseURL))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyopenaicompat.WithHeaders(headers))
|
||||
}
|
||||
if runtime.UseResponsesAPI {
|
||||
opts = append(opts, fantasyopenaicompat.WithUseResponsesAPI())
|
||||
}
|
||||
return fantasyopenaicompat.New(opts...)
|
||||
case ProviderOpenRouter:
|
||||
opts := []fantasyopenrouter.Option{
|
||||
fantasyopenrouter.WithName(ProviderOpenRouter),
|
||||
fantasyopenrouter.WithHTTPClient(httpClient),
|
||||
}
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, fantasyopenrouter.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
opts = append(opts, fantasyopenrouter.WithHeaders(headers))
|
||||
}
|
||||
return fantasyopenrouter.New(opts...)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported AI provider %q", runtime.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFantasyCall(runtime *RuntimeConfig, systemPrompt string, messages []providerMessage) fantasy.Call {
|
||||
prompt := make(fantasy.Prompt, 0, len(messages)+1)
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: strings.TrimSpace(systemPrompt)},
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, message := range messages {
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: toFantasyMessageRole(message.Role),
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: message.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: prompt,
|
||||
ProviderOptions: fantasyProviderOptions(runtime),
|
||||
MaxOutputTokens: optionalRuntimeInt(runtime.MaxOutputTokens),
|
||||
Temperature: copyOptionalFloat(runtime.Temperature),
|
||||
TopP: copyOptionalFloat(runtime.TopP),
|
||||
TopK: copyOptionalInt(runtime.TopK),
|
||||
PresencePenalty: copyOptionalFloat(runtime.PresencePenalty),
|
||||
FrequencyPenalty: copyOptionalFloat(runtime.FrequencyPenalty),
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func fantasyProviderOptions(runtime *RuntimeConfig) fantasy.ProviderOptions {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch runtime.Provider {
|
||||
case ProviderAnthropic:
|
||||
if budget := anthropicThinkingBudget(runtime.ThinkingLevel); budget > 0 {
|
||||
return fantasyanthropic.NewProviderOptions(&fantasyanthropic.ProviderOptions{
|
||||
Thinking: &fantasyanthropic.ThinkingProviderOption{BudgetTokens: budget},
|
||||
})
|
||||
}
|
||||
case ProviderGoogle:
|
||||
if thinkingConfig := googleThinkingConfig(runtime.ThinkingLevel); thinkingConfig != nil {
|
||||
return fantasy.ProviderOptions{
|
||||
fantasygoogle.Name: &fantasygoogle.ProviderOptions{
|
||||
ThinkingConfig: thinkingConfig,
|
||||
},
|
||||
}
|
||||
}
|
||||
case ProviderOpenAI:
|
||||
if effort := openAIReasoningEffort(runtime.ThinkingLevel); effort != nil {
|
||||
if runtime.UseResponsesAPI && fantasyopenai.IsResponsesModel(runtime.Model) {
|
||||
return fantasyopenai.NewResponsesProviderOptions(&fantasyopenai.ResponsesProviderOptions{
|
||||
ReasoningEffort: effort,
|
||||
})
|
||||
}
|
||||
return fantasyopenai.NewProviderOptions(&fantasyopenai.ProviderOptions{
|
||||
ReasoningEffort: effort,
|
||||
})
|
||||
}
|
||||
case ProviderOpenAICompat:
|
||||
if effort := openAIReasoningEffort(runtime.ThinkingLevel); effort != nil {
|
||||
return fantasyopenaicompat.NewProviderOptions(&fantasyopenaicompat.ProviderOptions{
|
||||
ReasoningEffort: effort,
|
||||
})
|
||||
}
|
||||
case ProviderOpenRouter:
|
||||
if reasoning := openRouterReasoningOptions(runtime.ThinkingLevel); reasoning != nil {
|
||||
return fantasyopenrouter.NewProviderOptions(&fantasyopenrouter.ProviderOptions{
|
||||
Reasoning: reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return driver.CompleteConversation(ctx, runtime, &completionRequest{
|
||||
SystemPrompt: systemPrompt,
|
||||
Messages: messages,
|
||||
})
|
||||
}
|
||||
|
||||
func conversationHistory(conversation *clientpb.AIConversation) (string, []providerMessage, error) {
|
||||
@@ -385,6 +167,12 @@ func conversationHistory(conversation *clientpb.AIConversation) (string, []provi
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
if !clientpbutil.AIConversationMessageIncludesContext(message) {
|
||||
continue
|
||||
}
|
||||
if message.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
|
||||
continue
|
||||
}
|
||||
|
||||
role := strings.ToLower(strings.TrimSpace(message.GetRole()))
|
||||
content := strings.TrimSpace(message.GetContent())
|
||||
@@ -432,82 +220,69 @@ func defaultModelForProvider(provider string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func configuredDefaultModel(provider string, aiConfig *configs.AIConfig, providerConfig *configs.AIProviderConfig) string {
|
||||
if providerConfig != nil {
|
||||
for _, model := range providerConfig.Models {
|
||||
model = strings.TrimSpace(model)
|
||||
if model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
if aiConfig != nil {
|
||||
if legacyModel := strings.TrimSpace(aiConfig.Model); legacyModel != "" {
|
||||
return legacyModel
|
||||
}
|
||||
}
|
||||
return defaultModelForProvider(provider)
|
||||
}
|
||||
|
||||
func providerConfigKey(provider string) string {
|
||||
switch NormalizeProviderName(provider) {
|
||||
case ProviderOpenAICompat:
|
||||
return "openai_compat"
|
||||
default:
|
||||
return NormalizeProviderName(provider)
|
||||
}
|
||||
}
|
||||
|
||||
func selectedAIConfig(cfg *configs.ServerConfig) *configs.AIConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return cfg.AI
|
||||
}
|
||||
|
||||
func completionDriverAvailable(runtime *RuntimeConfig) bool {
|
||||
_, err := selectCompletionDriver(runtime)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func missingDriverError(runtime *RuntimeConfig) error {
|
||||
if runtime == nil {
|
||||
return fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
_, err := selectCompletionDriver(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeThinkingLevelValue(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "low", "medium", "high", "xhigh", "disabled":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeThinkingLevel(cfg *configs.ServerConfig) string {
|
||||
if cfg == nil || cfg.AI == nil {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.AI.ThinkingLevel)) {
|
||||
case "low", "medium", "high", "disabled":
|
||||
return strings.ToLower(strings.TrimSpace(cfg.AI.ThinkingLevel))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func openAIReasoningEffort(thinkingLevel string) *fantasyopenai.ReasoningEffort {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortLow)
|
||||
case "medium":
|
||||
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortMedium)
|
||||
case "high":
|
||||
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortHigh)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicThinkingBudget(thinkingLevel string) int64 {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return anthropicThinkingBudgetLow
|
||||
case "medium":
|
||||
return anthropicThinkingBudgetLow * 2
|
||||
case "high":
|
||||
return anthropicThinkingBudgetLow * 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func googleThinkingConfig(thinkingLevel string) *fantasygoogle.ThinkingConfig {
|
||||
var budget int64
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
budget = 1024
|
||||
case "medium":
|
||||
budget = 2048
|
||||
case "high":
|
||||
budget = 4096
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return &fantasygoogle.ThinkingConfig{
|
||||
ThinkingBudget: fantasy.Opt(budget),
|
||||
}
|
||||
}
|
||||
|
||||
func openRouterReasoningOptions(thinkingLevel string) *fantasyopenrouter.ReasoningOptions {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return &fantasyopenrouter.ReasoningOptions{
|
||||
Enabled: fantasy.Opt(true),
|
||||
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortLow),
|
||||
}
|
||||
case "medium":
|
||||
return &fantasyopenrouter.ReasoningOptions{
|
||||
Enabled: fantasy.Opt(true),
|
||||
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortMedium),
|
||||
}
|
||||
case "high":
|
||||
return &fantasyopenrouter.ReasoningOptions{
|
||||
Enabled: fantasy.Opt(true),
|
||||
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortHigh),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return NormalizeThinkingLevelValue(cfg.AI.ThinkingLevel)
|
||||
}
|
||||
|
||||
func applyProviderRuntimeConfig(runtime *RuntimeConfig, providerConfig *configs.AIProviderConfig) {
|
||||
@@ -583,71 +358,6 @@ func runtimeProviderConfigured(runtime *RuntimeConfig) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func extractFantasyResponseText(response *fantasy.Response) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
blocks := make([]string, 0, len(response.Content))
|
||||
for _, content := range response.Content {
|
||||
if content == nil || content.GetType() != fantasy.ContentTypeText {
|
||||
continue
|
||||
}
|
||||
textContent, ok := fantasy.AsContentType[fantasy.TextContent](content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(textContent.Text) == "" {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, textContent.Text)
|
||||
}
|
||||
return joinTextBlocks(blocks)
|
||||
}
|
||||
|
||||
func extractProviderMessageID(_ *fantasy.Response) string {
|
||||
// Fantasy's unified response model does not currently expose a stable top-level
|
||||
// provider message ID for plain text generations.
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatFantasyError(provider string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var providerErr *fantasy.ProviderError
|
||||
if errors.As(err, &providerErr) {
|
||||
message := strings.TrimSpace(providerErr.Error())
|
||||
if providerErr.StatusCode > 0 {
|
||||
if message == "" {
|
||||
message = http.StatusText(providerErr.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s API request failed with HTTP %d: %s", provider, providerErr.StatusCode, truncateForError(message))
|
||||
}
|
||||
if message != "" {
|
||||
return fmt.Errorf("%s API request failed: %s", provider, truncateForError(message))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func toFantasyMessageRole(role string) fantasy.MessageRole {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "assistant":
|
||||
return fantasy.MessageRoleAssistant
|
||||
default:
|
||||
return fantasy.MessageRoleUser
|
||||
}
|
||||
}
|
||||
|
||||
func optionalRuntimeInt(value int64) *int64 {
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return fantasy.Opt(value)
|
||||
}
|
||||
|
||||
func copyOptionalFloat(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
+478
-65
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testing.T) {
|
||||
@@ -38,6 +39,13 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
|
||||
"id": "resp_123",
|
||||
"model": "gpt-5.2",
|
||||
"status": "completed",
|
||||
"usage": {
|
||||
"input_tokens": 12000,
|
||||
"input_tokens_details": {},
|
||||
"output_tokens": 6000,
|
||||
"output_tokens_details": {},
|
||||
"total_tokens": 18000
|
||||
},
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
@@ -95,7 +103,10 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"model":"gpt-5.2"`,
|
||||
`"type":"web_search"`,
|
||||
`"search_context_size":"medium"`,
|
||||
`"effort":"high"`,
|
||||
`"summary":"auto"`,
|
||||
`"Keep it brief."`,
|
||||
`"Earlier answer."`,
|
||||
`"Explain the workflow."`,
|
||||
@@ -108,17 +119,25 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
|
||||
if completion.Content != "OpenAI assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "" {
|
||||
t.Fatalf("expected fantasy-backed completion to leave provider message id empty, got %q", completion.ProviderMessageID)
|
||||
if completion.ProviderMessageID != "resp_123" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
if completion.ContextWindowUsage == nil {
|
||||
t.Fatal("expected context window usage to be captured")
|
||||
}
|
||||
if completion.ContextWindowUsage.TotalTokens != 18000 {
|
||||
t.Fatalf("unexpected total tokens: %d", completion.ContextWindowUsage.TotalTokens)
|
||||
}
|
||||
if completion.ContextWindowUsage.ContextWindowTokens != openAIFrontierContextWindowTokens {
|
||||
t.Fatalf("unexpected context window tokens: %d", completion.ContextWindowUsage.ContextWindowTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *testing.T) {
|
||||
func TestCompleteConversationOpenAIUsesDefaultBaseURLWhenUnset(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
Path string
|
||||
APIKey string
|
||||
AnthropicVersion string
|
||||
Body string
|
||||
Path string
|
||||
Authorization string
|
||||
Body string
|
||||
}
|
||||
|
||||
requests := make(chan capturedRequest, 1)
|
||||
@@ -132,25 +151,192 @@ func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *te
|
||||
}
|
||||
|
||||
requests <- capturedRequest{
|
||||
Path: r.URL.Path,
|
||||
APIKey: r.Header.Get("x-api-key"),
|
||||
AnthropicVersion: r.Header.Get("anthropic-version"),
|
||||
Body: string(payload),
|
||||
Path: r.URL.Path,
|
||||
Authorization: r.Header.Get("Authorization"),
|
||||
Body: string(payload),
|
||||
}
|
||||
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "msg_123",
|
||||
"model": "claude-sonnet-4-0",
|
||||
"stop_reason": "end_turn",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "internal reasoning"},
|
||||
{"type": "text", "text": "Anthropic assistant reply"}
|
||||
"id": "resp_default_base",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "OpenAI default-base reply"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
OpenAI: &configs.AIProviderConfig{
|
||||
Models: []string{"gpt-5.4"},
|
||||
APIKey: "openai-key",
|
||||
UseResponsesAPI: boolPtr(true),
|
||||
},
|
||||
Anthropic: &configs.AIProviderConfig{},
|
||||
},
|
||||
}
|
||||
conversation := &clientpb.AIConversation{
|
||||
Provider: ProviderOpenAI,
|
||||
Model: "gpt-5.4",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Say hi."},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
if runtime.BaseURL != "" {
|
||||
t.Fatalf("expected openai runtime base url to remain unset, got %q", runtime.BaseURL)
|
||||
}
|
||||
|
||||
completion, err := CompleteConversation(context.Background(), runtime, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("complete conversation: %v", err)
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
if request.Path != "/v1/responses" {
|
||||
t.Fatalf("unexpected openai default-base request path: got=%q want=%q", request.Path, "/v1/responses")
|
||||
}
|
||||
if request.Authorization != "Bearer openai-key" {
|
||||
t.Fatalf("unexpected authorization header: %q", request.Authorization)
|
||||
}
|
||||
if !strings.Contains(request.Body, `"Say hi."`) {
|
||||
t.Fatalf("expected openai request body to contain the user prompt, got %s", request.Body)
|
||||
}
|
||||
if completion.Content != "OpenAI default-base reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "resp_default_base" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationOpenAIChatEnablesWebSearch(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
Path string
|
||||
Authorization string
|
||||
Body string
|
||||
}
|
||||
|
||||
requests := make(chan capturedRequest, 1)
|
||||
restoreClient := SetHTTPClientForTests(&http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
|
||||
requests <- capturedRequest{
|
||||
Path: r.URL.Path,
|
||||
Authorization: r.Header.Get("Authorization"),
|
||||
Body: string(payload),
|
||||
}
|
||||
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "chatcmpl_openai_123",
|
||||
"object": "chat.completion",
|
||||
"model": "gpt-5.4",
|
||||
"usage": {
|
||||
"prompt_tokens": 32000,
|
||||
"completion_tokens": 4000,
|
||||
"total_tokens": 36000
|
||||
},
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "OpenAI chat reply"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
OpenAI: &configs.AIProviderConfig{
|
||||
Models: []string{"gpt-5.4"},
|
||||
APIKey: "openai-key",
|
||||
UseResponsesAPI: boolPtr(false),
|
||||
},
|
||||
Anthropic: &configs.AIProviderConfig{},
|
||||
},
|
||||
}
|
||||
conversation := &clientpb.AIConversation{
|
||||
Provider: ProviderOpenAI,
|
||||
Model: "gpt-5.4",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Say hi."},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
if runtime.UseResponsesAPI {
|
||||
t.Fatal("expected openai runtime to use chat completions for this test")
|
||||
}
|
||||
|
||||
completion, err := CompleteConversation(context.Background(), runtime, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("complete conversation: %v", err)
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
if request.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("unexpected openai chat request path: got=%q want=%q", request.Path, "/v1/chat/completions")
|
||||
}
|
||||
if request.Authorization != "Bearer openai-key" {
|
||||
t.Fatalf("unexpected authorization header: %q", request.Authorization)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"model":"gpt-5.4"`,
|
||||
`"web_search_options":{"search_context_size":"medium"}`,
|
||||
`"Say hi."`,
|
||||
} {
|
||||
if !strings.Contains(request.Body, fragment) {
|
||||
t.Fatalf("expected openai chat request body to contain %q, got %s", fragment, request.Body)
|
||||
}
|
||||
}
|
||||
if completion.Content != "OpenAI chat reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "chatcmpl_openai_123" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
if completion.ContextWindowUsage == nil {
|
||||
t.Fatal("expected openai chat usage to be captured")
|
||||
}
|
||||
if completion.ContextWindowUsage.TotalTokens != 36000 {
|
||||
t.Fatalf("unexpected openai chat total tokens: %d", completion.ContextWindowUsage.TotalTokens)
|
||||
}
|
||||
if completion.ContextWindowUsage.ContextWindowTokens != openAI5_4ContextWindowTokens {
|
||||
t.Fatalf("unexpected openai chat context window tokens: %d", completion.ContextWindowUsage.ContextWindowTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuntimeConfigAnthropicRequiresAnInstalledDriver(t *testing.T) {
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderAnthropic,
|
||||
@@ -162,57 +348,16 @@ func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *te
|
||||
OpenAI: &configs.AIProviderConfig{},
|
||||
},
|
||||
}
|
||||
conversation := &clientpb.AIConversation{
|
||||
Provider: ProviderAnthropic,
|
||||
SystemPrompt: "Use short answers.",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "assistant", Content: "Hi there"},
|
||||
{Role: "user", Content: "What changed?"},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
runtime, err := ResolveRuntimeConfig(cfg, &clientpb.AIConversation{Provider: ProviderAnthropic})
|
||||
if runtime.Model != defaultAnthropicModel {
|
||||
t.Fatalf("unexpected default anthropic model: got=%q want=%q", runtime.Model, defaultAnthropicModel)
|
||||
}
|
||||
|
||||
completion, err := CompleteConversation(context.Background(), runtime, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("complete conversation: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected anthropic runtime resolution to require a dedicated driver")
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
if request.Path != "/edge/v1/messages" {
|
||||
t.Fatalf("unexpected anthropic request path: got=%q want=%q", request.Path, "/edge/v1/messages")
|
||||
}
|
||||
if request.APIKey != "anthropic-key" {
|
||||
t.Fatalf("unexpected anthropic api key header: %q", request.APIKey)
|
||||
}
|
||||
if request.AnthropicVersion == "" {
|
||||
t.Fatal("expected anthropic version header to be set")
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"model":"claude-sonnet-4-0"`,
|
||||
`"Use short answers."`,
|
||||
`"budget_tokens":2048`,
|
||||
`"Hello"`,
|
||||
`"Hi there"`,
|
||||
`"What changed?"`,
|
||||
} {
|
||||
if !strings.Contains(request.Body, fragment) {
|
||||
t.Fatalf("expected anthropic request body to contain %q, got %s", fragment, request.Body)
|
||||
}
|
||||
}
|
||||
|
||||
if completion.Content != "Anthropic assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.FinishReason != "stop" {
|
||||
t.Fatalf("unexpected finish reason: %q", completion.FinishReason)
|
||||
if !strings.Contains(err.Error(), `provider "anthropic"`) || !strings.Contains(err.Error(), "SDK driver") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,8 +365,8 @@ func TestResolveRuntimeConfigOpenAICompatAllowsBaseURLWithoutAPIKey(t *testing.T
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAICompat,
|
||||
Model: "gpt-oss-120b",
|
||||
OpenAICompat: &configs.AIProviderConfig{
|
||||
Models: []string{"gpt-oss-120b"},
|
||||
BaseURL: "http://127.0.0.1:8080/v1",
|
||||
},
|
||||
},
|
||||
@@ -244,6 +389,58 @@ func TestResolveRuntimeConfigOpenAICompatAllowsBaseURLWithoutAPIKey(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRuntimeConfigUsesConversationThinkingOverride(t *testing.T) {
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
ThinkingLevel: "high",
|
||||
OpenAI: &configs.AIProviderConfig{
|
||||
Models: []string{"gpt-5.4"},
|
||||
APIKey: "openai-key",
|
||||
UseResponsesAPI: boolPtr(true),
|
||||
},
|
||||
Anthropic: &configs.AIProviderConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, &clientpb.AIConversation{
|
||||
Provider: ProviderOpenAI,
|
||||
ThinkingLevel: "disabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
if runtime.ThinkingLevel != "disabled" {
|
||||
t.Fatalf("expected conversation thinking override %q, got %q", "disabled", runtime.ThinkingLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningEffortForThinkingLevelSupportsXHigh(t *testing.T) {
|
||||
effort, ok := reasoningEffortForThinkingLevel("xhigh")
|
||||
if !ok {
|
||||
t.Fatal("expected xhigh reasoning effort to be supported")
|
||||
}
|
||||
if string(effort) != "xhigh" {
|
||||
t.Fatalf("expected reasoning effort %q, got %q", "xhigh", effort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseReasoningParamSkipsSummaryForOpenAICompat(t *testing.T) {
|
||||
reasoning, ok := responseReasoningParam(&RuntimeConfig{
|
||||
Provider: ProviderOpenAICompat,
|
||||
ThinkingLevel: "high",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected response reasoning params to be available")
|
||||
}
|
||||
if reasoning.Effort != shared.ReasoningEffortHigh {
|
||||
t.Fatalf("expected reasoning effort %q, got %q", shared.ReasoningEffortHigh, reasoning.Effort)
|
||||
}
|
||||
if reasoning.Summary != "" {
|
||||
t.Fatalf("expected openai-compatible runtime to skip reasoning summary, got %q", reasoning.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationOpenAICompatUsesBaseURLWithoutAuth(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
Path string
|
||||
@@ -294,8 +491,8 @@ func TestCompleteConversationOpenAICompatUsesBaseURLWithoutAuth(t *testing.T) {
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenAICompat,
|
||||
Model: "gpt-oss-120b",
|
||||
OpenAICompat: &configs.AIProviderConfig{
|
||||
Models: []string{"gpt-oss-120b"},
|
||||
BaseURL: "http://127.0.0.1:8080/v1",
|
||||
},
|
||||
},
|
||||
@@ -330,6 +527,222 @@ func TestCompleteConversationOpenAICompatUsesBaseURLWithoutAuth(t *testing.T) {
|
||||
if completion.Content != "OpenAI-compatible assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "chatcmpl_123" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationOpenRouterUsesDefaultBaseURL(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
Path string
|
||||
Authorization string
|
||||
Body string
|
||||
}
|
||||
|
||||
requests := make(chan capturedRequest, 1)
|
||||
restoreClient := SetHTTPClientForTests(&http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
|
||||
requests <- capturedRequest{
|
||||
Path: r.URL.Path,
|
||||
Authorization: r.Header.Get("Authorization"),
|
||||
Body: string(payload),
|
||||
}
|
||||
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"id": "chatcmpl_or_123",
|
||||
"object": "chat.completion",
|
||||
"model": "openai/gpt-5",
|
||||
"usage": {
|
||||
"prompt_tokens": 14000,
|
||||
"completion_tokens": 2000,
|
||||
"total_tokens": 16000
|
||||
},
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "OpenRouter assistant reply"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
cfg := &configs.ServerConfig{
|
||||
AI: &configs.AIConfig{
|
||||
Provider: ProviderOpenRouter,
|
||||
ThinkingLevel: "medium",
|
||||
OpenRouter: &configs.AIProviderConfig{
|
||||
APIKey: "openrouter-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
conversation := &clientpb.AIConversation{
|
||||
Provider: ProviderOpenRouter,
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{Role: "user", Content: "Say hi."},
|
||||
},
|
||||
}
|
||||
|
||||
runtime, err := ResolveRuntimeConfig(cfg, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime config: %v", err)
|
||||
}
|
||||
if runtime.Model != defaultOpenRouterModel {
|
||||
t.Fatalf("unexpected default openrouter model: got=%q want=%q", runtime.Model, defaultOpenRouterModel)
|
||||
}
|
||||
|
||||
completion, err := CompleteConversation(context.Background(), runtime, conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("complete conversation: %v", err)
|
||||
}
|
||||
|
||||
request := <-requests
|
||||
if request.Path != "/api/v1/chat/completions" {
|
||||
t.Fatalf("unexpected openrouter request path: got=%q want=%q", request.Path, "/api/v1/chat/completions")
|
||||
}
|
||||
if request.Authorization != "Bearer openrouter-key" {
|
||||
t.Fatalf("unexpected authorization header: %q", request.Authorization)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"model":"openai/gpt-5"`,
|
||||
`"Say hi."`,
|
||||
} {
|
||||
if !strings.Contains(request.Body, fragment) {
|
||||
t.Fatalf("expected openrouter request body to contain %q, got %s", fragment, request.Body)
|
||||
}
|
||||
}
|
||||
if completion.Content != "OpenRouter assistant reply" {
|
||||
t.Fatalf("unexpected completion content: %q", completion.Content)
|
||||
}
|
||||
if completion.ProviderMessageID != "chatcmpl_or_123" {
|
||||
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
|
||||
}
|
||||
if completion.ContextWindowUsage == nil {
|
||||
t.Fatal("expected openrouter usage to be captured")
|
||||
}
|
||||
if completion.ContextWindowUsage.TotalTokens != 16000 {
|
||||
t.Fatalf("unexpected openrouter total tokens: %d", completion.ContextWindowUsage.TotalTokens)
|
||||
}
|
||||
if completion.ContextWindowUsage.ContextWindowTokens != openAIFrontierContextWindowTokens {
|
||||
t.Fatalf("unexpected openrouter context window tokens: %d", completion.ContextWindowUsage.ContextWindowTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationHistoryUsesExplicitContextFlagIndependentlyOfVisibility(t *testing.T) {
|
||||
systemPrompt, messages, err := conversationHistory(&clientpb.AIConversation{
|
||||
SystemPrompt: "Stay concise.",
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Visible in the transcript but excluded from context.",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
IncludeInContext: boolPtr(false),
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "UI-only but still included in context.",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
|
||||
IncludeInContext: boolPtr(true),
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "What changed?",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
IncludeInContext: boolPtr(true),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("conversationHistory: %v", err)
|
||||
}
|
||||
if systemPrompt != "Stay concise." {
|
||||
t.Fatalf("unexpected system prompt: %q", systemPrompt)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("unexpected message count: got=%d want=%d", len(messages), 2)
|
||||
}
|
||||
if messages[0].Content != "UI-only but still included in context." {
|
||||
t.Fatalf("unexpected retained assistant message: %+v", messages[0])
|
||||
}
|
||||
if messages[1].Role != "user" || messages[1].Content != "What changed?" {
|
||||
t.Fatalf("unexpected trailing user message: %+v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationHistoryTreatsLeadingSystemMessageAsSystemPrompt(t *testing.T) {
|
||||
systemPrompt, messages, err := conversationHistory(&clientpb.AIConversation{
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "Stay concise.",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
IncludeInContext: boolPtr(true),
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "What changed?",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
IncludeInContext: boolPtr(true),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("conversationHistory: %v", err)
|
||||
}
|
||||
if systemPrompt != "Stay concise." {
|
||||
t.Fatalf("unexpected system prompt: %q", systemPrompt)
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("unexpected message count: got=%d want=%d", len(messages), 1)
|
||||
}
|
||||
if messages[0].Role != "user" || messages[0].Content != "What changed?" {
|
||||
t.Fatalf("unexpected trailing user message: %+v", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationHistoryFallsBackToVisibilityWhenContextFlagIsUnset(t *testing.T) {
|
||||
_, messages, err := conversationHistory(&clientpb.AIConversation{
|
||||
Messages: []*clientpb.AIConversationMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Legacy context-visible message.",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Legacy prompt.",
|
||||
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
|
||||
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("conversationHistory: %v", err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("unexpected legacy message count: got=%d want=%d", len(messages), 2)
|
||||
}
|
||||
if messages[0].Content != "Legacy context-visible message." {
|
||||
t.Fatalf("unexpected legacy assistant message: %+v", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
openAIFrontierContextWindowTokens = 400000
|
||||
openAIChatContextWindowTokens = 128000
|
||||
openAI4_1ContextWindowTokens = 1047576
|
||||
openAI5_4ContextWindowTokens = 1050000
|
||||
openAIReasoningContextWindowTokens = 200000
|
||||
openAIRealtimeContextWindowTokens = 32000
|
||||
openAIRealtimeMiniContextWindowTokens = 16000
|
||||
openRouterContextWindowCacheTTL = 6 * time.Hour
|
||||
)
|
||||
|
||||
// ContextWindowUsage captures the provider-reported token usage for a turn and,
|
||||
// when available, the model context window used to render the client-side meter.
|
||||
type ContextWindowUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
ContextWindowTokens int64
|
||||
ContextWindowTokensEstimated bool
|
||||
}
|
||||
|
||||
func newContextWindowUsage(inputTokens, outputTokens, totalTokens, windowTokens int64, estimated bool) *ContextWindowUsage {
|
||||
if inputTokens == 0 && outputTokens == 0 && totalTokens == 0 {
|
||||
return nil
|
||||
}
|
||||
return &ContextWindowUsage{
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
TotalTokens: totalTokens,
|
||||
ContextWindowTokens: windowTokens,
|
||||
ContextWindowTokensEstimated: estimated,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneContextWindowUsage(usage *ContextWindowUsage) *ContextWindowUsage {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *usage
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func contextWindowUsageRank(usage *ContextWindowUsage) (int64, int64, int64) {
|
||||
if usage == nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return usage.TotalTokens, usage.InputTokens, usage.OutputTokens
|
||||
}
|
||||
|
||||
func mergeContextWindowUsage(current *ContextWindowUsage, candidate *ContextWindowUsage) *ContextWindowUsage {
|
||||
if candidate == nil {
|
||||
return cloneContextWindowUsage(current)
|
||||
}
|
||||
if current == nil {
|
||||
return cloneContextWindowUsage(candidate)
|
||||
}
|
||||
|
||||
result := cloneContextWindowUsage(current)
|
||||
currentTotal, currentInput, currentOutput := contextWindowUsageRank(result)
|
||||
candidateTotal, candidateInput, candidateOutput := contextWindowUsageRank(candidate)
|
||||
if candidateTotal > currentTotal ||
|
||||
(candidateTotal == currentTotal && candidateInput > currentInput) ||
|
||||
(candidateTotal == currentTotal && candidateInput == currentInput && candidateOutput > currentOutput) {
|
||||
result = cloneContextWindowUsage(candidate)
|
||||
}
|
||||
|
||||
if result.ContextWindowTokens == 0 && candidate.ContextWindowTokens > 0 {
|
||||
result.ContextWindowTokens = candidate.ContextWindowTokens
|
||||
result.ContextWindowTokensEstimated = candidate.ContextWindowTokensEstimated
|
||||
}
|
||||
if result.ContextWindowTokens > 0 && result.ContextWindowTokensEstimated &&
|
||||
candidate.ContextWindowTokens > 0 && !candidate.ContextWindowTokensEstimated {
|
||||
result.ContextWindowTokens = candidate.ContextWindowTokens
|
||||
result.ContextWindowTokensEstimated = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveContextWindowUsage(ctx context.Context, runtime *RuntimeConfig, model string, inputTokens, outputTokens, totalTokens int64) *ContextWindowUsage {
|
||||
windowTokens, estimated := resolveContextWindowTokens(ctx, runtime, model)
|
||||
return newContextWindowUsage(inputTokens, outputTokens, totalTokens, windowTokens, estimated)
|
||||
}
|
||||
|
||||
func resolveContextWindowTokens(ctx context.Context, runtime *RuntimeConfig, model string) (int64, bool) {
|
||||
model = normalizeContextWindowModelID(model)
|
||||
if model == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if tokens := openAIKnownContextWindowTokens(model); tokens > 0 {
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
if openRouterCompatibleRuntime(runtime) {
|
||||
if tokens, ok := openRouterContextWindowTokens(ctx, runtime, model); ok {
|
||||
return tokens, false
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func openAIKnownContextWindowTokens(model string) int64 {
|
||||
model = normalizeContextWindowModelID(model)
|
||||
switch {
|
||||
case model == "codex-mini-latest":
|
||||
return openAIReasoningContextWindowTokens
|
||||
case strings.HasPrefix(model, "o3"), strings.HasPrefix(model, "o4-"):
|
||||
return openAIReasoningContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-5.4"):
|
||||
return openAI5_4ContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-4.1"):
|
||||
return openAI4_1ContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-5.1-chat"), strings.HasPrefix(model, "gpt-5-chat"):
|
||||
return openAIChatContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-5"):
|
||||
return openAIFrontierContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-4o-mini-realtime"):
|
||||
return openAIRealtimeMiniContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-4o-realtime"):
|
||||
return openAIRealtimeContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-4o"), strings.HasPrefix(model, "chatgpt-4o"):
|
||||
return openAIChatContextWindowTokens
|
||||
case strings.Contains(model, "gpt-4-turbo"):
|
||||
return openAIChatContextWindowTokens
|
||||
case strings.HasPrefix(model, "gpt-4"):
|
||||
return 8192
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeContextWindowModelID(model string) string {
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
if idx := strings.Index(model, "/"); idx >= 0 {
|
||||
model = model[idx+1:]
|
||||
}
|
||||
if idx := strings.Index(model, ":"); idx >= 0 {
|
||||
model = model[:idx]
|
||||
}
|
||||
return strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
func openRouterCompatibleRuntime(runtime *RuntimeConfig) bool {
|
||||
if runtime == nil {
|
||||
return false
|
||||
}
|
||||
switch NormalizeProviderName(runtime.Provider) {
|
||||
case ProviderOpenRouter:
|
||||
return true
|
||||
case ProviderOpenAICompat:
|
||||
baseURL := strings.ToLower(strings.TrimSpace(openAIBaseURL(runtime)))
|
||||
return strings.Contains(baseURL, "openrouter.ai")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type openRouterModelsResponse struct {
|
||||
Data []openRouterModel `json:"data"`
|
||||
}
|
||||
|
||||
type openRouterModel struct {
|
||||
ID string `json:"id"`
|
||||
TopProvider struct {
|
||||
ContextLength int64 `json:"context_length"`
|
||||
} `json:"top_provider"`
|
||||
}
|
||||
|
||||
type cachedOpenRouterContextWindow struct {
|
||||
tokens int64
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var openRouterContextWindowCache = struct {
|
||||
sync.RWMutex
|
||||
entries map[string]cachedOpenRouterContextWindow
|
||||
}{
|
||||
entries: map[string]cachedOpenRouterContextWindow{},
|
||||
}
|
||||
|
||||
func openRouterContextWindowTokens(ctx context.Context, runtime *RuntimeConfig, model string) (int64, bool) {
|
||||
model = normalizeContextWindowModelID(model)
|
||||
if model == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
cacheKey := openRouterContextWindowCacheKey(runtime, model)
|
||||
if tokens, ok := cachedOpenRouterContextWindowTokens(cacheKey); ok {
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterModelsURL(runtime), nil)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if runtime != nil && !runtime.SkipAuth && strings.TrimSpace(runtime.APIKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(runtime.APIKey))
|
||||
}
|
||||
for key, value := range providerHeaders(runtime) {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
payload := &openRouterModelsResponse{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for _, candidate := range payload.Data {
|
||||
candidateID := normalizeContextWindowModelID(candidate.ID)
|
||||
if candidateID != model {
|
||||
continue
|
||||
}
|
||||
if candidate.TopProvider.ContextLength <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
storeOpenRouterContextWindowTokens(cacheKey, candidate.TopProvider.ContextLength)
|
||||
return candidate.TopProvider.ContextLength, true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func openRouterModelsURL(runtime *RuntimeConfig) string {
|
||||
baseURL := strings.TrimSpace(openAIBaseURL(runtime))
|
||||
if baseURL == "" {
|
||||
baseURL = defaultOpenRouterBaseURL
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed == nil {
|
||||
return defaultOpenRouterBaseURL + "/models"
|
||||
}
|
||||
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/chat/completions"):
|
||||
path = strings.TrimSuffix(path, "/chat/completions")
|
||||
case strings.HasSuffix(path, "/responses"):
|
||||
path = strings.TrimSuffix(path, "/responses")
|
||||
case path == "":
|
||||
path = "/api/v1"
|
||||
}
|
||||
if !strings.HasSuffix(path, "/models") {
|
||||
path += "/models"
|
||||
}
|
||||
parsed.Path = path
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func openRouterContextWindowCacheKey(runtime *RuntimeConfig, model string) string {
|
||||
return openRouterModelsURL(runtime) + "|" + normalizeContextWindowModelID(model)
|
||||
}
|
||||
|
||||
func cachedOpenRouterContextWindowTokens(cacheKey string) (int64, bool) {
|
||||
if cacheKey == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
openRouterContextWindowCache.RLock()
|
||||
cached, ok := openRouterContextWindowCache.entries[cacheKey]
|
||||
openRouterContextWindowCache.RUnlock()
|
||||
if !ok || cached.tokens <= 0 || now.After(cached.expiresAt) {
|
||||
if ok && now.After(cached.expiresAt) {
|
||||
openRouterContextWindowCache.Lock()
|
||||
delete(openRouterContextWindowCache.entries, cacheKey)
|
||||
openRouterContextWindowCache.Unlock()
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return cached.tokens, true
|
||||
}
|
||||
|
||||
func storeOpenRouterContextWindowTokens(cacheKey string, tokens int64) {
|
||||
if cacheKey == "" || tokens <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
openRouterContextWindowCache.Lock()
|
||||
openRouterContextWindowCache.entries[cacheKey] = cachedOpenRouterContextWindow{
|
||||
tokens: tokens,
|
||||
expiresAt: time.Now().Add(openRouterContextWindowCacheTTL),
|
||||
}
|
||||
openRouterContextWindowCache.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveContextWindowUsageOpenRouterUsesModelsAPIForUnknownModels(t *testing.T) {
|
||||
openRouterContextWindowCache.Lock()
|
||||
openRouterContextWindowCache.entries = map[string]cachedOpenRouterContextWindow{}
|
||||
openRouterContextWindowCache.Unlock()
|
||||
|
||||
requests := make(chan string, 1)
|
||||
restoreClient := SetHTTPClientForTests(&http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
requests <- r.URL.Path
|
||||
return jsonResponse(http.StatusOK, `{
|
||||
"data": [
|
||||
{
|
||||
"id": "anthropic/claude-sonnet-4-0",
|
||||
"top_provider": {
|
||||
"context_length": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}),
|
||||
})
|
||||
defer restoreClient()
|
||||
|
||||
usage := resolveContextWindowUsage(context.Background(), &RuntimeConfig{
|
||||
Provider: ProviderOpenRouter,
|
||||
APIKey: "openrouter-key",
|
||||
}, "anthropic/claude-sonnet-4-0", 24000, 1000, 25000)
|
||||
if usage == nil {
|
||||
t.Fatal("expected context window usage")
|
||||
}
|
||||
if usage.ContextWindowTokens != 200000 {
|
||||
t.Fatalf("unexpected context window tokens: %d", usage.ContextWindowTokens)
|
||||
}
|
||||
if usage.ContextWindowTokensEstimated {
|
||||
t.Fatal("expected OpenRouter model lookup to be treated as exact")
|
||||
}
|
||||
|
||||
requestPath := <-requests
|
||||
if requestPath != "/api/v1/models" {
|
||||
t.Fatalf("unexpected models lookup path: %q", requestPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type completionRequest struct {
|
||||
SystemPrompt string
|
||||
Messages []providerMessage
|
||||
}
|
||||
|
||||
type sdkDriver interface {
|
||||
Name() string
|
||||
Supports(*RuntimeConfig) bool
|
||||
CompleteConversation(context.Context, *RuntimeConfig, *completionRequest) (*Completion, error)
|
||||
}
|
||||
|
||||
var completionDrivers = []sdkDriver{
|
||||
newOpenAIDriver(),
|
||||
}
|
||||
|
||||
func selectCompletionDriver(runtime *RuntimeConfig) (sdkDriver, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
|
||||
for _, driver := range completionDrivers {
|
||||
if driver.Supports(runtime) {
|
||||
return driver, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"server AI provider %q does not have an available SDK driver; use openai, openai-compat, or openrouter, or add a driver in server/ai",
|
||||
runtime.Provider,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
openai "github.com/openai/openai-go/v2"
|
||||
"github.com/openai/openai-go/v2/option"
|
||||
openairesponses "github.com/openai/openai-go/v2/responses"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
openAIDriverName = "openai-go"
|
||||
defaultOpenRouterBaseURL = "https://openrouter.ai/api/v1"
|
||||
openAIWebSearchContext = "medium"
|
||||
)
|
||||
|
||||
type openAIDriver struct{}
|
||||
|
||||
type openAIClient struct {
|
||||
chat openai.ChatCompletionService
|
||||
responses openairesponses.ResponseService
|
||||
}
|
||||
|
||||
func newOpenAIDriver() sdkDriver {
|
||||
return &openAIDriver{}
|
||||
}
|
||||
|
||||
func (d *openAIDriver) Name() string {
|
||||
return openAIDriverName
|
||||
}
|
||||
|
||||
func (d *openAIDriver) Supports(runtime *RuntimeConfig) bool {
|
||||
if runtime == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch NormalizeProviderName(runtime.Provider) {
|
||||
case ProviderOpenAI, ProviderOpenAICompat, ProviderOpenRouter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *openAIDriver) CompleteConversation(ctx context.Context, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
|
||||
if runtime == nil {
|
||||
return nil, fmt.Errorf("AI runtime config is required")
|
||||
}
|
||||
if request == nil {
|
||||
return nil, fmt.Errorf("AI completion request is required")
|
||||
}
|
||||
|
||||
client := newOpenAIClient(openAIRequestOptions(runtime)...)
|
||||
if runtime.UseResponsesAPI {
|
||||
return d.completeResponses(ctx, client, runtime, request)
|
||||
}
|
||||
return d.completeChat(ctx, client, runtime, request)
|
||||
}
|
||||
|
||||
func newOpenAIClient(opts ...option.RequestOption) *openAIClient {
|
||||
return &openAIClient{
|
||||
chat: openai.NewChatCompletionService(opts...),
|
||||
responses: openairesponses.NewResponseService(opts...),
|
||||
}
|
||||
}
|
||||
|
||||
func openAIRequestOptions(runtime *RuntimeConfig) []option.RequestOption {
|
||||
opts := []option.RequestOption{
|
||||
option.WithHTTPClient(httpClient),
|
||||
option.WithRequestTimeout(defaultCompletionTimeout),
|
||||
option.WithMaxRetries(0),
|
||||
}
|
||||
|
||||
if runtime != nil && NormalizeProviderName(runtime.Provider) == ProviderOpenAI {
|
||||
opts = append(opts, option.WithEnvironmentProduction())
|
||||
}
|
||||
if baseURL := openAIBaseURL(runtime); baseURL != "" {
|
||||
opts = append(opts, option.WithBaseURL(baseURL))
|
||||
}
|
||||
if runtime != nil {
|
||||
if runtime.APIKey != "" {
|
||||
opts = append(opts, option.WithAPIKey(runtime.APIKey))
|
||||
}
|
||||
if runtime.Organization != "" {
|
||||
opts = append(opts, option.WithOrganization(runtime.Organization))
|
||||
}
|
||||
if runtime.Project != "" {
|
||||
opts = append(opts, option.WithProject(runtime.Project))
|
||||
}
|
||||
for key, value := range providerHeaders(runtime) {
|
||||
opts = append(opts, option.WithHeader(key, value))
|
||||
}
|
||||
if runtime.TopK != nil && NormalizeProviderName(runtime.Provider) != ProviderOpenAI {
|
||||
opts = append(opts, option.WithJSONSet("top_k", *runtime.TopK))
|
||||
}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func openAIBaseURL(runtime *RuntimeConfig) string {
|
||||
if runtime == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if runtime.BaseURL != "" {
|
||||
return runtime.BaseURL
|
||||
}
|
||||
if NormalizeProviderName(runtime.Provider) == ProviderOpenRouter {
|
||||
return defaultOpenRouterBaseURL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func openAIWebSearchEnabled(runtime *RuntimeConfig) bool {
|
||||
return runtime != nil && NormalizeProviderName(runtime.Provider) == ProviderOpenAI
|
||||
}
|
||||
|
||||
func appendOpenAIResponseTools(runtime *RuntimeConfig, tools []openairesponses.ToolUnionParam) []openairesponses.ToolUnionParam {
|
||||
if !openAIWebSearchEnabled(runtime) {
|
||||
return tools
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
if tool.OfWebSearch != nil || tool.OfWebSearchPreview != nil {
|
||||
return tools
|
||||
}
|
||||
}
|
||||
|
||||
webSearch := openairesponses.ToolParamOfWebSearch(openairesponses.WebSearchToolTypeWebSearch)
|
||||
if webSearch.OfWebSearch != nil {
|
||||
webSearch.OfWebSearch.SearchContextSize = openairesponses.WebSearchToolSearchContextSizeMedium
|
||||
}
|
||||
|
||||
return append(append([]openairesponses.ToolUnionParam{}, tools...), webSearch)
|
||||
}
|
||||
|
||||
func applyOpenAIChatWebSearch(runtime *RuntimeConfig, params *openai.ChatCompletionNewParams) {
|
||||
if params == nil || !openAIWebSearchEnabled(runtime) {
|
||||
return
|
||||
}
|
||||
|
||||
params.WebSearchOptions = openai.ChatCompletionNewParamsWebSearchOptions{
|
||||
SearchContextSize: openAIWebSearchContext,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *openAIDriver) completeResponses(ctx context.Context, client *openAIClient, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
|
||||
params := openairesponses.ResponseNewParams{
|
||||
Input: openairesponses.ResponseNewParamsInputUnion{
|
||||
OfInputItemList: buildResponseInput(request.SystemPrompt, request.Messages),
|
||||
},
|
||||
Model: shared.ResponsesModel(runtime.Model),
|
||||
}
|
||||
if tools := appendOpenAIResponseTools(runtime, nil); len(tools) > 0 {
|
||||
params.Tools = tools
|
||||
}
|
||||
if runtime.MaxOutputTokens > 0 {
|
||||
params.MaxOutputTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
}
|
||||
if runtime.Temperature != nil {
|
||||
params.Temperature = openai.Float(*runtime.Temperature)
|
||||
}
|
||||
if runtime.TopP != nil {
|
||||
params.TopP = openai.Float(*runtime.TopP)
|
||||
}
|
||||
if reasoning, ok := responseReasoningParam(runtime); ok {
|
||||
params.Reasoning = reasoning
|
||||
}
|
||||
|
||||
response, err := client.responses.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, formatOpenAIError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(response.OutputText())
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
model := fallbackString(response.Model, runtime.Model)
|
||||
return &Completion{
|
||||
Provider: runtime.Provider,
|
||||
Model: model,
|
||||
Content: content,
|
||||
ProviderMessageID: strings.TrimSpace(response.ID),
|
||||
FinishReason: responseFinishReason(response),
|
||||
ContextWindowUsage: resolveContextWindowUsage(
|
||||
ctx,
|
||||
runtime,
|
||||
model,
|
||||
response.Usage.InputTokens,
|
||||
response.Usage.OutputTokens,
|
||||
response.Usage.TotalTokens,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *openAIDriver) completeChat(ctx context.Context, client *openAIClient, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Messages: buildChatMessages(request.SystemPrompt, request.Messages),
|
||||
Model: shared.ChatModel(runtime.Model),
|
||||
}
|
||||
applyOpenAIChatWebSearch(runtime, ¶ms)
|
||||
if runtime.MaxOutputTokens > 0 {
|
||||
if NormalizeProviderName(runtime.Provider) == ProviderOpenAI {
|
||||
params.MaxCompletionTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
} else {
|
||||
params.MaxTokens = openai.Int(runtime.MaxOutputTokens)
|
||||
}
|
||||
}
|
||||
if runtime.Temperature != nil {
|
||||
params.Temperature = openai.Float(*runtime.Temperature)
|
||||
}
|
||||
if runtime.TopP != nil {
|
||||
params.TopP = openai.Float(*runtime.TopP)
|
||||
}
|
||||
if runtime.PresencePenalty != nil {
|
||||
params.PresencePenalty = openai.Float(*runtime.PresencePenalty)
|
||||
}
|
||||
if runtime.FrequencyPenalty != nil {
|
||||
params.FrequencyPenalty = openai.Float(*runtime.FrequencyPenalty)
|
||||
}
|
||||
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
|
||||
params.ReasoningEffort = effort
|
||||
}
|
||||
|
||||
response, err := client.chat.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, formatOpenAIError(runtime.Provider, err)
|
||||
}
|
||||
|
||||
content, finishReason := extractChatCompletionResult(response)
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
|
||||
}
|
||||
|
||||
model := fallbackString(response.Model, runtime.Model)
|
||||
return &Completion{
|
||||
Provider: runtime.Provider,
|
||||
Model: model,
|
||||
Content: content,
|
||||
ProviderMessageID: strings.TrimSpace(response.ID),
|
||||
FinishReason: finishReason,
|
||||
ContextWindowUsage: resolveContextWindowUsage(
|
||||
ctx,
|
||||
runtime,
|
||||
model,
|
||||
response.Usage.PromptTokens,
|
||||
response.Usage.CompletionTokens,
|
||||
response.Usage.TotalTokens,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildResponseInput(systemPrompt string, messages []providerMessage) openairesponses.ResponseInputParam {
|
||||
input := make(openairesponses.ResponseInputParam, 0, len(messages)+1)
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
input = append(input, openairesponses.ResponseInputItemParamOfMessage(strings.TrimSpace(systemPrompt), openairesponses.EasyInputMessageRoleSystem))
|
||||
}
|
||||
for _, message := range messages {
|
||||
input = append(input, openairesponses.ResponseInputItemParamOfMessage(message.Content, responseMessageRole(message.Role)))
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func buildChatMessages(systemPrompt string, messages []providerMessage) []openai.ChatCompletionMessageParamUnion {
|
||||
chatMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)+1)
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
chatMessages = append(chatMessages, openai.SystemMessage(strings.TrimSpace(systemPrompt)))
|
||||
}
|
||||
for _, message := range messages {
|
||||
switch strings.ToLower(strings.TrimSpace(message.Role)) {
|
||||
case "assistant":
|
||||
chatMessages = append(chatMessages, openai.AssistantMessage(message.Content))
|
||||
default:
|
||||
chatMessages = append(chatMessages, openai.UserMessage(message.Content))
|
||||
}
|
||||
}
|
||||
return chatMessages
|
||||
}
|
||||
|
||||
func responseMessageRole(role string) openairesponses.EasyInputMessageRole {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "assistant":
|
||||
return openairesponses.EasyInputMessageRoleAssistant
|
||||
default:
|
||||
return openairesponses.EasyInputMessageRoleUser
|
||||
}
|
||||
}
|
||||
|
||||
func extractChatCompletionResult(response *openai.ChatCompletion) (string, string) {
|
||||
if response == nil || len(response.Choices) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
for _, choice := range response.Choices {
|
||||
content := strings.TrimSpace(choice.Message.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
finishReason := strings.TrimSpace(choice.FinishReason)
|
||||
if finishReason == "" {
|
||||
finishReason = "completed"
|
||||
}
|
||||
return content, finishReason
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func responseFinishReason(response *openairesponses.Response) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if response.Status == openairesponses.ResponseStatusIncomplete && strings.TrimSpace(response.IncompleteDetails.Reason) != "" {
|
||||
return strings.TrimSpace(response.IncompleteDetails.Reason)
|
||||
}
|
||||
|
||||
finishReason := strings.TrimSpace(string(response.Status))
|
||||
if finishReason == "" {
|
||||
return "completed"
|
||||
}
|
||||
return finishReason
|
||||
}
|
||||
|
||||
func reasoningEffortForThinkingLevel(thinkingLevel string) (shared.ReasoningEffort, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
|
||||
case "low":
|
||||
return shared.ReasoningEffortLow, true
|
||||
case "medium":
|
||||
return shared.ReasoningEffortMedium, true
|
||||
case "high":
|
||||
return shared.ReasoningEffortHigh, true
|
||||
case "xhigh":
|
||||
return shared.ReasoningEffort("xhigh"), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func responseReasoningParam(runtime *RuntimeConfig) (shared.ReasoningParam, bool) {
|
||||
effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel)
|
||||
if !ok {
|
||||
return shared.ReasoningParam{}, false
|
||||
}
|
||||
|
||||
reasoning := shared.ReasoningParam{Effort: effort}
|
||||
switch NormalizeProviderName(runtime.Provider) {
|
||||
case ProviderOpenAI, ProviderOpenRouter:
|
||||
// Native OpenAI-style Responses backends can return a reasoning summary
|
||||
// for UI-only transcript blocks. Use "auto" because summary modes are
|
||||
// model-specific; hard-coding "concise" can suppress summaries on models
|
||||
// that only expose a different summarizer.
|
||||
reasoning.Summary = shared.ReasoningSummaryAuto
|
||||
}
|
||||
|
||||
return reasoning, true
|
||||
}
|
||||
|
||||
func formatOpenAIError(provider string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var apiErr *openai.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
message := strings.TrimSpace(apiErr.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(apiErr.RawJSON())
|
||||
}
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(err.Error())
|
||||
}
|
||||
if apiErr.StatusCode > 0 {
|
||||
return fmt.Errorf("%s API request failed with HTTP %d: %s", provider, apiErr.StatusCode, truncateForError(message))
|
||||
}
|
||||
if message != "" {
|
||||
return fmt.Errorf("%s API request failed: %s", provider, truncateForError(message))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
// AIDirName stores server-side AI helper data under the Sliver root.
|
||||
AIDirName = "ai"
|
||||
|
||||
// AIAliasesDirName stores aliases made available to the server-side AI agent.
|
||||
AIAliasesDirName = "aliases"
|
||||
|
||||
// AIExtensionsDirName stores extensions made available to the server-side AI agent.
|
||||
AIExtensionsDirName = "extensions"
|
||||
)
|
||||
|
||||
// GetAIDir returns the server-side AI data directory: ~/.sliver/ai
|
||||
func GetAIDir() string {
|
||||
return ensureAIDir(filepath.Join(GetRootAppDir(), AIDirName))
|
||||
}
|
||||
|
||||
// GetAIAliasesDir returns the server-side AI alias directory: ~/.sliver/ai/aliases
|
||||
func GetAIAliasesDir() string {
|
||||
return ensureAIDir(filepath.Join(GetAIDir(), AIAliasesDirName))
|
||||
}
|
||||
|
||||
// GetAIExtensionsDir returns the server-side AI extension directory: ~/.sliver/ai/extensions
|
||||
func GetAIExtensionsDir() string {
|
||||
return ensureAIDir(filepath.Join(GetAIDir(), AIExtensionsDirName))
|
||||
}
|
||||
|
||||
func ensureAIDir(dir string) string {
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
setupLog.Fatalf("Cannot create AI data dir %s", err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -776,6 +776,8 @@ func (s *SliverHTTPC2) stagerHandler(resp http.ResponseWriter, req *http.Request
|
||||
nonces, err := getNoncesFromURL(req.URL, 0)
|
||||
if err != nil {
|
||||
s.defaultHandler(resp, req)
|
||||
// Added return statement to prevent duplicate HTML when serving websites
|
||||
return
|
||||
}
|
||||
|
||||
for _, nonce := range nonces {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package c2
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2026 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
sliverEncoders "github.com/bishopfox/sliver/util/encoders"
|
||||
)
|
||||
|
||||
func TestDecodeReqBodyWithMaxLenRejectsOversizedPNG(t *testing.T) {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 1700, 1700))
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, img); err != nil {
|
||||
t.Fatalf("png encode failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := decodeReqBodyWithMaxLen(sliverEncoders.PNGEncoder{}, encoded.Bytes(), DefaultMaxUnauthBodyLength)
|
||||
if !errors.Is(err, sliverEncoders.ErrPNGTooLarge) {
|
||||
t.Fatalf("expected ErrPNGTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Loaded 100 of 674 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user