mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-08 01:38:17 -10:00
Compare commits
42
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 | ||
|
|
593925b4a5 |
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
-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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -95,6 +95,9 @@ type SliverClient struct {
|
||||
|
||||
connMu sync.Mutex
|
||||
grpcConn *grpc.ClientConn
|
||||
backgroundRPC rpcpb.SliverRPCClient
|
||||
backgroundConn *grpc.ClientConn
|
||||
backgroundDedicated bool
|
||||
connDetails *ConnectionDetails
|
||||
connCancel context.CancelFunc
|
||||
connWg *sync.WaitGroup
|
||||
@@ -326,6 +329,7 @@ func (con *SliverClient) applyConnectionHooksOnce() {
|
||||
|
||||
con.App.PreReadlineHooks = append(con.App.PreReadlineHooks, con.syncOutputHook)
|
||||
con.App.PostCmdRunHooks = append(con.App.PostCmdRunHooks, con.syncOutputHook)
|
||||
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, con.refreshDedicatedCommandConnectionHook)
|
||||
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, con.allowServerRootCommands)
|
||||
if shell := con.App.Shell(); shell != nil && shell.Completer != nil {
|
||||
baseCompleter := shell.Completer
|
||||
@@ -336,8 +340,12 @@ func (con *SliverClient) applyConnectionHooksOnce() {
|
||||
}
|
||||
}
|
||||
|
||||
func (con *SliverClient) startEventLoop(ctx context.Context) {
|
||||
eventStream, err := con.Rpc.Events(ctx, &commonpb.Empty{})
|
||||
func (con *SliverClient) startEventLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) {
|
||||
if rpc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
eventStream, err := rpc.Events(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
fmt.Printf("%s%s\n", Warn, err)
|
||||
return
|
||||
|
||||
+15
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -5,8 +5,8 @@ This document describes the technical design of Sliver.
|
||||
There are four major components to the Sliver ecosystem:
|
||||
|
||||
- **Server Console -** The server console is the main interface, which is started when you run the `sliver-server` executable. The server console is a superset of the client console. All code is shared between the client/server consoles except server-specific commands related to client (i.e., operator) management. The server console talks over an in-memory gRPC interface to the server.
|
||||
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network (i.e., multiplayer mode) over mutual TLS (mTLS).
|
||||
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the "mutliplayer" gRPC network listener.
|
||||
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network in multiplayer mode, either directly over mutual TLS (mTLS) or, by default, through a WireGuard-protected wrapper that carries the same gRPC/mTLS service.
|
||||
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the multiplayer gRPC network listener.
|
||||
- **Implant -** The implant is the actual malicious code run on the target system you want remote access to.
|
||||
|
||||
```
|
||||
@@ -17,7 +17,8 @@ There are four major components to the Sliver ecosystem:
|
||||
└─────────┘ └───────────────┘
|
||||
▲
|
||||
│
|
||||
│gRPC/mTLS
|
||||
│WireGuard (default)
|
||||
│or direct gRPC/mTLS
|
||||
│
|
||||
┌────┴────┐
|
||||
│ Sliver │
|
||||
@@ -25,4 +26,4 @@ There are four major components to the Sliver ecosystem:
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
By implementing all functionality over this gRPC interface, and only differing the in-memory/mTLS connection types the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
|
||||
By implementing all functionality over this gRPC interface, and only differing the in-memory or remote transport types, the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
|
||||
@@ -12,6 +12,7 @@ daemon:
|
||||
host: ""
|
||||
port: 31337
|
||||
tailscale: false
|
||||
enable_wg: false
|
||||
logs:
|
||||
level: 4
|
||||
grpc_unary_payloads: false
|
||||
@@ -51,8 +52,9 @@ cxx: {}
|
||||
- `daemon_mode` - Enable [daemon mode](/docs?name=Daemon+Mode).
|
||||
- `daemon` - Options for daemon mode.
|
||||
- `host` - Interface to bind the daemon listener to. Empty string means all interfaces.
|
||||
- `port` - TCP port for the daemon listener.
|
||||
- `tailscale` - Enable Tailscale for daemon listener setup.
|
||||
- `port` - Listen port for the multiplayer listener.
|
||||
- `tailscale` - Enable Tailscale for daemon listener setup. When enabled, Sliver does not wrap multiplayer in its own WireGuard layer.
|
||||
- `enable_wg` - Wrap multiplayer in WireGuard. If omitted or false, multiplayer is exposed directly over mTLS.
|
||||
- `logs` - Server logging options.
|
||||
- `level` - `logrus` level (`0`-`6`, clamped by Sliver). `4` is `INFO`, `5` is `DEBUG`, `6` is `TRACE`.
|
||||
- `grpc_unary_payloads` - Log gRPC unary payloads.
|
||||
@@ -140,8 +142,21 @@ For details on generating them, see [multiplayer mode](/docs?name=Multi-player+M
|
||||
"lport": 31337,
|
||||
"ca_certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
|
||||
"private_key": "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n",
|
||||
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
|
||||
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
|
||||
"wg": {
|
||||
"server_pub_key": "<multiplayer-wg-server-pubkey>",
|
||||
"client_private_key": "<operator-wg-private-key>",
|
||||
"client_pub_key": "<operator-wg-public-key>",
|
||||
"client_ip": "100.65.0.10",
|
||||
"server_ip": "100.65.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `operator` field is mostly a local label in the client. Server-side identity is derived from the client certificate (`CommonName`) and token material generated by the server. If you need to rename an operator, generate a new client configuration.
|
||||
|
||||
The `wg` block is optional:
|
||||
|
||||
- When present, `sliver-client` automatically brings up the multiplayer WireGuard wrapper and then dials the in-tunnel gRPC/mTLS service.
|
||||
- When absent, the client connects directly to the multiplayer mTLS listener.
|
||||
- Generate a WireGuard-enabled profile with `new-operator --enable-wg` or `sliver-server operator --enable-wg` when you want the multiplayer WireGuard wrapper.
|
||||
@@ -1,9 +1,20 @@
|
||||
Although the `sliver-client` is the default way to interact with a `sliver-server` and with implant sessions, there might be a time where you would want to automate some tasks upon reception of certain events.
|
||||
|
||||
To do so, one case use [sliver-script](https://github.com/moloch--/sliver-script), [sliver-py](https://github.com/moloch--/sliver-py) or write a custom client in another language. As all the communications between the client and the server are based on gRPC, any language with gRPC support should in theory be used to create a custom client.
|
||||
To do so, you can use [sliver-script](https://github.com/moloch--/sliver-script), [sliver-py](https://github.com/moloch--/sliver-py), or write a custom client in another language.
|
||||
|
||||
Current Sliver releases generate multiplayer operator configs with a `wg` block by default. The official `sliver-client`, and Go clients that reuse Sliver's `client/assets` plus `client/transport` packages, will automatically bring up that WireGuard wrapper before dialing the in-tunnel gRPC/mTLS service.
|
||||
|
||||
If you are writing a custom client in another language, you have two options:
|
||||
|
||||
- Implement the multiplayer WireGuard wrapper described in [multiplayer mode](/docs?name=Multi-player+Mode), then connect to the in-tunnel gRPC/mTLS service.
|
||||
- Keep multiplayer in direct mode by default, or explicitly opt into the wrapper with `multiplayer --enable-wg` or `sliver-server daemon --enable-wg`. Generate matching operator profiles with `new-operator --enable-wg` or `sliver-server operator --enable-wg` when you want the client-side WireGuard wrapper.
|
||||
|
||||
Once connected, the client/server API is still gRPC, so any language with gRPC support can in theory be used to create a custom client.
|
||||
|
||||
## Writing a Go client
|
||||
|
||||
This example uses Sliver's Go client libraries, so it works with either a direct multiplayer profile or a WireGuard-enabled profile.
|
||||
|
||||
In this example, we will focus on writing a custom Go client that executes a new system command on every new implant that connects to the sliver server.
|
||||
|
||||
Create a new Go project somewhere on your file system:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Sliver supports running in daemon mode, which automatically starts the multiplayer client listener (but not an interactive console). To connect to a daemonized server, use [multiplayer mode](/docs?name=Multi-player+Mode) operator configs.
|
||||
Sliver supports running in daemon mode, which automatically starts the multiplayer listener but does not provide an interactive console. To connect to a daemonized server, use [multiplayer mode](/docs?name=Multi-player+Mode) operator configs.
|
||||
|
||||
By default, daemon mode starts the same WireGuard-protected multiplayer listener used by the interactive `multiplayer` command. That means the outer listener is UDP on `31337` by default, while the authenticated gRPC/mTLS service stays inside the WireGuard tunnel.
|
||||
|
||||
There are two ways to run in daemon mode:
|
||||
|
||||
@@ -13,9 +15,11 @@ If `server.yaml` does not exist, Sliver generates it. If a legacy `server.json`
|
||||
- `-l, --lhost` multiplayer listener host
|
||||
- `-p, --lport` multiplayer listener port
|
||||
- `-t, --tailscale` enable Tailscale listener
|
||||
- `--enable-wg` wrap multiplayer in WireGuard instead of exposing it directly over mTLS
|
||||
- `-f, --force` force unpack static assets
|
||||
- For `sliver-server daemon`, `--lhost` and `--lport` override config values. If omitted, Sliver uses `daemon.host` and `daemon.port` from `server.yaml`.
|
||||
- For normal startup (`sliver-server`) with `daemon_mode: true`, Sliver uses `daemon.tailscale` from `server.yaml`.
|
||||
- For normal startup (`sliver-server`) with `daemon_mode: true`, Sliver uses `daemon.tailscale` and `daemon.enable_wg` from `server.yaml`.
|
||||
- With `--enable-wg` or `daemon.enable_wg: true`, multiplayer is wrapped in WireGuard. Otherwise it stays on direct TCP mTLS.
|
||||
|
||||
### Example Config
|
||||
|
||||
@@ -25,6 +29,7 @@ daemon:
|
||||
host: ""
|
||||
port: 31337
|
||||
tailscale: false
|
||||
enable_wg: false
|
||||
logs:
|
||||
level: 4
|
||||
grpc_unary_payloads: false
|
||||
@@ -44,6 +49,8 @@ Since daemon mode does not provide an interactive server console, generate opera
|
||||
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
|
||||
```
|
||||
|
||||
The `operator` CLI generates direct multiplayer profiles by default. Add `--enable-wg` when the daemon is running with the WireGuard wrapper.
|
||||
|
||||
### Shutdown Behavior
|
||||
|
||||
On Unix-like systems, daemon mode handles `SIGTERM` and exits cleanly.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
This script will install the latest version of Sliver as a systemd service, installs Windows cross-compiler dependencies (mingw), and setup multiplayer for the all local users. After running the script connect locally by running `sliver`.
|
||||
This script installs the latest version of Sliver as a systemd service, installs Windows cross-compiler dependencies (mingw), and sets up multiplayer for all local users. After running the script, connect locally by running `sliver`.
|
||||
|
||||
https://sliver.sh/install
|
||||
|
||||
This script should work on Kali, Ubuntu, and RHEL (CentOS, etc) distributions of Linux.
|
||||
|
||||
**⚠️ OPSEC:** By default the Linux install script will bind the multiplayer listener to `:31337` i.e. all interfaces. Ensure your firewalls are properly configured if this is a concern, or reconfigure the server to bind to localhost if you only wish to allow local users. Multiplayer Mode is authenticated using mTLS/gRPC and per-operator bearer tokens, so this doesn't present a security problem (notwithstanding bugs in our implementation), but publicly exposing your multiplayer listener will make it trivial to identify what the server is running.
|
||||
**⚠️ OPSEC:** By default the Linux install script will bind the multiplayer listener to `:31337` on all interfaces. In current releases that is the WireGuard-protected multiplayer listener, so the outer service is UDP/31337 and the authenticated gRPC/mTLS server only exists inside the tunnel. Ensure your firewalls are properly configured if this is a concern, or reconfigure the server to bind to localhost if you only wish to allow local users. Publicly exposing the multiplayer listener still makes the server easier to discover and fingerprint.
|
||||
|
||||
### One Liner
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Multiplayer-mode allows multiple operators (players) to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script) which will configure the server as a systemd service. However, any Sliver server binary supports multiplayer mode.
|
||||
Multiplayer mode allows multiple operators to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script), which configures the server as a systemd service. However, any `sliver-server` binary supports multiplayer mode.
|
||||
|
||||
Sliver exposes the operator-facing multiplayer listener directly over gRPC/mTLS by default. You can opt into a dedicated WireGuard wrapper with `--enable-wg`; the gRPC/mTLS authentication stack still runs inside that tunnel.
|
||||
|
||||
```
|
||||
┌──────────────────┐ C2
|
||||
@@ -8,7 +10,8 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
|
||||
└──────────────────┘
|
||||
▲
|
||||
│
|
||||
gRPC/mTLS │
|
||||
UDP/WireGuard (default) │
|
||||
gRPC/mTLS inside │
|
||||
┌────────────┬────────┴─────┬───────────┐
|
||||
│ │ │ │
|
||||
┌─────┴──┐ │ │ ┌──┴─────┐
|
||||
@@ -18,32 +21,39 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
|
||||
└────────┘ └────────┘
|
||||
```
|
||||
|
||||
## New Operators
|
||||
## Default WireGuard-Protected Mode
|
||||
|
||||
Operators will need to download their preferred platform's client software from the releases page. Clients can connect to servers of differing platforms (e.g. a Windows client can connect to a Linux server). Operators and servers authenticate using Mutual TLS, however all the certificates are managed automatically for you.
|
||||
|
||||
- If you did not install the server as a systemd service, be sure to leave the server console running in screen or [tmux](https://github.com/tmux/tmux) after starting the multiplayer listener.
|
||||
- If you did install the server as systemd service see the CLI section below for an alternative to the console commands for generating operator configs.
|
||||
- If you did install the server as a systemd service, see the CLI section below for an alternative to the console commands for generating operator configs.
|
||||
|
||||
Operators need a configuration file generated by the Sliver server to connect in multiplayer mode. The Sliver client can support multiple configuration files, but you can only be connected to one server at a time (per process).
|
||||
|
||||
From the server use the `new-operator` command to generate a new operator configuration file. You'll need to specify the operator's `--name` and the `--lhost` of the server (e.g., its public IP). Then from the server start a multiplayer listener using the `multiplayer` command to allow operators to connect:
|
||||
In the default mode:
|
||||
|
||||
**Note:** The `new-operator` and `multiplayer` commands are only accessible from the server's console
|
||||
- `multiplayer` exposes a WireGuard listener on `--lport` (default `31337`) over UDP.
|
||||
- The authenticated gRPC/mTLS server only listens inside that WireGuard tunnel.
|
||||
- `new-operator` generates a per-operator WireGuard keypair and tunnel IP, then embeds that information in the operator config's `wg` block.
|
||||
- The multiplayer listener uses WireGuard keys that are separate from Sliver's implant `wg` C2 listener.
|
||||
- `sliver-client` automatically uses the WireGuard wrapper when the imported config contains that `wg` block.
|
||||
|
||||
From the server, start the multiplayer listener and generate an operator config with the server's public address or hostname in `--lhost`. Use `--permissions all` for a standard interactive operator profile:
|
||||
|
||||
**Note:** The `new-operator` and `multiplayer` commands are only available from the server console.
|
||||
|
||||
```
|
||||
[server] sliver > new-operator --name moloch --lhost 1.2.3.4
|
||||
|
||||
[*] Generating new client certificate, please wait ...
|
||||
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
|
||||
|
||||
[server] sliver > multiplayer
|
||||
|
||||
[*] Multiplayer mode enabled!
|
||||
|
||||
[server] sliver > new-operator --name moloch --lhost 1.2.3.4 --permissions all
|
||||
|
||||
[*] Generating new client certificate, please wait ...
|
||||
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
|
||||
```
|
||||
|
||||
**IMPORTANT:** Before clients can connect to a server you must start an RPC listener with the `multiplayer` command. The default port is TCP/31337.
|
||||
**IMPORTANT:** Before clients can connect to a server you must start a multiplayer listener with the `multiplayer` command. By default multiplayer is exposed directly on TCP/31337. If you enable the wrapper, the outer listener becomes UDP/31337 and the gRPC/mTLS service stays inside the tunnel.
|
||||
|
||||
You can now give this configuration file `moloch_example.com.cfg` to the operator and they can connect to the server using the `sliver-client` binary. The Sliver client will look for configuration files in `~/.sliver-client/configs/` or you can import configs using the `import` CLI. The configs directory can contain multiple configs for different servers.
|
||||
|
||||
@@ -56,19 +66,48 @@ $ ./sliver-client
|
||||
localhost
|
||||
```
|
||||
|
||||
## Direct mTLS Mode
|
||||
|
||||
If you want the old behavior, or you need compatibility with third-party gRPC clients that do not implement the multiplayer WireGuard wrapper, disable it on both the listener and the generated operator profile:
|
||||
|
||||
```
|
||||
[server] sliver > multiplayer
|
||||
|
||||
[*] Multiplayer mode enabled!
|
||||
|
||||
[server] sliver > new-operator --name tester --lhost 1.2.3.4 --permissions all
|
||||
|
||||
[*] Generating new client certificate, please wait ...
|
||||
[*] Saved new client config to: /Users/tester/Desktop/tester_example.com.cfg
|
||||
```
|
||||
|
||||
In direct mode:
|
||||
|
||||
- Multiplayer is exposed directly over TCP on `--lport` (default `31337`).
|
||||
- Generated operator configs omit the `wg` block.
|
||||
- `sliver-client --enable-wg` opts into the multiplayer WireGuard wrapper when the operator config includes a `wg` block.
|
||||
|
||||
The listener mode, operator config, and client flag need to match. A WireGuard-wrapped listener needs a `wg` block in the operator profile plus `sliver-client --enable-wg`. Direct multiplayer works without the `wg` block and without the flag.
|
||||
|
||||
### Server CLI / Daemon Mode Multiplayer
|
||||
|
||||
If the server is running in daemon mode (as is the default with the Linux install script), that means the multiplayer listener is started for you without an interactive console. You can use the server binaries' CLI to generate operator configuration files to connect:
|
||||
If the server is running in daemon mode, the multiplayer listener is started for you without an interactive console. By default the daemon uses direct multiplayer mTLS. You can opt into the WireGuard wrapper with `sliver-server daemon --enable-wg` or `daemon.enable_wg: true` in `server.yaml`.
|
||||
|
||||
Use the server CLI to generate operator configuration files:
|
||||
|
||||
```
|
||||
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --save zer0cool.cfg
|
||||
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
|
||||
```
|
||||
|
||||
The installation scripts by default will place the `sliver-server` binary in `/root`.
|
||||
Operator profiles omit the multiplayer `wg` block by default. Add `--enable-wg` when the listener is wrapped in WireGuard.
|
||||
|
||||
The installation script places the `sliver-server` binary in `/root` by default.
|
||||
|
||||
### Tailscale Integration
|
||||
|
||||
Sliver v1.6 supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when wanting to maintain operational security.
|
||||
Sliver supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when you want to keep the multiplayer listener off the public Internet.
|
||||
|
||||
Tailscale mode is separate from the default multiplayer WireGuard wrapper. When you use `multiplayer -T`, Sliver exposes the listener only through Tailscale instead of layering its own multiplayer WireGuard tunnel on top.
|
||||
|
||||
```
|
||||
$ export TS_AUTHKEY=<tailscale authkey>
|
||||
@@ -77,4 +116,4 @@ $ ./sliver-server
|
||||
sliver > multiplayer -T
|
||||
```
|
||||
|
||||
You should now see a new Tailscale host named `sliver-server-<machine>`, you can set this using the `--lhost` flag. Use this as the hostname when connecting to the Sliver server from any other host on the Tailscale network.
|
||||
You should now see a new Tailscale host named `sliver-server-<machine>`. Use that hostname as `--lhost` when generating operator configs for other hosts on the same tailnet. Tailscale multiplayer does not use the multiplayer WireGuard wrapper, so operator profiles should be generated without `--enable-wg`.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -73,10 +73,32 @@ type GRPCConfig struct {
|
||||
|
||||
// DaemonConfig - Configure daemon mode
|
||||
type DaemonConfig struct {
|
||||
Host string `json:"host" yaml:"host"`
|
||||
Port int `json:"port" yaml:"port"`
|
||||
Tailscale bool `json:"tailscale" yaml:"tailscale"`
|
||||
DisableWG bool `json:"disable_wg" yaml:"disable_wg"`
|
||||
Host string `json:"host" yaml:"host"`
|
||||
Port int `json:"port" yaml:"port"`
|
||||
Tailscale bool `json:"tailscale" yaml:"tailscale"`
|
||||
EnableWG *bool `json:"enable_wg,omitempty" yaml:"enable_wg,omitempty"`
|
||||
LegacyDisableWG *bool `json:"disable_wg,omitempty" yaml:"disable_wg,omitempty"`
|
||||
}
|
||||
|
||||
func (c *DaemonConfig) WireGuardEnabled() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.EnableWG != nil {
|
||||
return *c.EnableWG
|
||||
}
|
||||
if c.LegacyDisableWG != nil {
|
||||
return !*c.LegacyDisableWG
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *DaemonConfig) SetWireGuardEnabled(enabled bool) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.EnableWG = &enabled
|
||||
c.LegacyDisableWG = nil
|
||||
}
|
||||
|
||||
// JobConfig - Restart Jobs on Load
|
||||
@@ -215,6 +237,7 @@ func (c *ServerConfig) Save() error {
|
||||
if err := c.AI.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeServerConfig(c)
|
||||
}
|
||||
|
||||
configPath := GetServerConfigPath()
|
||||
@@ -296,6 +319,7 @@ func GetServerConfig() *ServerConfig {
|
||||
defaultPermit := true
|
||||
config.GRPC.Keepalive.PermitWithoutStream = &defaultPermit
|
||||
}
|
||||
normalizeServerConfig(config)
|
||||
config.AI = getAIConfig(migratedAIConfig)
|
||||
|
||||
err := config.Save() // This updates the config with any missing fields
|
||||
@@ -346,3 +370,13 @@ func getDefaultServerConfig() *ServerConfig {
|
||||
CXX: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeServerConfig(config *ServerConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
if config.DaemonConfig == nil {
|
||||
config.DaemonConfig = &DaemonConfig{}
|
||||
}
|
||||
config.DaemonConfig.SetWireGuardEnabled(config.DaemonConfig.WireGuardEnabled())
|
||||
}
|
||||
@@ -21,6 +21,7 @@ daemon:
|
||||
host: "127.0.0.1"
|
||||
port: 4444
|
||||
tailscale: true
|
||||
enable_wg: true
|
||||
logs:
|
||||
level: 5
|
||||
grpc_unary_payloads: true
|
||||
@@ -99,6 +100,9 @@ openrouter:
|
||||
if config.DaemonConfig == nil || config.DaemonConfig.Port != 4444 {
|
||||
t.Fatalf("expected daemon port %d, got %v", 4444, config.DaemonConfig)
|
||||
}
|
||||
if !config.DaemonConfig.WireGuardEnabled() {
|
||||
t.Fatalf("expected daemon enable_wg true")
|
||||
}
|
||||
if config.Logs == nil || config.Logs.Level != 5 {
|
||||
t.Fatalf("expected logs level %d, got %v", 5, config.Logs)
|
||||
}
|
||||
@@ -202,6 +206,47 @@ openrouter:
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigMigratesLegacyDisableWG(t *testing.T) {
|
||||
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
|
||||
|
||||
configPath := GetServerConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
|
||||
t.Fatalf("failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
data := []byte(`daemon_mode: true
|
||||
daemon:
|
||||
host: "127.0.0.1"
|
||||
port: 4444
|
||||
disable_wg: false
|
||||
`)
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
t.Fatalf("failed to write yaml config: %v", err)
|
||||
}
|
||||
|
||||
config := GetServerConfig()
|
||||
if config.DaemonConfig == nil {
|
||||
t.Fatalf("expected daemon config")
|
||||
}
|
||||
if !config.DaemonConfig.WireGuardEnabled() {
|
||||
t.Fatalf("expected legacy disable_wg:false to migrate to enable_wg:true")
|
||||
}
|
||||
if config.DaemonConfig.LegacyDisableWG != nil {
|
||||
t.Fatalf("expected legacy disable_wg field to be cleared after normalization")
|
||||
}
|
||||
|
||||
saved, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read migrated config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(saved), "enable_wg: true") {
|
||||
t.Fatalf("expected migrated config to contain enable_wg: true, got:\n%s", string(saved))
|
||||
}
|
||||
if strings.Contains(string(saved), "disable_wg") {
|
||||
t.Fatalf("expected migrated config to drop legacy disable_wg field, got:\n%s", string(saved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigWritesDefault(t *testing.T) {
|
||||
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import (
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/bishopfox/sliver/server/console/forms"
|
||||
"github.com/bishopfox/sliver/server/core"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
@@ -82,11 +81,7 @@ func newOperatorCmd(cmd *cobra.Command, _ []string) {
|
||||
lport, _ := cmd.Flags().GetUint16("lport")
|
||||
save, _ := cmd.Flags().GetString("save")
|
||||
permissions, _ := cmd.Flags().GetStringSlice("permissions")
|
||||
includeWG := DefaultOperatorWireGuardEnabled()
|
||||
if cmd.Flags().Changed("disable-wg") {
|
||||
disableWG, _ := cmd.Flags().GetBool("disable-wg")
|
||||
includeWG = !disableWG
|
||||
}
|
||||
includeWG, _ := cmd.Flags().GetBool("enable-wg")
|
||||
|
||||
if save == "" {
|
||||
save, _ = os.Getwd()
|
||||
@@ -117,34 +112,6 @@ func newOperatorCmd(cmd *cobra.Command, _ []string) {
|
||||
fmt.Printf(Info+"Saved new client config to: %s \n", saveTo)
|
||||
}
|
||||
|
||||
// DefaultOperatorWireGuardEnabled returns the default wireguard mode for new
|
||||
// operator configs based on the current server multiplayer exposure.
|
||||
func DefaultOperatorWireGuardEnabled() bool {
|
||||
serverConfig := configs.GetServerConfig()
|
||||
if serverConfig != nil && serverConfig.DaemonMode && serverConfig.DaemonConfig != nil {
|
||||
if serverConfig.DaemonConfig.Tailscale {
|
||||
return false
|
||||
}
|
||||
return !serverConfig.DaemonConfig.DisableWG
|
||||
}
|
||||
|
||||
listenerJobs, err := db.ListenerJobs()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, listenerJob := range listenerJobs {
|
||||
if listenerJob == nil || listenerJob.Type != consts.MultiplayerModeStr {
|
||||
continue
|
||||
}
|
||||
config, err := db.ListenerByJobID(listenerJob.JobID)
|
||||
if err != nil || config == nil || config.MultiConf == nil {
|
||||
continue
|
||||
}
|
||||
return config.MultiConf.WireGuard
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NewOperatorConfig - Generate a new player/client/operator configuration
|
||||
func NewOperatorConfig(operatorName string, lhost string, lport uint16, permissions []string, includeWG bool) ([]byte, error) {
|
||||
if !namePattern.MatchString(operatorName) {
|
||||
@@ -438,8 +405,8 @@ func startMultiplayerModeCmd(cmd *cobra.Command, _ []string) {
|
||||
lhost, _ := cmd.Flags().GetString("lhost")
|
||||
lport, _ := cmd.Flags().GetUint16("lport")
|
||||
tailscale, _ := cmd.Flags().GetBool("tailscale")
|
||||
disableWG, _ := cmd.Flags().GetBool("disable-wg")
|
||||
useWireGuard := !disableWG && !tailscale
|
||||
enableWG, _ := cmd.Flags().GetBool("enable-wg")
|
||||
useWireGuard := enableWG && !tailscale
|
||||
|
||||
var err error
|
||||
var jobID int
|
||||
|
||||
@@ -4,17 +4,25 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clientassets "github.com/bishopfox/sliver/client/assets"
|
||||
clienttransport "github.com/bishopfox/sliver/client/transport"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
servertransport "github.com/bishopfox/sliver/server/transport"
|
||||
"google.golang.org/grpc"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -22,7 +30,7 @@ func TestNewOperatorConfigWithWireGuardConnectsToWrappedMultiplayer(t *testing.T
|
||||
certs.SetupCAs()
|
||||
certs.SetupWGKeys()
|
||||
certs.SetupMultiplayerWGKeys()
|
||||
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectAuto)
|
||||
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
|
||||
|
||||
operatorName := uniqueKickOperatorName(t)
|
||||
t.Cleanup(func() {
|
||||
@@ -116,6 +124,228 @@ func TestNewOperatorConfigWithWireGuardConnectsToWrappedMultiplayer(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOperatorConfigWithWireGuardConnectsToWrappedMultiplayerRepeatedly(t *testing.T) {
|
||||
certs.SetupCAs()
|
||||
certs.SetupWGKeys()
|
||||
certs.SetupMultiplayerWGKeys()
|
||||
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
|
||||
|
||||
operatorName := uniqueKickOperatorName(t)
|
||||
t.Cleanup(func() {
|
||||
_ = removeOperator(operatorName)
|
||||
_ = revokeOperatorClientCertificate(operatorName)
|
||||
closeOperatorStreams(operatorName)
|
||||
})
|
||||
|
||||
port := freeUDPPort(t)
|
||||
configJSON, err := NewOperatorConfig(operatorName, "127.0.0.1", uint16(port), []string{"all"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("generate wireguard operator config: %v", err)
|
||||
}
|
||||
|
||||
config := &clientassets.ClientConfig{}
|
||||
if err := json.Unmarshal(configJSON, config); err != nil {
|
||||
t.Fatalf("parse operator config: %v", err)
|
||||
}
|
||||
|
||||
grpcServer, ln, err := servertransport.StartWGWrappedMtlsClientListener("127.0.0.1", uint16(port))
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "operation not permitted") {
|
||||
t.Skipf("wireguard listener bind not permitted in this environment: %v", err)
|
||||
}
|
||||
t.Fatalf("start wrapped multiplayer listener: %v", err)
|
||||
}
|
||||
defer grpcServer.Stop()
|
||||
defer ln.Close()
|
||||
|
||||
for attempt := 1; attempt <= 5; attempt++ {
|
||||
rpcClient, conn, err := clienttransport.MTLSConnect(config)
|
||||
if err != nil {
|
||||
t.Fatalf("attempt %d: connect operator through wireguard wrapper: %v", attempt, err)
|
||||
}
|
||||
if _, err := rpcClient.GetVersion(context.Background(), &commonpb.Empty{}); err != nil {
|
||||
_ = clienttransport.CloseGRPCConnection(conn)
|
||||
t.Fatalf("attempt %d: GetVersion over wrapped multiplayer failed: %v", attempt, err)
|
||||
}
|
||||
if _, err := rpcClient.GetOperators(context.Background(), &commonpb.Empty{}); err != nil {
|
||||
_ = clienttransport.CloseGRPCConnection(conn)
|
||||
t.Fatalf("attempt %d: GetOperators over wrapped multiplayer failed: %v", attempt, err)
|
||||
}
|
||||
if err := clienttransport.CloseGRPCConnection(conn); err != nil {
|
||||
t.Fatalf("attempt %d: close wrapped multiplayer connection: %v", attempt, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedMultiplayerWireGuardSupportsUnaryRPCsWithDedicatedCommandConnection(t *testing.T) {
|
||||
t.Run("events-only", func(t *testing.T) {
|
||||
runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t, true, false)
|
||||
})
|
||||
t.Run("tunnel-only", func(t *testing.T) {
|
||||
runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t, false, true)
|
||||
})
|
||||
t.Run("events-and-tunnel", func(t *testing.T) {
|
||||
runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t, true, true)
|
||||
})
|
||||
}
|
||||
|
||||
func runWrappedMultiplayerWireGuardUnaryWithBackgroundStreams(t *testing.T, useEvents bool, useTunnel bool) {
|
||||
certs.SetupCAs()
|
||||
certs.SetupWGKeys()
|
||||
certs.SetupMultiplayerWGKeys()
|
||||
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
|
||||
|
||||
operatorName := uniqueKickOperatorName(t)
|
||||
t.Cleanup(func() {
|
||||
_ = removeOperator(operatorName)
|
||||
_ = revokeOperatorClientCertificate(operatorName)
|
||||
closeOperatorStreams(operatorName)
|
||||
})
|
||||
|
||||
port := freeUDPPort(t)
|
||||
configJSON, err := NewOperatorConfig(operatorName, "127.0.0.1", uint16(port), []string{"all"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("generate wireguard operator config: %v", err)
|
||||
}
|
||||
|
||||
config := &clientassets.ClientConfig{}
|
||||
if err := json.Unmarshal(configJSON, config); err != nil {
|
||||
t.Fatalf("parse operator config: %v", err)
|
||||
}
|
||||
|
||||
grpcServer, ln, err := servertransport.StartWGWrappedMtlsClientListener("127.0.0.1", uint16(port))
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "operation not permitted") {
|
||||
t.Skipf("wireguard listener bind not permitted in this environment: %v", err)
|
||||
}
|
||||
t.Fatalf("start wrapped multiplayer listener: %v", err)
|
||||
}
|
||||
defer grpcServer.Stop()
|
||||
defer ln.Close()
|
||||
|
||||
var streamClient rpcpb.SliverRPCClient
|
||||
var streamConn *grpc.ClientConn
|
||||
if useEvents || useTunnel {
|
||||
streamClient, streamConn, err = clienttransport.MTLSConnect(config)
|
||||
if err != nil {
|
||||
t.Fatalf("connect dedicated background operator through wireguard wrapper: %v", err)
|
||||
}
|
||||
defer clienttransport.CloseGRPCConnection(streamConn)
|
||||
}
|
||||
|
||||
streamCtx, streamCancel := context.WithCancel(context.Background())
|
||||
defer streamCancel()
|
||||
|
||||
if useEvents {
|
||||
events, err := streamClient.Events(streamCtx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
t.Fatalf("open events stream: %v", err)
|
||||
}
|
||||
go drainWGTestStream(t, "events", func() error {
|
||||
_, err := events.Recv()
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if useTunnel {
|
||||
tunnelData, err := streamClient.TunnelData(streamCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("open tunnel data stream: %v", err)
|
||||
}
|
||||
go drainWGTestStream(t, "tunnel-data", func() error {
|
||||
_, err := tunnelData.Recv()
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// Let the background streams settle before issuing the unary call. The
|
||||
// interactive console opens these streams first and only later runs
|
||||
// unary RPC-backed commands like "operators".
|
||||
if useEvents || useTunnel {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
rpcClient, conn, err := clienttransport.MTLSConnect(config)
|
||||
if err != nil {
|
||||
t.Fatalf("connect operator command channel through wireguard wrapper: %v", err)
|
||||
}
|
||||
defer clienttransport.CloseGRPCConnection(conn)
|
||||
|
||||
callCtx, callCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer callCancel()
|
||||
if _, err := rpcClient.GetOperators(callCtx, &commonpb.Empty{}); err != nil {
|
||||
t.Fatalf("GetOperators with dedicated command connection over wrapped multiplayer failed (events=%t tunnel=%t): %v", useEvents, useTunnel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorCLIWireGuardConfigConnectsToWrappedMultiplayer(t *testing.T) {
|
||||
certs.SetupCAs()
|
||||
certs.SetupWGKeys()
|
||||
certs.SetupMultiplayerWGKeys()
|
||||
clienttransport.SetMultiplayerConnectMode(clienttransport.MultiplayerConnectEnableWG)
|
||||
|
||||
port := freeUDPPort(t)
|
||||
grpcServer, ln, err := servertransport.StartWGWrappedMtlsClientListener("127.0.0.1", uint16(port))
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "operation not permitted") {
|
||||
t.Skipf("wireguard listener bind not permitted in this environment: %v", err)
|
||||
}
|
||||
t.Fatalf("start wrapped multiplayer listener: %v", err)
|
||||
}
|
||||
defer grpcServer.Stop()
|
||||
defer ln.Close()
|
||||
|
||||
operatorName := uniqueKickOperatorName(t)
|
||||
t.Cleanup(func() {
|
||||
_ = removeOperator(operatorName)
|
||||
_ = revokeOperatorClientCertificate(operatorName)
|
||||
closeOperatorStreams(operatorName)
|
||||
})
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get working directory: %v", err)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
savePath := filepath.Join(t.TempDir(), operatorName+".cfg")
|
||||
|
||||
cmd := exec.Command(
|
||||
"go", "run", "-tags=server,go_sqlite", "./server", "operator",
|
||||
"--name", operatorName,
|
||||
"--lhost", "127.0.0.1",
|
||||
"--lport", strconv.Itoa(port),
|
||||
"--permissions", "all",
|
||||
"--enable-wg",
|
||||
"--save", savePath,
|
||||
)
|
||||
cmd.Dir = repoRoot
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("generate operator config through CLI: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
configJSON, err := os.ReadFile(savePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated operator config: %v", err)
|
||||
}
|
||||
config := &clientassets.ClientConfig{}
|
||||
if err := json.Unmarshal(configJSON, config); err != nil {
|
||||
t.Fatalf("parse operator config: %v", err)
|
||||
}
|
||||
if config.WG == nil {
|
||||
t.Fatal("expected wireguard config block to be present")
|
||||
}
|
||||
|
||||
rpcClient, conn, err := clienttransport.MTLSConnect(config)
|
||||
if err != nil {
|
||||
t.Fatalf("connect operator through wireguard wrapper after CLI generation: %v", err)
|
||||
}
|
||||
defer clienttransport.CloseGRPCConnection(conn)
|
||||
|
||||
if _, err := rpcClient.GetVersion(context.Background(), &commonpb.Empty{}); err != nil {
|
||||
t.Fatalf("GetVersion over wrapped multiplayer failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickOperatorReleasesWireGuardTunnelIPReservation(t *testing.T) {
|
||||
certs.SetupCAs()
|
||||
certs.SetupMultiplayerWGKeys()
|
||||
@@ -167,3 +397,16 @@ func freeUDPPort(t *testing.T) int {
|
||||
defer ln.Close()
|
||||
return ln.LocalAddr().(*net.UDPAddr).Port
|
||||
}
|
||||
|
||||
func drainWGTestStream(t *testing.T, name string, recv func() error) {
|
||||
t.Helper()
|
||||
|
||||
err := recv()
|
||||
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "context canceled") {
|
||||
return
|
||||
}
|
||||
t.Errorf("%s stream recv failed: %v", name, err)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func serverOnlyCmds() (commands []*cobra.Command) {
|
||||
f.StringP("lhost", "L", "", "interface to bind server to")
|
||||
f.Uint16P("lport", "l", 31337, "tcp listen port")
|
||||
f.BoolP("tailscale", "T", false, "only expose multiplayer interface over Tailscale (requires TS_AUTHKEY)")
|
||||
f.Bool("disable-wg", false, "expose multiplayer directly instead of wrapping it in WireGuard")
|
||||
f.Bool("enable-wg", false, "wrap the multiplayer listener in WireGuard")
|
||||
})
|
||||
|
||||
commands = append(commands, startMultiplayer)
|
||||
@@ -93,7 +93,7 @@ func serverOnlyCmds() (commands []*cobra.Command) {
|
||||
f.StringP("save", "s", "", "directory/file in which to save config")
|
||||
f.StringP("name", "n", "", "operator name")
|
||||
f.StringSliceP("permissions", "P", []string{}, "grant permissions to the operator profile (all, builder, crackstation)")
|
||||
f.Bool("disable-wg", false, "omit WireGuard tunnel settings from the generated operator config")
|
||||
f.Bool("enable-wg", false, "include WireGuard tunnel settings in the generated operator config")
|
||||
})
|
||||
command.BindFlagCompletions(newOperator, func(comp *carapace.ActionMap) {
|
||||
(*comp)["save"] = carapace.ActionDirectories()
|
||||
@@ -133,9 +133,8 @@ Create a new operator config file, operator configuration files allow
|
||||
remote machines to connect to the Sliver server. They are most commonly
|
||||
used for allowing remote operators to connect in "Multiplayer Mode."
|
||||
|
||||
By default, operator configs now include a WireGuard wrapper for the
|
||||
multiplayer listener; use --disable-wg if the listener is being exposed
|
||||
directly instead.
|
||||
Operator configs use direct multiplayer mTLS by default. Add --enable-wg
|
||||
when the multiplayer listener is wrapped in WireGuard.
|
||||
|
||||
To generate a profile for a remote operator, you need to specify the
|
||||
"all" permission to grant the profile access to all gRPC APIs:
|
||||
|
||||
+19
-4
@@ -28,7 +28,8 @@ const (
|
||||
)
|
||||
|
||||
// Event - An event is fired when there's a state change involving a
|
||||
// session, job, or client.
|
||||
//
|
||||
// session, job, or client.
|
||||
type Event struct {
|
||||
Session *Session
|
||||
Job *Job
|
||||
@@ -62,15 +63,30 @@ func (broker *eventBroker) Start() {
|
||||
case sub := <-broker.subscribe:
|
||||
subscribers[sub] = struct{}{}
|
||||
case sub := <-broker.unsubscribe:
|
||||
delete(subscribers, sub)
|
||||
if _, ok := subscribers[sub]; ok {
|
||||
delete(subscribers, sub)
|
||||
close(sub)
|
||||
}
|
||||
case event := <-broker.publish:
|
||||
for sub := range subscribers {
|
||||
sub <- event
|
||||
safeBrokerSend(sub, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safeBrokerSend sends an event to a subscriber, recovering from a closed-channel
|
||||
// panic if the subscriber dropped its end without unsubscribing first (#2252).
|
||||
func safeBrokerSend(sub chan Event, event Event) {
|
||||
defer func() { _ = recover() }()
|
||||
select {
|
||||
case sub <- event:
|
||||
default:
|
||||
// Subscriber's buffer is full or it's gone; drop the event rather
|
||||
// than block the broker.
|
||||
}
|
||||
}
|
||||
|
||||
// Stop - Close the broker channel
|
||||
func (broker *eventBroker) Stop() {
|
||||
close(broker.stop)
|
||||
@@ -86,7 +102,6 @@ func (broker *eventBroker) Subscribe() chan Event {
|
||||
// Unsubscribe - Remove a subscription channel
|
||||
func (broker *eventBroker) Unsubscribe(events chan Event) {
|
||||
broker.unsubscribe <- events
|
||||
close(events)
|
||||
}
|
||||
|
||||
// Publish - Push a message to all subscribers
|
||||
|
||||
@@ -41,7 +41,7 @@ var (
|
||||
)
|
||||
|
||||
// Start - Start as daemon process
|
||||
func Start(host string, port uint16, tailscale bool, disableWG bool) {
|
||||
func Start(host string, port uint16, tailscale bool, enableWG bool) {
|
||||
var (
|
||||
ln net.Listener
|
||||
err error
|
||||
@@ -59,7 +59,7 @@ func Start(host string, port uint16, tailscale bool, disableWG bool) {
|
||||
daemonLog.Infof("Starting Sliver daemon %s:%d ...", host, port)
|
||||
if tailscale {
|
||||
_, ln, err = transport.StartTsNetClientListener(host, port)
|
||||
} else if !disableWG {
|
||||
} else if enableWG {
|
||||
_, ln, err = transport.StartWGWrappedMtlsClientListener(host, port)
|
||||
} else {
|
||||
_, ln, err = transport.StartMtlsClientListener(host, port)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
serverai "github.com/bishopfox/sliver/server/ai"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/bishopfox/sliver/server/core"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -26,6 +27,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const aiConversationEventStreamTimeout = 30 * time.Second
|
||||
|
||||
func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *testing.T) {
|
||||
setupAIRPCTestEnv(t)
|
||||
|
||||
@@ -69,9 +72,9 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
|
||||
client, cleanup := newBufnetRPCClient(t)
|
||||
defer cleanup()
|
||||
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
|
||||
defer cancelStream()
|
||||
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
|
||||
eventStream, err := startAIEventStream(t, client, streamCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("start events stream: %v", err)
|
||||
}
|
||||
@@ -242,9 +245,9 @@ func TestSaveAIConversationMessageCompletesOpenAIWithoutExplicitBaseURL(t *testi
|
||||
client, cleanup := newBufnetRPCClient(t)
|
||||
defer cleanup()
|
||||
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
|
||||
defer cancelStream()
|
||||
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
|
||||
eventStream, err := startAIEventStream(t, client, streamCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("start events stream: %v", err)
|
||||
}
|
||||
@@ -332,9 +335,9 @@ func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *t
|
||||
client, cleanup := newBufnetRPCClient(t)
|
||||
defer cleanup()
|
||||
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
|
||||
defer cancelStream()
|
||||
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
|
||||
eventStream, err := startAIEventStream(t, client, streamCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("start events stream: %v", err)
|
||||
}
|
||||
@@ -622,9 +625,9 @@ func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
|
||||
client, cleanup := newBufnetRPCClient(t)
|
||||
defer cleanup()
|
||||
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
streamCtx, cancelStream := context.WithTimeout(context.Background(), aiConversationEventStreamTimeout)
|
||||
defer cancelStream()
|
||||
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
|
||||
eventStream, err := startAIEventStream(t, client, streamCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("start events stream: %v", err)
|
||||
}
|
||||
@@ -933,6 +936,27 @@ func saveOpenAICompletionConfig(t *testing.T, model string, thinkingLevel string
|
||||
}
|
||||
}
|
||||
|
||||
func startAIEventStream(t *testing.T, client rpcpb.SliverRPCClient, streamCtx context.Context) (rpcpb.SliverRPC_EventsClient, error) {
|
||||
t.Helper()
|
||||
|
||||
before := len(core.Clients.ActiveOperators())
|
||||
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(core.Clients.ActiveOperators()) > before {
|
||||
return eventStream, nil
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("event stream did not subscribe before deadline")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func waitForAIConversationEvent(t *testing.T, eventStream rpcpb.SliverRPC_EventsClient, conversationID string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
@@ -89,21 +90,33 @@ func StartMtlsClientServer(ln net.Listener) (*grpc.Server, error) {
|
||||
grpcServer := grpc.NewServer(options...)
|
||||
rpcpb.RegisterSliverRPCServer(grpcServer, rpc.NewServer())
|
||||
go func() {
|
||||
panicked := true
|
||||
defer func() {
|
||||
if panicked {
|
||||
mtlsLog.Errorf("stacktrace from panic: %s", string(debug.Stack()))
|
||||
if r := recover(); r != nil {
|
||||
mtlsLog.Errorf("gRPC server panic: %v\n%s", r, string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
if err := grpcServer.Serve(ln); err != nil {
|
||||
if err := grpcServer.Serve(ln); err != nil && !isExpectedGRPCServerExit(err) {
|
||||
mtlsLog.Warnf("gRPC server exited with error: %v", err)
|
||||
} else {
|
||||
panicked = false
|
||||
}
|
||||
}()
|
||||
return grpcServer, nil
|
||||
}
|
||||
|
||||
func isExpectedGRPCServerExit(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, grpc.ErrServerStopped) || errors.Is(err, net.ErrClosed) {
|
||||
return true
|
||||
}
|
||||
|
||||
// The gVisor-backed listener used by the WireGuard multiplayer transport can
|
||||
// surface this accept error during normal shutdown on Windows.
|
||||
errString := err.Error()
|
||||
return strings.Contains(errString, "use of closed network connection") ||
|
||||
strings.Contains(errString, "endpoint is in invalid state")
|
||||
}
|
||||
|
||||
// getOperatorServerTLSConfig - Generate the TLS configuration, we do now allow the end user
|
||||
// to specify any TLS parameters, we choose sensible defaults instead
|
||||
func getOperatorServerTLSConfig(host string) *tls.Config {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
@@ -19,19 +20,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
multiplayerWireGuardMTU = 1420
|
||||
multiplayerWireGuardMTU = 1420
|
||||
multiplayerPeerSyncInterval = time.Second
|
||||
)
|
||||
|
||||
type wireGuardWrappedClientListener struct {
|
||||
net.Listener
|
||||
dev *device.Device
|
||||
events chan core.Event
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (l *wireGuardWrappedClientListener) Close() error {
|
||||
var err error
|
||||
l.once.Do(func() {
|
||||
if l.closed != nil {
|
||||
close(l.closed)
|
||||
l.closed = nil
|
||||
}
|
||||
if l.events != nil {
|
||||
core.EventBroker.Unsubscribe(l.events)
|
||||
l.events = nil
|
||||
@@ -47,23 +54,49 @@ func (l *wireGuardWrappedClientListener) Close() error {
|
||||
}
|
||||
|
||||
func (l *wireGuardWrappedClientListener) processPeerEvents() {
|
||||
if l == nil || l.events == nil || l.dev == nil {
|
||||
if l == nil || l.events == nil || l.dev == nil || l.closed == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for event := range l.events {
|
||||
switch event.EventType {
|
||||
case consts.MultiplayerWireGuardNewPeer, consts.MultiplayerWireGuardRemoved:
|
||||
if len(event.Data) == 0 {
|
||||
continue
|
||||
ticker := time.NewTicker(multiplayerPeerSyncInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.closed:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := l.syncPeersFromDB(); err != nil {
|
||||
mtlsLog.Errorf("Failed to resync multiplayer wireguard peers from the database: %v", err)
|
||||
}
|
||||
if err := l.dev.IpcSetOperation(bufio.NewReader(bytes.NewReader(event.Data))); err != nil {
|
||||
mtlsLog.Errorf("Failed to update multiplayer wireguard config: %v", err)
|
||||
case event, ok := <-l.events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch event.EventType {
|
||||
case consts.MultiplayerWireGuardNewPeer, consts.MultiplayerWireGuardRemoved:
|
||||
if len(event.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := l.dev.IpcSetOperation(bufio.NewReader(bytes.NewReader(event.Data))); err != nil {
|
||||
mtlsLog.Errorf("Failed to update multiplayer wireguard config: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *wireGuardWrappedClientListener) syncPeersFromDB() error {
|
||||
if l == nil || l.dev == nil {
|
||||
return nil
|
||||
}
|
||||
peerConfig, err := operatorWGPeerConfig(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return l.dev.IpcSetOperation(bufio.NewReader(peerConfig))
|
||||
}
|
||||
|
||||
// StartWGWrappedMtlsClientListener exposes the multiplayer mTLS listener only
|
||||
// inside a WireGuard tunnel while preserving the existing gRPC/mTLS auth stack.
|
||||
func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, net.Listener, error) {
|
||||
@@ -95,10 +128,12 @@ func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, n
|
||||
fmt.Fprintf(wgConf, "private_key=%s\n", privateKey)
|
||||
fmt.Fprintf(wgConf, "listen_port=%d\n", port)
|
||||
|
||||
if err := appendOperatorWGPeers(wgConf); err != nil {
|
||||
peerConfig, err := operatorWGPeerConfig(false)
|
||||
if err != nil {
|
||||
dev.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
wgConf.Write(peerConfig.Bytes())
|
||||
if err := dev.IpcSetOperation(bufio.NewReader(wgConf)); err != nil {
|
||||
dev.Close()
|
||||
return nil, nil, err
|
||||
@@ -118,6 +153,7 @@ func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, n
|
||||
Listener: innerListener,
|
||||
dev: dev,
|
||||
events: core.EventBroker.Subscribe(),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go wrappedListener.processPeerEvents()
|
||||
|
||||
@@ -129,6 +165,17 @@ func StartWGWrappedMtlsClientListener(host string, port uint16) (*grpc.Server, n
|
||||
return grpcServer, wrappedListener, nil
|
||||
}
|
||||
|
||||
func operatorWGPeerConfig(replace bool) (*bytes.Buffer, error) {
|
||||
dst := bytes.NewBuffer(nil)
|
||||
if replace {
|
||||
fmt.Fprint(dst, "replace_peers=true\n")
|
||||
}
|
||||
if err := appendOperatorWGPeers(dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func appendOperatorWGPeers(dst *bytes.Buffer) error {
|
||||
peers, err := certs.GetOperatorWGPeers()
|
||||
if err != nil && err != certs.ErrWGPeerDoesNotExist {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package assets
|
||||
|
||||
const (
|
||||
goVersion = "1.25.8"
|
||||
garbleVersion = "1.25.8"
|
||||
goVersion = "1.26.2"
|
||||
garbleVersion = "1.26.2"
|
||||
zigVersion = "0.15.2"
|
||||
zigSourceParam = "source=sliver"
|
||||
|
||||
|
||||
+54
-3
@@ -20,6 +20,8 @@ package encoders
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
@@ -36,11 +38,22 @@ const (
|
||||
|
||||
// we can shove three bytes into each pixel: R, G, and B.
|
||||
bytesPerPixel = 3
|
||||
|
||||
// DefaultMaxPNGDecodeLen is the maximum amount of pixel data Decode will
|
||||
// process. Callers handling untrusted data should use DecodeWithMaxLen with
|
||||
// a limit appropriate for the request they are processing.
|
||||
DefaultMaxPNGDecodeLen = 2 * 1024 * 1024 * 1024 // 2GB
|
||||
)
|
||||
|
||||
// ErrPNGTooLarge is returned when a PNG's dimensions or decoded payload exceed
|
||||
// the decode limit.
|
||||
var ErrPNGTooLarge = errors.New("png decoded payload exceeds maximum size")
|
||||
|
||||
// PNGEncoder - PNG image object
|
||||
type PNGEncoder struct{}
|
||||
|
||||
var _ LimitedDecoder = PNGEncoder{}
|
||||
|
||||
// Encode outputs a valid PNG file
|
||||
func (p PNGEncoder) Encode(data []byte) ([]byte, error) {
|
||||
img := imageFromBytes(data)
|
||||
@@ -54,12 +67,50 @@ func (p PNGEncoder) Encode(data []byte) ([]byte, error) {
|
||||
|
||||
// Decode reads a encoded PNG to get the original binary data
|
||||
func (p PNGEncoder) Decode(data []byte) ([]byte, error) {
|
||||
buf := bytes.NewBuffer(data)
|
||||
img, err := png.Decode(buf)
|
||||
return p.DecodeWithMaxLen(data, DefaultMaxPNGDecodeLen)
|
||||
}
|
||||
|
||||
// DecodeWithMaxLen reads an encoded PNG while limiting both the returned
|
||||
// payload and the three-byte-per-pixel representation used during decoding.
|
||||
// Since padding and escape sequences are removed after extracting the pixels,
|
||||
// this may conservatively reject an image whose final payload would fit.
|
||||
func (p PNGEncoder) DecodeWithMaxLen(data []byte, maxLen int64) ([]byte, error) {
|
||||
if maxLen < 0 {
|
||||
return nil, fmt.Errorf("invalid max decode length: %d", maxLen)
|
||||
}
|
||||
|
||||
config, err := png.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytesFromImage(img), nil
|
||||
if err := validatePNGDimensions(config.Width, config.Height, maxLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoded := bytesFromImage(img)
|
||||
if int64(len(decoded)) > maxLen {
|
||||
return nil, fmt.Errorf("%w: decoded payload is %d bytes, limit is %d", ErrPNGTooLarge, len(decoded), maxLen)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func validatePNGDimensions(width int, height int, maxLen int64) error {
|
||||
if width <= 0 || height <= 0 {
|
||||
return fmt.Errorf("invalid PNG dimensions: %dx%d", width, height)
|
||||
}
|
||||
|
||||
// bytesFromImage materializes three bytes for every pixel before removing
|
||||
// padding and escape sequences. Bound that intermediate representation
|
||||
// before png.Decode allocates storage based on attacker-controlled dimensions.
|
||||
maxPixels := maxLen / int64(bytesPerPixel)
|
||||
if int64(width) > maxPixels/int64(height) {
|
||||
return fmt.Errorf("%w: %dx%d image exceeds %d bytes", ErrPNGTooLarge, width, height, maxLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// imageFromBytes returns a valid image with data encoded in each pixel
|
||||
|
||||
@@ -20,6 +20,9 @@ package encoders
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -62,3 +65,77 @@ func TestPNGRandomDataRandomSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGDecodeWithMaxLen(t *testing.T) {
|
||||
const maxLen = 4 * bytesPerPixel
|
||||
payload := []byte{0x00, 0x01, 0x02, 0x03, 0x04}
|
||||
pngEncoder := new(PNGEncoder)
|
||||
encoded, err := pngEncoder.Encode(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("png encode failed: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := pngEncoder.DecodeWithMaxLen(encoded, maxLen)
|
||||
if err != nil {
|
||||
t.Fatalf("png decode failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(payload, decoded) {
|
||||
t.Fatalf("decoded payload does not match original: %q != %q", decoded, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGDecodeWithMaxLenRejectsOversizedPixelData(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("A"), 8192)
|
||||
pngEncoder := new(PNGEncoder)
|
||||
encoded, err := pngEncoder.Encode(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("png encode failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pngEncoder.DecodeWithMaxLen(encoded, int64(len(payload)-1))
|
||||
if !errors.Is(err, ErrPNGTooLarge) {
|
||||
t.Fatalf("expected ErrPNGTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGDecodeWithMaxLenRejectsOversizedDimensionsBeforeDecode(t *testing.T) {
|
||||
const maxLen = 8 * 1024 * 1024
|
||||
pngEncoder := new(PNGEncoder)
|
||||
encoded, err := pngEncoder.Encode([]byte("abc"))
|
||||
if err != nil {
|
||||
t.Fatalf("png encode failed: %v", err)
|
||||
}
|
||||
|
||||
// A PNG starts with an eight-byte signature followed by its IHDR chunk.
|
||||
// Rewrite only the CRC-valid header and omit IDAT so this test remains safe
|
||||
// even if the preflight check regresses and png.Decode is called directly.
|
||||
const ihdrEnd = 33
|
||||
if len(encoded) < ihdrEnd || string(encoded[12:16]) != "IHDR" {
|
||||
t.Fatal("encoded PNG does not contain the expected IHDR chunk")
|
||||
}
|
||||
encoded = encoded[:ihdrEnd]
|
||||
binary.BigEndian.PutUint32(encoded[16:20], 4096)
|
||||
binary.BigEndian.PutUint32(encoded[20:24], 4096)
|
||||
binary.BigEndian.PutUint32(encoded[29:33], crc32.ChecksumIEEE(encoded[12:29]))
|
||||
|
||||
_, err = pngEncoder.DecodeWithMaxLen(encoded, maxLen)
|
||||
if !errors.Is(err, ErrPNGTooLarge) {
|
||||
t.Fatalf("expected ErrPNGTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePNGDimensionsRejectsReportedBomb(t *testing.T) {
|
||||
err := validatePNGDimensions(32767, 32767, 8*1024*1024)
|
||||
if !errors.Is(err, ErrPNGTooLarge) {
|
||||
t.Fatalf("expected ErrPNGTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePNGDimensionsPixelDataBoundary(t *testing.T) {
|
||||
if err := validatePNGDimensions(1, 1, bytesPerPixel); err != nil {
|
||||
t.Fatalf("expected one pixel to fit: %v", err)
|
||||
}
|
||||
if err := validatePNGDimensions(1, 1, bytesPerPixel-1); !errors.Is(err, ErrPNGTooLarge) {
|
||||
t.Fatalf("expected ErrPNGTooLarge, got %v", err)
|
||||
}
|
||||
}
|
||||
+341
-67
@@ -1,3 +1,4 @@
|
||||
// Package progress provides a simple progress bar for Bubble Tea applications.
|
||||
package progress
|
||||
|
||||
import (
|
||||
@@ -5,36 +6,97 @@ import (
|
||||
"image/color"
|
||||
"math"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/harmonica"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultFullCharHalfBlock = '▌'
|
||||
DefaultFullCharFullBlock = '█'
|
||||
DefaultEmptyCharBlock = '░'
|
||||
// ColorFunc is a function that can be used to dynamically fill the progress
|
||||
// bar based on the current percentage. total is the total filled percentage,
|
||||
// and current is the current percentage that is actively being filled with a
|
||||
// color.
|
||||
type ColorFunc func(total, current float64) color.Color
|
||||
|
||||
defaultWidth = 40
|
||||
// Internal ID management. Used during animating to assure that frame messages
|
||||
// can only be received by progress components that sent them.
|
||||
var lastID int64
|
||||
|
||||
func nextID() int {
|
||||
return int(atomic.AddInt64(&lastID, 1))
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultFullCharHalfBlock is the default character used to fill the progress
|
||||
// bar. It is a half block, which allows more granular color blending control,
|
||||
// by having a different foreground and background color, doubling blending
|
||||
// resolution.
|
||||
DefaultFullCharHalfBlock = '▌'
|
||||
|
||||
// DefaultFullCharFullBlock can also be used as a fill character for the
|
||||
// progress bar. Use this to disable the higher resolution blending which is
|
||||
// enabled when using [DefaultFullCharHalfBlock].
|
||||
DefaultFullCharFullBlock = '█'
|
||||
|
||||
// DefaultEmptyCharBlock is the default character used to fill the empty
|
||||
// portion of the progress bar.
|
||||
DefaultEmptyCharBlock = '░'
|
||||
|
||||
fps = 60
|
||||
defaultWidth = 40
|
||||
defaultFrequency = 18.0
|
||||
defaultDamping = 1.0
|
||||
)
|
||||
|
||||
var (
|
||||
defaultFullColor = lipgloss.Color("#7571F9")
|
||||
defaultEmptyColor = lipgloss.Color("#606060")
|
||||
defaultBlendStart = lipgloss.Color("#5A56E0") // Purple haze.
|
||||
defaultBlendEnd = lipgloss.Color("#EE6FF8") // Neon pink.
|
||||
defaultFullColor = lipgloss.Color("#7571F9") // Blueberry.
|
||||
defaultEmptyColor = lipgloss.Color("#606060") // Slate gray.
|
||||
)
|
||||
|
||||
// Option is used to set options in [New]. For example:
|
||||
//
|
||||
// progress := New(
|
||||
// WithColors(
|
||||
// lipgloss.Color("#5A56E0"),
|
||||
// lipgloss.Color("#EE6FF8"),
|
||||
// ),
|
||||
// WithoutPercentage(),
|
||||
// )
|
||||
type Option func(*Model)
|
||||
|
||||
// WithDefaultBlend sets a default blend of colors, which is a blend of purple
|
||||
// haze to neon pink.
|
||||
func WithDefaultBlend() Option {
|
||||
return WithColors(
|
||||
defaultBlendStart,
|
||||
defaultBlendEnd,
|
||||
)
|
||||
}
|
||||
|
||||
// WithColors sets the colors to use to fill the progress bar. Depending on the
|
||||
// number of colors passed in, will determine whether to use a solid fill or a
|
||||
// blend of colors.
|
||||
//
|
||||
// - 0 colors: clears all previously set colors, setting them back to defaults.
|
||||
// - 1 color: uses a solid fill with the given color.
|
||||
// - 2+ colors: uses a blend of the provided colors.
|
||||
func WithColors(colors ...color.Color) Option {
|
||||
if len(colors) == 0 {
|
||||
return func(m *Model) {
|
||||
m.FullColor = defaultFullColor
|
||||
m.blend = nil
|
||||
m.colorFunc = nil
|
||||
}
|
||||
}
|
||||
if len(colors) == 1 {
|
||||
return func(m *Model) {
|
||||
m.FullColor = colors[0]
|
||||
m.colorFunc = nil
|
||||
m.blend = nil
|
||||
}
|
||||
}
|
||||
@@ -43,39 +105,133 @@ func WithColors(colors ...color.Color) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithFillCharacters(full, empty rune) Option {
|
||||
// WithColorFunc sets a function that can be used to dynamically fill the progress
|
||||
// bar based on the current percentage. total is the total filled percentage, and
|
||||
// current is the current percentage that is actively being filled with a color.
|
||||
// When specified, this overrides any other defined colors and scaling.
|
||||
//
|
||||
// Example: A progress bar that changes color based on the total completed
|
||||
// percentage:
|
||||
//
|
||||
// WithColorFunc(func(total, current float64) color.Color {
|
||||
// if total <= 0.3 {
|
||||
// return lipgloss.Color("#FF0000")
|
||||
// }
|
||||
// if total <= 0.7 {
|
||||
// return lipgloss.Color("#00FF00")
|
||||
// }
|
||||
// return lipgloss.Color("#0000FF")
|
||||
// }),
|
||||
func WithColorFunc(fn ColorFunc) Option {
|
||||
return func(m *Model) {
|
||||
m.colorFunc = fn
|
||||
m.blend = nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithFillCharacters sets the characters used to construct the full and empty
|
||||
// components of the progress bar.
|
||||
func WithFillCharacters(full rune, empty rune) Option {
|
||||
return func(m *Model) {
|
||||
m.Full = full
|
||||
m.Empty = empty
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutPercentage hides the numeric percentage.
|
||||
func WithoutPercentage() Option {
|
||||
return func(m *Model) {
|
||||
m.ShowPercentage = false
|
||||
}
|
||||
}
|
||||
|
||||
// WithWidth sets the initial width of the progress bar. Note that you can also
|
||||
// set the width via the Width property, which can come in handy if you're
|
||||
// waiting for a tea.WindowSizeMsg.
|
||||
func WithWidth(w int) Option {
|
||||
return func(m *Model) {
|
||||
m.SetWidth(w)
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
width int
|
||||
Full rune
|
||||
FullColor color.Color
|
||||
Empty rune
|
||||
EmptyColor color.Color
|
||||
ShowPercentage bool
|
||||
PercentFormat string
|
||||
PercentageStyle lipgloss.Style
|
||||
blend []color.Color
|
||||
// WithSpringOptions sets the initial frequency and damping options for the
|
||||
// progress bar's built-in spring-based animation. Frequency corresponds to
|
||||
// speed, and damping to bounciness. For details see:
|
||||
//
|
||||
// https://github.com/charmbracelet/harmonica
|
||||
func WithSpringOptions(frequency, damping float64) Option {
|
||||
return func(m *Model) {
|
||||
m.SetSpringOptions(frequency, damping)
|
||||
m.springCustomized = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithScaled sets whether to scale the blend/gradient to fit the width of only
|
||||
// the filled portion of the progress bar. The default is false, which means the
|
||||
// percentage must be 100% to see the full color blend/gradient.
|
||||
//
|
||||
// This is ignored when not using blending/multiple colors.
|
||||
func WithScaled(enabled bool) Option {
|
||||
return func(m *Model) {
|
||||
m.scaleBlend = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// FrameMsg indicates that an animation step should occur.
|
||||
type FrameMsg struct {
|
||||
id int
|
||||
tag int
|
||||
}
|
||||
|
||||
// Model stores values we'll use when rendering the progress bar.
|
||||
type Model struct {
|
||||
// An identifier to keep us from receiving messages intended for other
|
||||
// progress bars.
|
||||
id int
|
||||
|
||||
// An identifier to keep us from receiving frame messages too quickly.
|
||||
tag int
|
||||
|
||||
// Total width of the progress bar, including percentage, if set.
|
||||
width int
|
||||
|
||||
// "Filled" sections of the progress bar.
|
||||
Full rune
|
||||
FullColor color.Color
|
||||
|
||||
// "Empty" sections of the progress bar.
|
||||
Empty rune
|
||||
EmptyColor color.Color
|
||||
|
||||
// Settings for rendering the numeric percentage.
|
||||
ShowPercentage bool
|
||||
PercentFormat string // a fmt string for a float
|
||||
PercentageStyle lipgloss.Style
|
||||
|
||||
// Members for animated transitions.
|
||||
spring harmonica.Spring
|
||||
springCustomized bool
|
||||
percentShown float64 // percent currently displaying
|
||||
targetPercent float64 // percent to which we're animating
|
||||
velocity float64
|
||||
|
||||
// Blend of colors to use. When len < 1, we use FullColor.
|
||||
blend []color.Color
|
||||
|
||||
// When true, we scale the blended colors to fit the width of the filled
|
||||
// section of the progress bar. When false, the width of the blend will be
|
||||
// set to the full width of the progress bar.
|
||||
scaleBlend bool
|
||||
|
||||
// colorFunc is used to dynamically fill the progress bar based on the
|
||||
// current percentage.
|
||||
colorFunc ColorFunc
|
||||
}
|
||||
|
||||
// New returns a model with default values.
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{
|
||||
id: nextID(),
|
||||
width: defaultWidth,
|
||||
Full: DefaultFullCharHalfBlock,
|
||||
FullColor: defaultFullColor,
|
||||
@@ -84,24 +240,96 @@ func New(opts ...Option) Model {
|
||||
ShowPercentage: true,
|
||||
PercentFormat: " %3.0f%%",
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
|
||||
if !m.springCustomized {
|
||||
m.SetSpringOptions(defaultFrequency, defaultDamping)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
// Init exists to satisfy the tea.Model interface.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) Width() int {
|
||||
return m.width
|
||||
// Update is used to animate the progress bar during transitions. Use
|
||||
// SetPercent to create the command you'll need to trigger the animation.
|
||||
//
|
||||
// If you're rendering with ViewAs you won't need this.
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case FrameMsg:
|
||||
if msg.id != m.id || msg.tag != m.tag {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// If we've more or less reached equilibrium, stop updating.
|
||||
if !m.IsAnimating() {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.percentShown, m.velocity = m.spring.Update(m.percentShown, m.velocity, m.targetPercent)
|
||||
return m, m.nextFrame()
|
||||
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetSpringOptions sets the frequency and damping for the current spring.
|
||||
// Frequency corresponds to speed, and damping to bounciness. For details see:
|
||||
//
|
||||
// https://github.com/charmbracelet/harmonica
|
||||
func (m *Model) SetSpringOptions(frequency, damping float64) {
|
||||
m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
|
||||
}
|
||||
|
||||
// Percent returns the current visible percentage on the model. This is only
|
||||
// relevant when you're animating the progress bar.
|
||||
//
|
||||
// If you're rendering with ViewAs you won't need this.
|
||||
func (m Model) Percent() float64 {
|
||||
return m.targetPercent
|
||||
}
|
||||
|
||||
// SetPercent sets the percentage state of the model as well as a command
|
||||
// necessary for animating the progress bar to this new percentage.
|
||||
//
|
||||
// If you're rendering with ViewAs you won't need this.
|
||||
func (m *Model) SetPercent(p float64) tea.Cmd {
|
||||
m.targetPercent = math.Max(0, math.Min(1, p))
|
||||
m.tag++
|
||||
return m.nextFrame()
|
||||
}
|
||||
|
||||
// IncrPercent increments the percentage by a given amount, returning a command
|
||||
// necessary to animate the progress bar to the new percentage.
|
||||
//
|
||||
// If you're rendering with ViewAs you won't need this.
|
||||
func (m *Model) IncrPercent(v float64) tea.Cmd {
|
||||
return m.SetPercent(m.Percent() + v)
|
||||
}
|
||||
|
||||
// DecrPercent decrements the percentage by a given amount, returning a command
|
||||
// necessary to animate the progress bar to the new percentage.
|
||||
//
|
||||
// If you're rendering with ViewAs you won't need this.
|
||||
func (m *Model) DecrPercent(v float64) tea.Cmd {
|
||||
return m.SetPercent(m.Percent() - v)
|
||||
}
|
||||
|
||||
// View renders an animated progress bar in its current state. To render
|
||||
// a static progress bar based on your own calculations use ViewAs instead.
|
||||
func (m Model) View() string {
|
||||
return m.ViewAs(0)
|
||||
return m.ViewAs(m.percentShown)
|
||||
}
|
||||
|
||||
// ViewAs renders the progress bar with a given percentage.
|
||||
func (m Model) ViewAs(percent float64) string {
|
||||
b := strings.Builder{}
|
||||
percentView := m.percentageView(percent)
|
||||
@@ -110,55 +338,101 @@ func (m Model) ViewAs(percent float64) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SetWidth sets the width of the progress bar.
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
}
|
||||
|
||||
// Width returns the width of the progress bar.
|
||||
func (m Model) Width() int {
|
||||
return m.width
|
||||
}
|
||||
|
||||
func (m *Model) nextFrame() tea.Cmd {
|
||||
return tea.Tick(time.Second/time.Duration(fps), func(time.Time) tea.Msg {
|
||||
return FrameMsg{id: m.id, tag: m.tag}
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
|
||||
var (
|
||||
tw = max(0, m.width-textWidth) // total width
|
||||
fw = int(math.Round((float64(tw) * percent))) // filled width
|
||||
)
|
||||
|
||||
fw = max(0, min(tw, fw))
|
||||
|
||||
isHalfBlock := m.Full == DefaultFullCharHalfBlock
|
||||
|
||||
if m.colorFunc != nil { //nolint:nestif
|
||||
var style lipgloss.Style
|
||||
var current float64
|
||||
halfBlockPerc := 0.5 / float64(tw)
|
||||
for i := range fw {
|
||||
current = float64(i) / float64(tw)
|
||||
style = style.Foreground(m.colorFunc(percent, current))
|
||||
if isHalfBlock {
|
||||
style = style.Background(m.colorFunc(percent, min(current+halfBlockPerc, 1)))
|
||||
}
|
||||
b.WriteString(style.Render(string(m.Full)))
|
||||
}
|
||||
} else if len(m.blend) > 0 {
|
||||
var blend []color.Color
|
||||
|
||||
multiplier := 1
|
||||
if isHalfBlock {
|
||||
multiplier = 2
|
||||
}
|
||||
|
||||
if m.scaleBlend {
|
||||
blend = lipgloss.Blend1D(fw*multiplier, m.blend...)
|
||||
} else {
|
||||
blend = lipgloss.Blend1D(tw*multiplier, m.blend...)
|
||||
}
|
||||
|
||||
// Blend fill.
|
||||
var blendIndex int
|
||||
for i := range fw {
|
||||
if !isHalfBlock {
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(blend[i]).
|
||||
Render(string(m.Full)))
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(blend[blendIndex]).
|
||||
Background(blend[blendIndex+1]).
|
||||
Render(string(m.Full)))
|
||||
blendIndex += 2
|
||||
}
|
||||
} else {
|
||||
// Solid fill.
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(m.FullColor).
|
||||
Render(strings.Repeat(string(m.Full), fw)))
|
||||
}
|
||||
|
||||
// Empty fill.
|
||||
n := max(0, tw-fw)
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(m.EmptyColor).
|
||||
Render(strings.Repeat(string(m.Empty), n)))
|
||||
}
|
||||
|
||||
func (m Model) percentageView(percent float64) string {
|
||||
if !m.ShowPercentage {
|
||||
return ""
|
||||
}
|
||||
percent = math.Max(0, math.Min(1, percent))
|
||||
percentage := fmt.Sprintf(m.PercentFormat, percent*100)
|
||||
return m.PercentageStyle.Inline(true).Render(percentage)
|
||||
percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:mnd
|
||||
percentage = m.PercentageStyle.Inline(true).Render(percentage)
|
||||
return percentage
|
||||
}
|
||||
|
||||
func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
|
||||
totalWidth := maxInt(0, m.width-textWidth)
|
||||
filledWidth := int(math.Round(float64(totalWidth) * math.Max(0, math.Min(1, percent))))
|
||||
filledWidth = clampInt(filledWidth, 0, totalWidth)
|
||||
|
||||
if len(m.blend) > 1 {
|
||||
blend := lipgloss.Blend1D(totalWidth, m.blend...)
|
||||
for i := 0; i < filledWidth && i < len(blend); i++ {
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(blend[i]).
|
||||
Render(string(m.Full)))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(m.FullColor).
|
||||
Render(strings.Repeat(string(m.Full), filledWidth)))
|
||||
}
|
||||
|
||||
emptyWidth := maxInt(0, totalWidth-filledWidth)
|
||||
if emptyWidth == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(m.EmptyColor).
|
||||
Render(strings.Repeat(string(m.Empty), emptyWidth)))
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func clampInt(value, low, high int) int {
|
||||
if value < low {
|
||||
return low
|
||||
}
|
||||
if value > high {
|
||||
return high
|
||||
}
|
||||
return value
|
||||
// IsAnimating returns false if the progress bar reached equilibrium and is no
|
||||
// longer animating.
|
||||
func (m *Model) IsAnimating() bool {
|
||||
dist := math.Abs(m.percentShown - m.targetPercent)
|
||||
return !(dist < 0.001 && m.velocity < 0.01)
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
# https://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
[*.golden]
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1 +0,0 @@
|
||||
providertests/testdata/**/*.yaml -diff linguist-generated=true
|
||||
@@ -1,42 +0,0 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
vendor/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
*.log
|
||||
|
||||
# Binary output directory
|
||||
/bin/
|
||||
/dist/
|
||||
|
||||
# Local environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
manpages/
|
||||
*.patch
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
# - exhaustive
|
||||
# - goconst
|
||||
- godot
|
||||
# - gomoddirectives
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- misspell
|
||||
- nakedret
|
||||
# - nestif
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
# - wrapcheck
|
||||
exclusions:
|
||||
rules:
|
||||
- text: '(slog|log)\.\w+'
|
||||
linters:
|
||||
- noctx
|
||||
generated: lax
|
||||
presets:
|
||||
- common-false-positives
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
|
||||
version: 2
|
||||
includes:
|
||||
- from_url:
|
||||
url: charmbracelet/meta/main/goreleaser-lib.yaml
|
||||
Vendored
-29
@@ -1,29 +0,0 @@
|
||||
# CRUSH.md - Fantasy AI SDK
|
||||
|
||||
## Build/Test/Lint Commands
|
||||
- **Build**: `go build ./...`
|
||||
- **Test all**: `task test` or `go test ./... -count=1`
|
||||
- **Test single**: `go test -run TestName ./package -v`
|
||||
- **Test with args**: `task test -- -v -run TestName`
|
||||
- **Lint**: `task lint` or `golangci-lint run`
|
||||
- **Format**: `task fmt` or `gofmt -s -w .`
|
||||
- **Modernize**: `task modernize` or `modernize -fix ./...`
|
||||
|
||||
## Code Style Guidelines
|
||||
- **Package naming**: lowercase, single word (ai, openai, anthropic, google)
|
||||
- **Imports**: standard library first, then third-party, then local packages
|
||||
- **Error handling**: Use custom error types with structured fields, wrap with context
|
||||
- **Types**: Use type aliases for function signatures (`type Option = func(*options)`)
|
||||
- **Naming**: CamelCase for exported, camelCase for unexported
|
||||
- **Constants**: Use const blocks with descriptive names (ProviderName, DefaultURL)
|
||||
- **Structs**: Embed anonymous structs for composition (APICallError embeds *AIError)
|
||||
- **Functions**: Return error as last parameter, use context.Context as first param
|
||||
- **Testing**: Use testify/assert, table-driven tests, recorder pattern for HTTP mocking
|
||||
- **Comments**: Godoc format for exported functions, explain complex logic inline
|
||||
- **JSON**: Use struct tags for marshaling, handle empty values gracefully
|
||||
|
||||
## Project Structure
|
||||
- `/ai` - Core AI abstractions and agent logic
|
||||
- `/openai`, `/anthropic`, `/google` - Provider implementations
|
||||
- `/providertests` - Cross-provider integration tests with VCR recordings
|
||||
- `/examples` - Usage examples for different patterns
|
||||
Vendored
-177
@@ -1,177 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
Copyright 2025-2026 Charmbracelet, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
-98
@@ -1,98 +0,0 @@
|
||||
# Fantasy
|
||||
|
||||
<p>
|
||||
<img width="475" alt="The Charm Fantasy logo" src="https://github.com/user-attachments/assets/b22c5862-792a-44c1-bc98-55a2e46c8fb9" /><br>
|
||||
<a href="https://github.com/charmbracelet/fantasy/releases"><img src="https://img.shields.io/github/release/charmbracelet/fantasy.svg" alt="Latest Release"></a>
|
||||
<a href="https://pkg.go.dev/charm.land/fantasy?tab=doc"><img src="https://godoc.org/charm.land/fantasy?status.svg" alt="GoDoc"></a>
|
||||
<a href="https://github.com/charmbracelet/fantasy/actions"><img src="https://github.com/charmbracelet/fantasy/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status"></a>
|
||||
</p>
|
||||
|
||||
Build AI agents with Go. Multi-provider, multi-model, one API.
|
||||
|
||||
1. Choose a model and provider
|
||||
2. Add some tools
|
||||
3. Compile to native machine code and let it rip
|
||||
|
||||
> [!NOTE]
|
||||
> Fantasy is currently a preview. Expect API changes.
|
||||
|
||||
```go
|
||||
import "charm.land/fantasy"
|
||||
import "charm.land/fantasy/providers/openrouter"
|
||||
|
||||
// Choose your fave provider.
|
||||
provider, err := openrouter.New(openrouter.WithAPIKey(myHotKey))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Whoops:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Pick your fave model.
|
||||
model, err := provider.LanguageModel(ctx, "moonshotai/kimi-k2")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Dang:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Make your own tools.
|
||||
cuteDogTool := fantasy.NewAgentTool(
|
||||
"cute_dog_tool",
|
||||
"Provide up-to-date info on cute dogs.",
|
||||
fetchCuteDogInfoFunc,
|
||||
)
|
||||
|
||||
// Equip your agent.
|
||||
agent := fantasy.NewAgent(
|
||||
model,
|
||||
fantasy.WithSystemPrompt("You are a moderately helpful, dog-centric assistant."),
|
||||
fantasy.WithTools(cuteDogTool),
|
||||
)
|
||||
|
||||
// Put that agent to work!
|
||||
const prompt = "Find all the cute dogs in Silver Lake, Los Angeles."
|
||||
result, err := agent.Generate(ctx, fantasy.AgentCall{Prompt: prompt})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Oof:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(result.Response.Content.Text())
|
||||
```
|
||||
|
||||
🍔 For the full implementation and more [see the examples directory](https://github.com/charmbracelet/fantasy/tree/main/examples).
|
||||
|
||||
## Multi-model? Multi-provider?
|
||||
|
||||
Yeah! Fantasy is designed to support a wide variety of providers and models under a single API. While many providers such as Microsoft Azure, Amazon Bedrock, and OpenRouter have dedicated packages in Fantasy, many others work just fine with `openaicompat`, the generic OpenAI-compatible layer. That said, if you find a provider that’s not compatible and needs special treatment, please let us know in an issue (or open a PR).
|
||||
|
||||
## Work in Progress
|
||||
|
||||
We built Fantasy to power [Crush](https://github.com/charmbracelet/crush), a hot coding agent for glamourously invincible development. Given that, Fantasy does not yet support things like:
|
||||
|
||||
- Image models
|
||||
- Audio models
|
||||
- PDF uploads
|
||||
- Provider tools (e.g. web_search)
|
||||
|
||||
For things you’d like to see supported, PRs are welcome.
|
||||
|
||||
## Whatcha think?
|
||||
|
||||
We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
|
||||
|
||||
- [Slack](https://charm.land/slack)
|
||||
- [Discord][discord]
|
||||
- [Twitter](https://twitter.com/charmcli)
|
||||
- [The Fediverse](https://mastodon.social/@charmcli)
|
||||
- [Bluesky](https://bsky.app/profile/charm.land)
|
||||
|
||||
[discord]: https://charm.land/discord
|
||||
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.land).
|
||||
|
||||
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
tasks:
|
||||
fmt:
|
||||
desc: Run gofumpt for all packages
|
||||
cmds:
|
||||
- gofumpt -w .
|
||||
|
||||
modernize:
|
||||
desc: Run modernize for all packages
|
||||
cmds:
|
||||
- modernize -fix ./...
|
||||
|
||||
lint:
|
||||
desc: Run base linters for all packages
|
||||
cmds:
|
||||
- golangci-lint run
|
||||
|
||||
lint:fix:
|
||||
desc: Run base linters for all packages and applies fixes if possible
|
||||
cmds:
|
||||
- golangci-lint run --fix
|
||||
|
||||
test:
|
||||
desc: Run tests for all packages
|
||||
cmds:
|
||||
- go test ./... -count=1 {{.CLI_ARGS}}
|
||||
release:
|
||||
desc: Create and push a new tag following semver
|
||||
vars:
|
||||
NEXT:
|
||||
sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always
|
||||
prompt: "This will release {{.NEXT}}. Continue?"
|
||||
preconditions:
|
||||
- sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
|
||||
msg: Not on main branch
|
||||
- sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
|
||||
msg: "Git is dirty"
|
||||
cmds:
|
||||
- git commit --allow-empty -m "{{.NEXT}}"
|
||||
- git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
|
||||
- echo "Pushing {{.NEXT}}..."
|
||||
- git push origin main --follow-tags
|
||||
Vendored
-1426
File diff suppressed because it is too large.
Load diff
Vendored
-560
@@ -1,560 +0,0 @@
|
||||
package fantasy
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ProviderOptionsData is an interface for provider-specific options data.
|
||||
// All implementations MUST also implement encoding/json.Marshaler and
|
||||
// encoding/json.Unmarshaler interfaces to ensure proper JSON serialization
|
||||
// with the provider registry system.
|
||||
//
|
||||
// Recommended implementation pattern using generic helpers:
|
||||
//
|
||||
// // Define type constants at the top of your file
|
||||
// const TypeMyProviderOptions = "myprovider.options"
|
||||
//
|
||||
// type MyProviderOptions struct {
|
||||
// Field string `json:"field"`
|
||||
// }
|
||||
//
|
||||
// // Register the type in init() - place at top of file after constants
|
||||
// func init() {
|
||||
// fantasy.RegisterProviderType(TypeMyProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
// var opts MyProviderOptions
|
||||
// if err := json.Unmarshal(data, &opts); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return &opts, nil
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// // Implement ProviderOptionsData interface
|
||||
// func (*MyProviderOptions) Options() {}
|
||||
//
|
||||
// // Implement json.Marshaler using the generic helper
|
||||
// func (m MyProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
// type plain MyProviderOptions
|
||||
// return fantasy.MarshalProviderType(TypeMyProviderOptions, plain(m))
|
||||
// }
|
||||
//
|
||||
// // Implement json.Unmarshaler using the generic helper
|
||||
// // Note: Receives inner data after type routing by the registry.
|
||||
// func (m *MyProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
// type plain MyProviderOptions
|
||||
// var p plain
|
||||
// if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// *m = MyProviderOptions(p)
|
||||
// return nil
|
||||
// }
|
||||
type ProviderOptionsData interface {
|
||||
// Options is a marker method that identifies types implementing this interface.
|
||||
Options()
|
||||
json.Marshaler
|
||||
json.Unmarshaler
|
||||
}
|
||||
|
||||
// ProviderMetadata represents additional provider-specific metadata.
|
||||
// They are passed through from the provider to the AI SDK and enable
|
||||
// provider-specific results that can be fully encapsulated in the provider.
|
||||
//
|
||||
// The outer map is keyed by the provider name, and the inner
|
||||
// map is keyed by the provider-specific metadata key.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// {
|
||||
// "anthropic": {
|
||||
// "signature": "sig....."
|
||||
// }
|
||||
// }
|
||||
type ProviderMetadata map[string]ProviderOptionsData
|
||||
|
||||
// ProviderOptions represents additional provider-specific options.
|
||||
// Options are additional input to the provider. They are passed through
|
||||
// to the provider from the AI SDK and enable provider-specific functionality
|
||||
// that can be fully encapsulated in the provider.
|
||||
//
|
||||
// This enables us to quickly ship provider-specific functionality
|
||||
// without affecting the core AI SDK.
|
||||
//
|
||||
// The outer map is keyed by the provider name, and the inner
|
||||
// map is keyed by the provider-specific option key.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// {
|
||||
// "anthropic": {
|
||||
// "cacheControl": { "type": "ephemeral" }
|
||||
// }
|
||||
// }
|
||||
type ProviderOptions map[string]ProviderOptionsData
|
||||
|
||||
// FinishReason represents why a language model finished generating a response.
|
||||
//
|
||||
// Can be one of the following:
|
||||
// - `stop`: model generated stop sequence
|
||||
// - `length`: model generated maximum number of tokens
|
||||
// - `content-filter`: content filter violation stopped the model
|
||||
// - `tool-calls`: model triggered tool calls
|
||||
// - `error`: model stopped because of an error
|
||||
// - `other`: model stopped for other reasons
|
||||
// - `unknown`: the model has not transmitted a finish reason.
|
||||
type FinishReason string
|
||||
|
||||
const (
|
||||
// FinishReasonStop indicates the model generated a stop sequence.
|
||||
FinishReasonStop FinishReason = "stop" // model generated stop sequence
|
||||
// FinishReasonLength indicates the model generated maximum number of tokens.
|
||||
FinishReasonLength FinishReason = "length" // model generated maximum number of tokens
|
||||
// FinishReasonContentFilter indicates content filter violation stopped the model.
|
||||
FinishReasonContentFilter FinishReason = "content-filter" // content filter violation stopped the model
|
||||
// FinishReasonToolCalls indicates the model triggered tool calls.
|
||||
FinishReasonToolCalls FinishReason = "tool-calls" // model triggered tool calls
|
||||
// FinishReasonError indicates the model stopped because of an error.
|
||||
FinishReasonError FinishReason = "error" // model stopped because of an error
|
||||
// FinishReasonOther indicates the model stopped for other reasons.
|
||||
FinishReasonOther FinishReason = "other" // model stopped for other reasons
|
||||
// FinishReasonUnknown indicates the model has not transmitted a finish reason.
|
||||
FinishReasonUnknown FinishReason = "unknown" // the model has not transmitted a finish reason
|
||||
)
|
||||
|
||||
// Prompt represents a list of messages for the language model.
|
||||
type Prompt []Message
|
||||
|
||||
// MessageRole represents the role of a message.
|
||||
type MessageRole string
|
||||
|
||||
const (
|
||||
// MessageRoleSystem represents a system message.
|
||||
MessageRoleSystem MessageRole = "system"
|
||||
// MessageRoleUser represents a user message.
|
||||
MessageRoleUser MessageRole = "user"
|
||||
// MessageRoleAssistant represents an assistant message.
|
||||
MessageRoleAssistant MessageRole = "assistant"
|
||||
// MessageRoleTool represents a tool message.
|
||||
MessageRoleTool MessageRole = "tool"
|
||||
)
|
||||
|
||||
// Message represents a message in a prompt.
|
||||
type Message struct {
|
||||
Role MessageRole `json:"role"`
|
||||
Content []MessagePart `json:"content"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// AsContentType converts a Content interface to a specific content type.
|
||||
func AsContentType[T Content](content Content) (T, bool) {
|
||||
var zero T
|
||||
if content == nil {
|
||||
return zero, false
|
||||
}
|
||||
switch v := any(content).(type) {
|
||||
case T:
|
||||
return v, true
|
||||
case *T:
|
||||
return *v, true
|
||||
default:
|
||||
return zero, false
|
||||
}
|
||||
}
|
||||
|
||||
// AsMessagePart converts a MessagePart interface to a specific message part type.
|
||||
func AsMessagePart[T MessagePart](content MessagePart) (T, bool) {
|
||||
var zero T
|
||||
if content == nil {
|
||||
return zero, false
|
||||
}
|
||||
switch v := any(content).(type) {
|
||||
case T:
|
||||
return v, true
|
||||
case *T:
|
||||
return *v, true
|
||||
default:
|
||||
return zero, false
|
||||
}
|
||||
}
|
||||
|
||||
// MessagePart represents a part of a message content.
|
||||
type MessagePart interface {
|
||||
GetType() ContentType
|
||||
Options() ProviderOptions
|
||||
}
|
||||
|
||||
// TextPart represents text content in a message.
|
||||
type TextPart struct {
|
||||
Text string `json:"text"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the text part.
|
||||
func (t TextPart) GetType() ContentType {
|
||||
return ContentTypeText
|
||||
}
|
||||
|
||||
// Options returns the provider options for the text part.
|
||||
func (t TextPart) Options() ProviderOptions {
|
||||
return t.ProviderOptions
|
||||
}
|
||||
|
||||
// ReasoningPart represents reasoning content in a message.
|
||||
type ReasoningPart struct {
|
||||
Text string `json:"text"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the reasoning part.
|
||||
func (r ReasoningPart) GetType() ContentType {
|
||||
return ContentTypeReasoning
|
||||
}
|
||||
|
||||
// Options returns the provider options for the reasoning part.
|
||||
func (r ReasoningPart) Options() ProviderOptions {
|
||||
return r.ProviderOptions
|
||||
}
|
||||
|
||||
// FilePart represents file content in a message.
|
||||
type FilePart struct {
|
||||
Filename string `json:"filename"`
|
||||
Data []byte `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the file part.
|
||||
func (f FilePart) GetType() ContentType {
|
||||
return ContentTypeFile
|
||||
}
|
||||
|
||||
// Options returns the provider options for the file part.
|
||||
func (f FilePart) Options() ProviderOptions {
|
||||
return f.ProviderOptions
|
||||
}
|
||||
|
||||
// ToolCallPart represents a tool call in a message.
|
||||
type ToolCallPart struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Input string `json:"input"` // the json string
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool call part.
|
||||
func (t ToolCallPart) GetType() ContentType {
|
||||
return ContentTypeToolCall
|
||||
}
|
||||
|
||||
// Options returns the provider options for the tool call part.
|
||||
func (t ToolCallPart) Options() ProviderOptions {
|
||||
return t.ProviderOptions
|
||||
}
|
||||
|
||||
// ToolResultPart represents a tool result in a message.
|
||||
type ToolResultPart struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Output ToolResultOutputContent `json:"output"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result part.
|
||||
func (t ToolResultPart) GetType() ContentType {
|
||||
return ContentTypeToolResult
|
||||
}
|
||||
|
||||
// Options returns the provider options for the tool result part.
|
||||
func (t ToolResultPart) Options() ProviderOptions {
|
||||
return t.ProviderOptions
|
||||
}
|
||||
|
||||
// ToolResultContentType represents the type of tool result output.
|
||||
type ToolResultContentType string
|
||||
|
||||
const (
|
||||
// ToolResultContentTypeText represents text output.
|
||||
ToolResultContentTypeText ToolResultContentType = "text"
|
||||
// ToolResultContentTypeError represents error text output.
|
||||
ToolResultContentTypeError ToolResultContentType = "error"
|
||||
// ToolResultContentTypeMedia represents content output.
|
||||
ToolResultContentTypeMedia ToolResultContentType = "media"
|
||||
)
|
||||
|
||||
// ToolResultOutputContent represents the output content of a tool result.
|
||||
type ToolResultOutputContent interface {
|
||||
GetType() ToolResultContentType
|
||||
}
|
||||
|
||||
// ToolResultOutputContentText represents text output content of a tool result.
|
||||
type ToolResultOutputContentText struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result output content text.
|
||||
func (t ToolResultOutputContentText) GetType() ToolResultContentType {
|
||||
return ToolResultContentTypeText
|
||||
}
|
||||
|
||||
// ToolResultOutputContentError represents error output content of a tool result.
|
||||
type ToolResultOutputContentError struct {
|
||||
Error error `json:"error"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result output content error.
|
||||
func (t ToolResultOutputContentError) GetType() ToolResultContentType {
|
||||
return ToolResultContentTypeError
|
||||
}
|
||||
|
||||
// ToolResultOutputContentMedia represents media output content of a tool result.
|
||||
type ToolResultOutputContentMedia struct {
|
||||
Data string `json:"data"` // for media type (base64)
|
||||
MediaType string `json:"media_type"` // for media type
|
||||
Text string `json:"text,omitempty"` // optional text content accompanying the media
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result output content media.
|
||||
func (t ToolResultOutputContentMedia) GetType() ToolResultContentType {
|
||||
return ToolResultContentTypeMedia
|
||||
}
|
||||
|
||||
// AsToolResultOutputType converts a ToolResultOutputContent interface to a specific type.
|
||||
func AsToolResultOutputType[T ToolResultOutputContent](content ToolResultOutputContent) (T, bool) {
|
||||
var zero T
|
||||
if content == nil {
|
||||
return zero, false
|
||||
}
|
||||
switch v := any(content).(type) {
|
||||
case T:
|
||||
return v, true
|
||||
case *T:
|
||||
return *v, true
|
||||
default:
|
||||
return zero, false
|
||||
}
|
||||
}
|
||||
|
||||
// ContentType represents the type of content.
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
// ContentTypeText represents text content.
|
||||
ContentTypeText ContentType = "text"
|
||||
// ContentTypeReasoning represents reasoning content.
|
||||
ContentTypeReasoning ContentType = "reasoning"
|
||||
// ContentTypeFile represents file content.
|
||||
ContentTypeFile ContentType = "file"
|
||||
// ContentTypeSource represents source content.
|
||||
ContentTypeSource ContentType = "source"
|
||||
// ContentTypeToolCall represents a tool call.
|
||||
ContentTypeToolCall ContentType = "tool-call"
|
||||
// ContentTypeToolResult represents a tool result.
|
||||
ContentTypeToolResult ContentType = "tool-result"
|
||||
)
|
||||
|
||||
// Content represents generated content from the model.
|
||||
type Content interface {
|
||||
GetType() ContentType
|
||||
}
|
||||
|
||||
// TextContent represents text that the model has generated.
|
||||
type TextContent struct {
|
||||
// The text content.
|
||||
Text string `json:"text"`
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the text content.
|
||||
func (t TextContent) GetType() ContentType {
|
||||
return ContentTypeText
|
||||
}
|
||||
|
||||
// ReasoningContent represents reasoning that the model has generated.
|
||||
type ReasoningContent struct {
|
||||
Text string `json:"text"`
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the reasoning content.
|
||||
func (r ReasoningContent) GetType() ContentType {
|
||||
return ContentTypeReasoning
|
||||
}
|
||||
|
||||
// FileContent represents a file that has been generated by the model.
|
||||
// Generated files as base64 encoded strings or binary data.
|
||||
// The files should be returned without any unnecessary conversion.
|
||||
type FileContent struct {
|
||||
// The IANA media type of the file, e.g. `image/png` or `audio/mp3`.
|
||||
// @see https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
MediaType string `json:"media_type"`
|
||||
// Generated file data as binary data.
|
||||
Data []byte `json:"data"`
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the file content.
|
||||
func (f FileContent) GetType() ContentType {
|
||||
return ContentTypeFile
|
||||
}
|
||||
|
||||
// SourceType represents the type of source.
|
||||
type SourceType string
|
||||
|
||||
const (
|
||||
// SourceTypeURL represents a URL source.
|
||||
SourceTypeURL SourceType = "url"
|
||||
// SourceTypeDocument represents a document source.
|
||||
SourceTypeDocument SourceType = "document"
|
||||
)
|
||||
|
||||
// SourceContent represents a source that has been used as input to generate the response.
|
||||
type SourceContent struct {
|
||||
SourceType SourceType `json:"source_type"` // "url" or "document"
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"` // for URL sources
|
||||
Title string `json:"title"`
|
||||
MediaType string `json:"media_type"` // for document sources (IANA media type)
|
||||
Filename string `json:"filename"` // for document sources
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the source content.
|
||||
func (s SourceContent) GetType() ContentType {
|
||||
return ContentTypeSource
|
||||
}
|
||||
|
||||
// ToolCallContent represents tool calls that the model has generated.
|
||||
type ToolCallContent struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
// Stringified JSON object with the tool call arguments.
|
||||
// Must match the parameters schema of the tool.
|
||||
Input string `json:"input"`
|
||||
// Whether the tool call will be executed by the provider.
|
||||
// If this flag is not set or is false, the tool call will be executed by the client.
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
// Additional provider-specific metadata for the tool call.
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
// Whether this tool call is invalid (failed validation/parsing)
|
||||
Invalid bool `json:"invalid,omitempty"`
|
||||
// Error that occurred during validation/parsing (only set if Invalid is true)
|
||||
ValidationError error `json:"validation_error,omitempty"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool call content.
|
||||
func (t ToolCallContent) GetType() ContentType {
|
||||
return ContentTypeToolCall
|
||||
}
|
||||
|
||||
// ToolResultContent represents result of a tool call that has been executed by the provider.
|
||||
type ToolResultContent struct {
|
||||
// The ID of the tool call that this result is associated with.
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
// Name of the tool that generated this result.
|
||||
ToolName string `json:"tool_name"`
|
||||
// Result of the tool call. This is a JSON-serializable object.
|
||||
Result ToolResultOutputContent `json:"result"`
|
||||
ClientMetadata string `json:"client_metadata"` // Metadata from the client that executed the tool
|
||||
// Whether the tool result was generated by the provider.
|
||||
// If this flag is set to true, the tool result was generated by the provider.
|
||||
// If this flag is not set or is false, the tool result was generated by the client.
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
// Additional provider-specific metadata for the tool result.
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result content.
|
||||
func (t ToolResultContent) GetType() ContentType {
|
||||
return ContentTypeToolResult
|
||||
}
|
||||
|
||||
// ToolType represents the type of tool.
|
||||
type ToolType string
|
||||
|
||||
const (
|
||||
// ToolTypeFunction represents a function tool.
|
||||
ToolTypeFunction ToolType = "function"
|
||||
// ToolTypeProviderDefined represents a provider-defined tool.
|
||||
ToolTypeProviderDefined ToolType = "provider-defined"
|
||||
)
|
||||
|
||||
// Tool represents a tool that can be used by the model.
|
||||
//
|
||||
// Note: this is **not** the user-facing tool definition. The AI SDK methods will
|
||||
// map the user-facing tool definitions to this format.
|
||||
type Tool interface {
|
||||
GetType() ToolType
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// FunctionTool represents a function tool.
|
||||
//
|
||||
// A tool has a name, a description, and a set of parameters.
|
||||
type FunctionTool struct {
|
||||
// Name of the tool. Unique within this model call.
|
||||
Name string `json:"name"`
|
||||
// Description of the tool. The language model uses this to understand the
|
||||
// tool's purpose and to provide better completion suggestions.
|
||||
Description string `json:"description"`
|
||||
// InputSchema - the parameters that the tool expects. The language model uses this to
|
||||
// understand the tool's input requirements and to provide matching suggestions.
|
||||
InputSchema map[string]any `json:"input_schema"` // JSON Schema
|
||||
// ProviderOptions are provider-specific options for the tool.
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the function tool.
|
||||
func (f FunctionTool) GetType() ToolType {
|
||||
return ToolTypeFunction
|
||||
}
|
||||
|
||||
// GetName returns the name of the function tool.
|
||||
func (f FunctionTool) GetName() string {
|
||||
return f.Name
|
||||
}
|
||||
|
||||
// ProviderDefinedTool represents the configuration of a tool that is defined by the provider.
|
||||
type ProviderDefinedTool struct {
|
||||
// ID of the tool. Should follow the format `<provider-name>.<unique-tool-name>`.
|
||||
ID string `json:"id"`
|
||||
// Name of the tool that the user must use in the tool set.
|
||||
Name string `json:"name"`
|
||||
// Args for configuring the tool. Must match the expected arguments defined by the provider for this tool.
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the provider-defined tool.
|
||||
func (p ProviderDefinedTool) GetType() ToolType {
|
||||
return ToolTypeProviderDefined
|
||||
}
|
||||
|
||||
// GetName returns the name of the provider-defined tool.
|
||||
func (p ProviderDefinedTool) GetName() string {
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// NewUserMessage creates a new user message with the given prompt and optional files.
|
||||
func NewUserMessage(prompt string, files ...FilePart) Message {
|
||||
content := make([]MessagePart, 0, len(files)+1)
|
||||
content = append(content, TextPart{Text: prompt})
|
||||
|
||||
for _, f := range files {
|
||||
content = append(content, f)
|
||||
}
|
||||
|
||||
return Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSystemMessage creates a new system message with the given prompts.
|
||||
func NewSystemMessage(prompt ...string) Message {
|
||||
content := make([]MessagePart, 0, len(prompt))
|
||||
for _, p := range prompt {
|
||||
content = append(content, TextPart{Text: p})
|
||||
}
|
||||
|
||||
return Message{
|
||||
Role: MessageRoleSystem,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
-1038
File diff suppressed because it is too large.
Load diff
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema": "https://charm.land/crush.json",
|
||||
"lsp": {
|
||||
"gopls": {}
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"flagWords":[],"words":["mapstructure","mapstructure","charmbracelet","providertests","joho","godotenv","stretchr","Quantizations","Logit","Probs","openrouter","openaisdk","Logprobs","Includable"],"version":"0.2","language":"en"}
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
// Package fantasy provides a unified interface for interacting with various AI language models.
|
||||
package fantasy
|
||||
Vendored
-104
@@ -1,104 +0,0 @@
|
||||
package fantasy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/exp/slice"
|
||||
)
|
||||
|
||||
// Error is a custom error type for the fantasy package.
|
||||
type Error struct {
|
||||
Message string
|
||||
Title string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (err *Error) Error() string {
|
||||
if err.Title == "" {
|
||||
return err.Message
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", err.Title, err.Message)
|
||||
}
|
||||
|
||||
func (err Error) Unwrap() error {
|
||||
return err.Cause
|
||||
}
|
||||
|
||||
// ProviderError represents an error returned by an external provider.
|
||||
type ProviderError struct {
|
||||
Message string
|
||||
Title string
|
||||
Cause error
|
||||
|
||||
URL string
|
||||
StatusCode int
|
||||
RequestBody []byte
|
||||
ResponseHeaders map[string]string
|
||||
ResponseBody []byte
|
||||
}
|
||||
|
||||
func (m *ProviderError) Error() string {
|
||||
if m.Title == "" {
|
||||
return m.Message
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", m.Title, m.Message)
|
||||
}
|
||||
|
||||
// IsRetryable checks if the error is retryable based on the status code.
|
||||
func (m *ProviderError) IsRetryable() bool {
|
||||
return m.StatusCode == http.StatusRequestTimeout || m.StatusCode == http.StatusConflict || m.StatusCode == http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
// RetryError represents an error that occurred during retry operations.
|
||||
type RetryError struct {
|
||||
Errors []error
|
||||
}
|
||||
|
||||
func (e *RetryError) Error() string {
|
||||
if err, ok := slice.Last(e.Errors); ok {
|
||||
return fmt.Sprintf("retry error: %v", err)
|
||||
}
|
||||
return "retry error: no underlying errors"
|
||||
}
|
||||
|
||||
func (e RetryError) Unwrap() error {
|
||||
if err, ok := slice.Last(e.Errors); ok {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorTitleForStatusCode returns a human-readable title for a given HTTP status code.
|
||||
func ErrorTitleForStatusCode(statusCode int) string {
|
||||
return strings.ToLower(http.StatusText(statusCode))
|
||||
}
|
||||
|
||||
// NoObjectGeneratedError is returned when object generation fails
|
||||
// due to parsing errors, validation errors, or model failures.
|
||||
type NoObjectGeneratedError struct {
|
||||
RawText string
|
||||
ParseError error
|
||||
ValidationError error
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *NoObjectGeneratedError) Error() string {
|
||||
if e.ValidationError != nil {
|
||||
return fmt.Sprintf("object validation failed: %v", e.ValidationError)
|
||||
}
|
||||
if e.ParseError != nil {
|
||||
return fmt.Sprintf("failed to parse object: %v", e.ParseError)
|
||||
}
|
||||
return "failed to generate object"
|
||||
}
|
||||
|
||||
// IsNoObjectGeneratedError checks if an error is of type NoObjectGeneratedError.
|
||||
func IsNoObjectGeneratedError(err error) bool {
|
||||
var target *NoObjectGeneratedError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
-1648
File diff suppressed because it is too large.
Load diff
Vendored
-258
@@ -1,258 +0,0 @@
|
||||
package fantasy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Usage represents token usage statistics for a model call.
|
||||
type Usage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
}
|
||||
|
||||
func (u Usage) String() string {
|
||||
return fmt.Sprintf("Usage{Input: %d, Output: %d, Total: %d, Reasoning: %d, CacheCreation: %d, CacheRead: %d}",
|
||||
u.InputTokens,
|
||||
u.OutputTokens,
|
||||
u.TotalTokens,
|
||||
u.ReasoningTokens,
|
||||
u.CacheCreationTokens,
|
||||
u.CacheReadTokens,
|
||||
)
|
||||
}
|
||||
|
||||
// ResponseContent represents the content of a model response.
|
||||
type ResponseContent []Content
|
||||
|
||||
// Text returns the text content of the response.
|
||||
func (r ResponseContent) Text() string {
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeText {
|
||||
if textContent, ok := AsContentType[TextContent](c); ok {
|
||||
return textContent.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Reasoning returns all reasoning content parts.
|
||||
func (r ResponseContent) Reasoning() []ReasoningContent {
|
||||
var reasoning []ReasoningContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeReasoning {
|
||||
if reasoningContent, ok := AsContentType[ReasoningContent](c); ok {
|
||||
reasoning = append(reasoning, reasoningContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasoning
|
||||
}
|
||||
|
||||
// ReasoningText returns all reasoning content as a concatenated string.
|
||||
func (r ResponseContent) ReasoningText() string {
|
||||
var builder strings.Builder
|
||||
for _, reasoning := range r.Reasoning() {
|
||||
builder.WriteString(reasoning.Text)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// Files returns all file content parts.
|
||||
func (r ResponseContent) Files() []FileContent {
|
||||
var files []FileContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeFile {
|
||||
if fileContent, ok := AsContentType[FileContent](c); ok {
|
||||
files = append(files, fileContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// Sources returns all source content parts.
|
||||
func (r ResponseContent) Sources() []SourceContent {
|
||||
var sources []SourceContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeSource {
|
||||
if sourceContent, ok := AsContentType[SourceContent](c); ok {
|
||||
sources = append(sources, sourceContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// ToolCalls returns all tool call content parts.
|
||||
func (r ResponseContent) ToolCalls() []ToolCallContent {
|
||||
var toolCalls []ToolCallContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeToolCall {
|
||||
if toolCallContent, ok := AsContentType[ToolCallContent](c); ok {
|
||||
toolCalls = append(toolCalls, toolCallContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
// ToolResults returns all tool result content parts.
|
||||
func (r ResponseContent) ToolResults() []ToolResultContent {
|
||||
var toolResults []ToolResultContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeToolResult {
|
||||
if toolResultContent, ok := AsContentType[ToolResultContent](c); ok {
|
||||
toolResults = append(toolResults, toolResultContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return toolResults
|
||||
}
|
||||
|
||||
// Response represents a response from a language model.
|
||||
type Response struct {
|
||||
Content ResponseContent `json:"content"`
|
||||
FinishReason FinishReason `json:"finish_reason"`
|
||||
Usage Usage `json:"usage"`
|
||||
Warnings []CallWarning `json:"warnings"`
|
||||
|
||||
// for provider specific response metadata, the key is the provider id
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// StreamPartType represents the type of a stream part.
|
||||
type StreamPartType string
|
||||
|
||||
const (
|
||||
// StreamPartTypeWarnings represents warnings stream part type.
|
||||
StreamPartTypeWarnings StreamPartType = "warnings"
|
||||
// StreamPartTypeTextStart represents text start stream part type.
|
||||
StreamPartTypeTextStart StreamPartType = "text_start"
|
||||
// StreamPartTypeTextDelta represents text delta stream part type.
|
||||
StreamPartTypeTextDelta StreamPartType = "text_delta"
|
||||
// StreamPartTypeTextEnd represents text end stream part type.
|
||||
StreamPartTypeTextEnd StreamPartType = "text_end"
|
||||
|
||||
// StreamPartTypeReasoningStart represents reasoning start stream part type.
|
||||
StreamPartTypeReasoningStart StreamPartType = "reasoning_start"
|
||||
// StreamPartTypeReasoningDelta represents reasoning delta stream part type.
|
||||
StreamPartTypeReasoningDelta StreamPartType = "reasoning_delta"
|
||||
// StreamPartTypeReasoningEnd represents reasoning end stream part type.
|
||||
StreamPartTypeReasoningEnd StreamPartType = "reasoning_end"
|
||||
// StreamPartTypeToolInputStart represents tool input start stream part type.
|
||||
StreamPartTypeToolInputStart StreamPartType = "tool_input_start"
|
||||
// StreamPartTypeToolInputDelta represents tool input delta stream part type.
|
||||
StreamPartTypeToolInputDelta StreamPartType = "tool_input_delta"
|
||||
// StreamPartTypeToolInputEnd represents tool input end stream part type.
|
||||
StreamPartTypeToolInputEnd StreamPartType = "tool_input_end"
|
||||
// StreamPartTypeToolCall represents tool call stream part type.
|
||||
StreamPartTypeToolCall StreamPartType = "tool_call"
|
||||
// StreamPartTypeToolResult represents tool result stream part type.
|
||||
StreamPartTypeToolResult StreamPartType = "tool_result"
|
||||
// StreamPartTypeSource represents source stream part type.
|
||||
StreamPartTypeSource StreamPartType = "source"
|
||||
// StreamPartTypeFinish represents finish stream part type.
|
||||
StreamPartTypeFinish StreamPartType = "finish"
|
||||
// StreamPartTypeError represents error stream part type.
|
||||
StreamPartTypeError StreamPartType = "error"
|
||||
)
|
||||
|
||||
// StreamPart represents a part of a streaming response.
|
||||
type StreamPart struct {
|
||||
Type StreamPartType `json:"type"`
|
||||
ID string `json:"id"`
|
||||
ToolCallName string `json:"tool_call_name"`
|
||||
ToolCallInput string `json:"tool_call_input"`
|
||||
Delta string `json:"delta"`
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
Usage Usage `json:"usage"`
|
||||
FinishReason FinishReason `json:"finish_reason"`
|
||||
Error error `json:"error"`
|
||||
Warnings []CallWarning `json:"warnings"`
|
||||
|
||||
// Source-related fields
|
||||
SourceType SourceType `json:"source_type"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// StreamResponse represents a streaming response sequence.
|
||||
type StreamResponse = iter.Seq[StreamPart]
|
||||
|
||||
// ToolChoice represents the tool choice preference for a model call.
|
||||
type ToolChoice string
|
||||
|
||||
const (
|
||||
// ToolChoiceNone indicates no tools should be used.
|
||||
ToolChoiceNone ToolChoice = "none"
|
||||
// ToolChoiceAuto indicates tools should be used automatically.
|
||||
ToolChoiceAuto ToolChoice = "auto"
|
||||
// ToolChoiceRequired indicates tools are required.
|
||||
ToolChoiceRequired ToolChoice = "required"
|
||||
)
|
||||
|
||||
// SpecificToolChoice creates a tool choice for a specific tool name.
|
||||
func SpecificToolChoice(name string) ToolChoice {
|
||||
return ToolChoice(name)
|
||||
}
|
||||
|
||||
// Call represents a call to a language model.
|
||||
type Call struct {
|
||||
Prompt Prompt `json:"prompt"`
|
||||
MaxOutputTokens *int64 `json:"max_output_tokens"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
TopP *float64 `json:"top_p"`
|
||||
TopK *int64 `json:"top_k"`
|
||||
PresencePenalty *float64 `json:"presence_penalty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty"`
|
||||
Tools []Tool `json:"tools"`
|
||||
ToolChoice *ToolChoice `json:"tool_choice"`
|
||||
|
||||
// for provider specific options, the key is the provider id
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// CallWarningType represents the type of call warning.
|
||||
type CallWarningType string
|
||||
|
||||
const (
|
||||
// CallWarningTypeUnsupportedSetting indicates an unsupported setting.
|
||||
CallWarningTypeUnsupportedSetting CallWarningType = "unsupported-setting"
|
||||
// CallWarningTypeUnsupportedTool indicates an unsupported tool.
|
||||
CallWarningTypeUnsupportedTool CallWarningType = "unsupported-tool"
|
||||
// CallWarningTypeOther indicates other warnings.
|
||||
CallWarningTypeOther CallWarningType = "other"
|
||||
)
|
||||
|
||||
// CallWarning represents a warning from the model provider for this call.
|
||||
// The call will proceed, but e.g. some settings might not be supported,
|
||||
// which can lead to suboptimal results.
|
||||
type CallWarning struct {
|
||||
Type CallWarningType `json:"type"`
|
||||
Setting string `json:"setting"`
|
||||
Tool Tool `json:"tool"`
|
||||
Details string `json:"details"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LanguageModel represents a language model that can generate responses and stream responses.
|
||||
type LanguageModel interface {
|
||||
Generate(context.Context, Call) (*Response, error)
|
||||
Stream(context.Context, Call) (StreamResponse, error)
|
||||
|
||||
GenerateObject(context.Context, ObjectCall) (*ObjectResponse, error)
|
||||
StreamObject(context.Context, ObjectCall) (ObjectStreamResponse, error)
|
||||
|
||||
Provider() string
|
||||
Model() string
|
||||
}
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package fantasy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for Call.
|
||||
func (c *Call) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
Prompt Prompt `json:"prompt"`
|
||||
MaxOutputTokens *int64 `json:"max_output_tokens"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
TopP *float64 `json:"top_p"`
|
||||
TopK *int64 `json:"top_k"`
|
||||
PresencePenalty *float64 `json:"presence_penalty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
ToolChoice *ToolChoice `json:"tool_choice"`
|
||||
ProviderOptions map[string]json.RawMessage `json:"provider_options"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Prompt = aux.Prompt
|
||||
c.MaxOutputTokens = aux.MaxOutputTokens
|
||||
c.Temperature = aux.Temperature
|
||||
c.TopP = aux.TopP
|
||||
c.TopK = aux.TopK
|
||||
c.PresencePenalty = aux.PresencePenalty
|
||||
c.FrequencyPenalty = aux.FrequencyPenalty
|
||||
c.ToolChoice = aux.ToolChoice
|
||||
|
||||
// Unmarshal Tools slice
|
||||
c.Tools = make([]Tool, len(aux.Tools))
|
||||
for i, rawTool := range aux.Tools {
|
||||
tool, err := UnmarshalTool(rawTool)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal tool at index %d: %w", i, err)
|
||||
}
|
||||
c.Tools[i] = tool
|
||||
}
|
||||
|
||||
// Unmarshal ProviderOptions
|
||||
if len(aux.ProviderOptions) > 0 {
|
||||
options, err := UnmarshalProviderOptions(aux.ProviderOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ProviderOptions = options
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for Response.
|
||||
func (r *Response) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
FinishReason FinishReason `json:"finish_reason"`
|
||||
Usage Usage `json:"usage"`
|
||||
Warnings []CallWarning `json:"warnings"`
|
||||
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.FinishReason = aux.FinishReason
|
||||
r.Usage = aux.Usage
|
||||
r.Warnings = aux.Warnings
|
||||
|
||||
// Unmarshal ResponseContent (need to know the type definition)
|
||||
// If ResponseContent is []Content:
|
||||
var rawContent []json.RawMessage
|
||||
if err := json.Unmarshal(aux.Content, &rawContent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content := make([]Content, len(rawContent))
|
||||
for i, rawItem := range rawContent {
|
||||
item, err := UnmarshalContent(rawItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal content at index %d: %w", i, err)
|
||||
}
|
||||
content[i] = item
|
||||
}
|
||||
r.Content = content
|
||||
|
||||
// Unmarshal ProviderMetadata
|
||||
if len(aux.ProviderMetadata) > 0 {
|
||||
metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.ProviderMetadata = metadata
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for StreamPart.
|
||||
func (s StreamPart) MarshalJSON() ([]byte, error) {
|
||||
type alias StreamPart
|
||||
aux := struct {
|
||||
alias
|
||||
Error string `json:"error,omitempty"`
|
||||
}{
|
||||
alias: (alias)(s),
|
||||
}
|
||||
|
||||
// Marshal error to string
|
||||
if s.Error != nil {
|
||||
aux.Error = s.Error.Error()
|
||||
}
|
||||
|
||||
// Clear the original Error field to avoid duplicate marshaling
|
||||
aux.alias.Error = nil
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for StreamPart.
|
||||
func (s *StreamPart) UnmarshalJSON(data []byte) error {
|
||||
type alias StreamPart
|
||||
aux := struct {
|
||||
*alias
|
||||
Error string `json:"error"`
|
||||
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"`
|
||||
}{
|
||||
alias: (*alias)(s),
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unmarshal error string back to error type
|
||||
if aux.Error != "" {
|
||||
s.Error = fmt.Errorf("%s", aux.Error)
|
||||
}
|
||||
|
||||
// Unmarshal ProviderMetadata
|
||||
if len(aux.ProviderMetadata) > 0 {
|
||||
metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.ProviderMetadata = metadata
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Vendored
-233
@@ -1,233 +0,0 @@
|
||||
package fantasy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
"reflect"
|
||||
|
||||
"charm.land/fantasy/schema"
|
||||
)
|
||||
|
||||
// ObjectMode specifies how structured output should be generated.
|
||||
type ObjectMode string
|
||||
|
||||
const (
|
||||
// ObjectModeAuto lets the provider choose the best approach.
|
||||
ObjectModeAuto ObjectMode = "auto"
|
||||
|
||||
// ObjectModeJSON forces the use of native JSON mode (if supported).
|
||||
ObjectModeJSON ObjectMode = "json"
|
||||
|
||||
// ObjectModeTool forces the use of tool-based approach.
|
||||
ObjectModeTool ObjectMode = "tool"
|
||||
|
||||
// ObjectModeText uses text generation with schema in prompt (fallback for models without tool/JSON support).
|
||||
ObjectModeText ObjectMode = "text"
|
||||
)
|
||||
|
||||
// ObjectCall represents a request to generate a structured object.
|
||||
type ObjectCall struct {
|
||||
Prompt Prompt
|
||||
Schema Schema
|
||||
SchemaName string
|
||||
SchemaDescription string
|
||||
|
||||
MaxOutputTokens *int64
|
||||
Temperature *float64
|
||||
TopP *float64
|
||||
TopK *int64
|
||||
PresencePenalty *float64
|
||||
FrequencyPenalty *float64
|
||||
|
||||
ProviderOptions ProviderOptions
|
||||
|
||||
RepairText schema.ObjectRepairFunc
|
||||
}
|
||||
|
||||
// ObjectResponse represents the response from a structured object generation.
|
||||
type ObjectResponse struct {
|
||||
Object any
|
||||
RawText string
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
Warnings []CallWarning
|
||||
ProviderMetadata ProviderMetadata
|
||||
}
|
||||
|
||||
// ObjectStreamPartType indicates the type of stream part.
|
||||
type ObjectStreamPartType string
|
||||
|
||||
const (
|
||||
// ObjectStreamPartTypeObject is emitted when a new partial object is available.
|
||||
ObjectStreamPartTypeObject ObjectStreamPartType = "object"
|
||||
|
||||
// ObjectStreamPartTypeTextDelta is emitted for text deltas (if model generates text).
|
||||
ObjectStreamPartTypeTextDelta ObjectStreamPartType = "text-delta"
|
||||
|
||||
// ObjectStreamPartTypeError is emitted when an error occurs.
|
||||
ObjectStreamPartTypeError ObjectStreamPartType = "error"
|
||||
|
||||
// ObjectStreamPartTypeFinish is emitted when streaming completes.
|
||||
ObjectStreamPartTypeFinish ObjectStreamPartType = "finish"
|
||||
)
|
||||
|
||||
// ObjectStreamPart represents a single chunk in the object stream.
|
||||
type ObjectStreamPart struct {
|
||||
Type ObjectStreamPartType
|
||||
Object any
|
||||
Delta string
|
||||
Error error
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
Warnings []CallWarning
|
||||
ProviderMetadata ProviderMetadata
|
||||
}
|
||||
|
||||
// ObjectStreamResponse is an iterator over ObjectStreamPart.
|
||||
type ObjectStreamResponse = iter.Seq[ObjectStreamPart]
|
||||
|
||||
// ObjectResult is a typed result wrapper returned by GenerateObject[T].
|
||||
type ObjectResult[T any] struct {
|
||||
Object T
|
||||
RawText string
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
Warnings []CallWarning
|
||||
ProviderMetadata ProviderMetadata
|
||||
}
|
||||
|
||||
// StreamObjectResult provides typed access to a streaming object generation result.
|
||||
type StreamObjectResult[T any] struct {
|
||||
stream ObjectStreamResponse
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewStreamObjectResult creates a typed stream result from an untyped stream.
|
||||
func NewStreamObjectResult[T any](ctx context.Context, stream ObjectStreamResponse) *StreamObjectResult[T] {
|
||||
return &StreamObjectResult[T]{
|
||||
stream: stream,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// PartialObjectStream returns an iterator that yields progressively more complete objects.
|
||||
// Only emits when the object actually changes (deduplication).
|
||||
func (s *StreamObjectResult[T]) PartialObjectStream() iter.Seq[T] {
|
||||
return func(yield func(T) bool) {
|
||||
var lastObject T
|
||||
var hasEmitted bool
|
||||
|
||||
for part := range s.stream {
|
||||
if part.Type == ObjectStreamPartTypeObject && part.Object != nil {
|
||||
var current T
|
||||
if err := unmarshalObject(part.Object, ¤t); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasEmitted || !reflect.DeepEqual(current, lastObject) {
|
||||
if !yield(current) {
|
||||
return
|
||||
}
|
||||
lastObject = current
|
||||
hasEmitted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TextStream returns an iterator that yields text deltas.
|
||||
// Useful if the model generates explanatory text alongside the object.
|
||||
func (s *StreamObjectResult[T]) TextStream() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for part := range s.stream {
|
||||
if part.Type == ObjectStreamPartTypeTextDelta && part.Delta != "" {
|
||||
if !yield(part.Delta) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FullStream returns an iterator that yields all stream parts including errors and metadata.
|
||||
func (s *StreamObjectResult[T]) FullStream() iter.Seq[ObjectStreamPart] {
|
||||
return s.stream
|
||||
}
|
||||
|
||||
// Object waits for the stream to complete and returns the final object.
|
||||
// Returns an error if streaming fails or no valid object was generated.
|
||||
func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) {
|
||||
var finalObject T
|
||||
var usage Usage
|
||||
var finishReason FinishReason
|
||||
var warnings []CallWarning
|
||||
var providerMetadata ProviderMetadata
|
||||
var rawText string
|
||||
var lastError error
|
||||
hasObject := false
|
||||
|
||||
for part := range s.stream {
|
||||
switch part.Type {
|
||||
case ObjectStreamPartTypeObject:
|
||||
if part.Object != nil {
|
||||
if err := unmarshalObject(part.Object, &finalObject); err == nil {
|
||||
hasObject = true
|
||||
if jsonBytes, err := json.Marshal(part.Object); err == nil {
|
||||
rawText = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case ObjectStreamPartTypeError:
|
||||
lastError = part.Error
|
||||
|
||||
case ObjectStreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
if len(part.Warnings) > 0 {
|
||||
warnings = part.Warnings
|
||||
}
|
||||
if len(part.ProviderMetadata) > 0 {
|
||||
providerMetadata = part.ProviderMetadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
return nil, lastError
|
||||
}
|
||||
|
||||
if !hasObject {
|
||||
return nil, &NoObjectGeneratedError{
|
||||
RawText: rawText,
|
||||
ParseError: fmt.Errorf("no valid object generated in stream"),
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
}
|
||||
}
|
||||
|
||||
return &ObjectResult[T]{
|
||||
Object: finalObject,
|
||||
RawText: rawText,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
Warnings: warnings,
|
||||
ProviderMetadata: providerMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unmarshalObject(obj any, target any) error {
|
||||
jsonBytes, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal object: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, target); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal into target type: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
-616
@@ -1,616 +0,0 @@
|
||||
// Package object provides utilities for generating structured objects with automatic schema generation.
|
||||
// It simplifies working with typed structured outputs by handling schema reflection and unmarshaling.
|
||||
package object
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/schema"
|
||||
)
|
||||
|
||||
// Generate generates a structured object that matches the given type T.
|
||||
// The schema is automatically generated from T using reflection.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type Recipe struct {
|
||||
// Name string `json:"name"`
|
||||
// Ingredients []string `json:"ingredients"`
|
||||
// }
|
||||
//
|
||||
// result, err := object.Generate[Recipe](ctx, model, fantasy.ObjectCall{
|
||||
// Prompt: fantasy.Prompt{fantasy.NewUserMessage("Generate a lasagna recipe")},
|
||||
// })
|
||||
func Generate[T any](
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
opts fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResult[T], error) {
|
||||
var zero T
|
||||
s := schema.Generate(reflect.TypeOf(zero))
|
||||
opts.Schema = s
|
||||
|
||||
resp, err := model.GenerateObject(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result T
|
||||
if err := unmarshal(resp.Object, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal to %T: %w", result, err)
|
||||
}
|
||||
|
||||
return &fantasy.ObjectResult[T]{
|
||||
Object: result,
|
||||
RawText: resp.RawText,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
Warnings: resp.Warnings,
|
||||
ProviderMetadata: resp.ProviderMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream streams a structured object that matches the given type T.
|
||||
// Returns a StreamObjectResult[T] with progressive updates and deduplication.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// stream, err := object.Stream[Recipe](ctx, model, fantasy.ObjectCall{
|
||||
// Prompt: fantasy.Prompt{fantasy.NewUserMessage("Generate a lasagna recipe")},
|
||||
// })
|
||||
//
|
||||
// for partial := range stream.PartialObjectStream() {
|
||||
// fmt.Printf("Progress: %s\n", partial.Name)
|
||||
// }
|
||||
//
|
||||
// result, err := stream.Object() // Wait for final result
|
||||
func Stream[T any](
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
opts fantasy.ObjectCall,
|
||||
) (*fantasy.StreamObjectResult[T], error) {
|
||||
var zero T
|
||||
s := schema.Generate(reflect.TypeOf(zero))
|
||||
opts.Schema = s
|
||||
|
||||
stream, err := model.StreamObject(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fantasy.NewStreamObjectResult[T](ctx, stream), nil
|
||||
}
|
||||
|
||||
// GenerateWithTool is a helper for providers without native JSON mode.
|
||||
// It converts the schema to a tool definition, forces the model to call it,
|
||||
// and extracts the tool's input as the structured output.
|
||||
func GenerateWithTool(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResponse, error) {
|
||||
toolName := call.SchemaName
|
||||
if toolName == "" {
|
||||
toolName = "generate_object"
|
||||
}
|
||||
|
||||
toolDescription := call.SchemaDescription
|
||||
if toolDescription == "" {
|
||||
toolDescription = "Generate a structured object matching the schema"
|
||||
}
|
||||
|
||||
tool := fantasy.FunctionTool{
|
||||
Name: toolName,
|
||||
Description: toolDescription,
|
||||
InputSchema: schema.ToMap(call.Schema),
|
||||
}
|
||||
|
||||
toolChoice := fantasy.SpecificToolChoice(tool.Name)
|
||||
resp, err := model.Generate(ctx, fantasy.Call{
|
||||
Prompt: call.Prompt,
|
||||
Tools: []fantasy.Tool{tool},
|
||||
ToolChoice: &toolChoice,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool-based generation failed: %w", err)
|
||||
}
|
||||
|
||||
toolCalls := resp.Content.ToolCalls()
|
||||
if len(toolCalls) == 0 {
|
||||
return nil, &fantasy.NoObjectGeneratedError{
|
||||
RawText: resp.Content.Text(),
|
||||
ParseError: fmt.Errorf("no tool call generated"),
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
}
|
||||
}
|
||||
|
||||
toolCall := toolCalls[0]
|
||||
|
||||
var obj any
|
||||
if call.RepairText != nil {
|
||||
obj, err = schema.ParseAndValidateWithRepair(ctx, toolCall.Input, call.Schema, call.RepairText)
|
||||
} else {
|
||||
obj, err = schema.ParseAndValidate(toolCall.Input, call.Schema)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
|
||||
nogErr.Usage = resp.Usage
|
||||
nogErr.FinishReason = resp.FinishReason
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fantasy.ObjectResponse{
|
||||
Object: obj,
|
||||
RawText: toolCall.Input,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
Warnings: resp.Warnings,
|
||||
ProviderMetadata: resp.ProviderMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateWithText is a helper for providers without tool or JSON mode support.
|
||||
// It adds the schema to the system prompt and parses the text response as JSON.
|
||||
// This is a fallback for older models or simple providers.
|
||||
func GenerateWithText(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResponse, error) {
|
||||
jsonSchemaBytes, err := json.Marshal(call.Schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal schema: %w", err)
|
||||
}
|
||||
|
||||
schemaInstruction := fmt.Sprintf(
|
||||
"You must respond with valid JSON that matches this schema: %s\n"+
|
||||
"Respond ONLY with the JSON object, no additional text or explanation.",
|
||||
string(jsonSchemaBytes),
|
||||
)
|
||||
|
||||
enhancedPrompt := make(fantasy.Prompt, 0, len(call.Prompt)+1)
|
||||
|
||||
hasSystem := false
|
||||
for _, msg := range call.Prompt {
|
||||
if msg.Role == fantasy.MessageRoleSystem {
|
||||
hasSystem = true
|
||||
existingText := ""
|
||||
if len(msg.Content) > 0 {
|
||||
if textPart, ok := msg.Content[0].(fantasy.TextPart); ok {
|
||||
existingText = textPart.Text
|
||||
}
|
||||
}
|
||||
enhancedPrompt = append(enhancedPrompt, fantasy.NewSystemMessage(existingText+"\n\n"+schemaInstruction))
|
||||
} else {
|
||||
enhancedPrompt = append(enhancedPrompt, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSystem {
|
||||
enhancedPrompt = append(fantasy.Prompt{fantasy.NewSystemMessage(schemaInstruction)}, call.Prompt...)
|
||||
}
|
||||
|
||||
resp, err := model.Generate(ctx, fantasy.Call{
|
||||
Prompt: enhancedPrompt,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("text-based generation failed: %w", err)
|
||||
}
|
||||
|
||||
textContent := resp.Content.Text()
|
||||
if textContent == "" {
|
||||
return nil, &fantasy.NoObjectGeneratedError{
|
||||
RawText: "",
|
||||
ParseError: fmt.Errorf("no text content in response"),
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
}
|
||||
}
|
||||
|
||||
var obj any
|
||||
if call.RepairText != nil {
|
||||
obj, err = schema.ParseAndValidateWithRepair(ctx, textContent, call.Schema, call.RepairText)
|
||||
} else {
|
||||
obj, err = schema.ParseAndValidate(textContent, call.Schema)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if nogErr, ok := err.(*schema.ParseError); ok {
|
||||
return nil, &fantasy.NoObjectGeneratedError{
|
||||
RawText: nogErr.RawText,
|
||||
ParseError: nogErr.ParseError,
|
||||
ValidationError: nogErr.ValidationError,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fantasy.ObjectResponse{
|
||||
Object: obj,
|
||||
RawText: textContent,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
Warnings: resp.Warnings,
|
||||
ProviderMetadata: resp.ProviderMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StreamWithTool is a helper for providers without native JSON streaming.
|
||||
// It uses streaming tool calls to extract and parse the structured output progressively.
|
||||
func StreamWithTool(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (fantasy.ObjectStreamResponse, error) {
|
||||
// Create a tool from the schema
|
||||
toolName := call.SchemaName
|
||||
if toolName == "" {
|
||||
toolName = "generate_object"
|
||||
}
|
||||
|
||||
toolDescription := call.SchemaDescription
|
||||
if toolDescription == "" {
|
||||
toolDescription = "Generate a structured object matching the schema"
|
||||
}
|
||||
|
||||
tool := fantasy.FunctionTool{
|
||||
Name: toolName,
|
||||
Description: toolDescription,
|
||||
InputSchema: schema.ToMap(call.Schema),
|
||||
}
|
||||
|
||||
// Make a streaming Generate call with forced tool choice
|
||||
toolChoice := fantasy.SpecificToolChoice(tool.Name)
|
||||
stream, err := model.Stream(ctx, fantasy.Call{
|
||||
Prompt: call.Prompt,
|
||||
Tools: []fantasy.Tool{tool},
|
||||
ToolChoice: &toolChoice,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool-based streaming failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert the text stream to object stream parts
|
||||
return func(yield func(fantasy.ObjectStreamPart) bool) {
|
||||
var accumulated string
|
||||
var lastParsedObject any
|
||||
var usage fantasy.Usage
|
||||
var finishReason fantasy.FinishReason
|
||||
var warnings []fantasy.CallWarning
|
||||
var providerMetadata fantasy.ProviderMetadata
|
||||
var streamErr error
|
||||
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
case fantasy.StreamPartTypeTextDelta:
|
||||
accumulated += part.Delta
|
||||
|
||||
obj, state, parseErr := schema.ParsePartialJSON(accumulated)
|
||||
|
||||
if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
|
||||
if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state == schema.ParseStateFailed && call.RepairText != nil {
|
||||
repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
|
||||
if repairErr == nil {
|
||||
obj2, state2, _ := schema.ParsePartialJSON(repairedText)
|
||||
if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
|
||||
schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
|
||||
if !reflect.DeepEqual(obj2, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj2,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeToolInputDelta:
|
||||
accumulated += part.Delta
|
||||
|
||||
obj, state, parseErr := schema.ParsePartialJSON(accumulated)
|
||||
if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
|
||||
if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state == schema.ParseStateFailed && call.RepairText != nil {
|
||||
repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
|
||||
if repairErr == nil {
|
||||
obj2, state2, _ := schema.ParsePartialJSON(repairedText)
|
||||
if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
|
||||
schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
|
||||
if !reflect.DeepEqual(obj2, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj2,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeToolCall:
|
||||
toolInput := part.ToolCallInput
|
||||
|
||||
var obj any
|
||||
var err error
|
||||
if call.RepairText != nil {
|
||||
obj, err = schema.ParseAndValidateWithRepair(ctx, toolInput, call.Schema, call.RepairText)
|
||||
} else {
|
||||
obj, err = schema.ParseAndValidate(toolInput, call.Schema)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeError:
|
||||
streamErr = part.Error
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: part.Error,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
|
||||
case fantasy.StreamPartTypeWarnings:
|
||||
warnings = part.Warnings
|
||||
}
|
||||
|
||||
if len(part.ProviderMetadata) > 0 {
|
||||
providerMetadata = part.ProviderMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr == nil && lastParsedObject != nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeFinish,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
Warnings: warnings,
|
||||
ProviderMetadata: providerMetadata,
|
||||
})
|
||||
} else if streamErr == nil && lastParsedObject == nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: &fantasy.NoObjectGeneratedError{
|
||||
RawText: accumulated,
|
||||
ParseError: fmt.Errorf("no valid object generated in stream"),
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StreamWithText is a helper for providers without tool or JSON streaming support.
|
||||
// It adds the schema to the system prompt and parses the streamed text as JSON progressively.
|
||||
func StreamWithText(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (fantasy.ObjectStreamResponse, error) {
|
||||
jsonSchemaMap := schema.ToMap(call.Schema)
|
||||
jsonSchemaBytes, err := json.Marshal(jsonSchemaMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal schema: %w", err)
|
||||
}
|
||||
|
||||
schemaInstruction := fmt.Sprintf(
|
||||
"You must respond with valid JSON that matches this schema: %s\n"+
|
||||
"Respond ONLY with the JSON object, no additional text or explanation.",
|
||||
string(jsonSchemaBytes),
|
||||
)
|
||||
|
||||
enhancedPrompt := make(fantasy.Prompt, 0, len(call.Prompt)+1)
|
||||
|
||||
hasSystem := false
|
||||
for _, msg := range call.Prompt {
|
||||
if msg.Role == fantasy.MessageRoleSystem {
|
||||
hasSystem = true
|
||||
existingText := ""
|
||||
if len(msg.Content) > 0 {
|
||||
if textPart, ok := msg.Content[0].(fantasy.TextPart); ok {
|
||||
existingText = textPart.Text
|
||||
}
|
||||
}
|
||||
enhancedPrompt = append(enhancedPrompt, fantasy.NewSystemMessage(existingText+"\n\n"+schemaInstruction))
|
||||
} else {
|
||||
enhancedPrompt = append(enhancedPrompt, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSystem {
|
||||
enhancedPrompt = append(fantasy.Prompt{fantasy.NewSystemMessage(schemaInstruction)}, call.Prompt...)
|
||||
}
|
||||
|
||||
stream, err := model.Stream(ctx, fantasy.Call{
|
||||
Prompt: enhancedPrompt,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("text-based streaming failed: %w", err)
|
||||
}
|
||||
|
||||
return func(yield func(fantasy.ObjectStreamPart) bool) {
|
||||
var accumulated string
|
||||
var lastParsedObject any
|
||||
var usage fantasy.Usage
|
||||
var finishReason fantasy.FinishReason
|
||||
var warnings []fantasy.CallWarning
|
||||
var providerMetadata fantasy.ProviderMetadata
|
||||
var streamErr error
|
||||
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
case fantasy.StreamPartTypeTextDelta:
|
||||
accumulated += part.Delta
|
||||
|
||||
obj, state, parseErr := schema.ParsePartialJSON(accumulated)
|
||||
|
||||
if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
|
||||
if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state == schema.ParseStateFailed && call.RepairText != nil {
|
||||
repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
|
||||
if repairErr == nil {
|
||||
obj2, state2, _ := schema.ParsePartialJSON(repairedText)
|
||||
if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
|
||||
schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
|
||||
if !reflect.DeepEqual(obj2, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj2,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeError:
|
||||
streamErr = part.Error
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: part.Error,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
|
||||
case fantasy.StreamPartTypeWarnings:
|
||||
warnings = part.Warnings
|
||||
}
|
||||
|
||||
if len(part.ProviderMetadata) > 0 {
|
||||
providerMetadata = part.ProviderMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr == nil && lastParsedObject != nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeFinish,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
Warnings: warnings,
|
||||
ProviderMetadata: providerMetadata,
|
||||
})
|
||||
} else if streamErr == nil && lastParsedObject == nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: &fantasy.NoObjectGeneratedError{
|
||||
RawText: accumulated,
|
||||
ParseError: fmt.Errorf("no valid object generated in stream"),
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unmarshal(obj any, target any) error {
|
||||
jsonBytes, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal object: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, target); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal into target type: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
package fantasy
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Provider represents a provider of language models.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
LanguageModel(ctx context.Context, modelID string) (LanguageModel, error)
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
package fantasy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// providerDataJSON is the serialized wrapper used by the registry.
|
||||
type providerDataJSON struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// UnmarshalFunc converts raw JSON into a ProviderOptionsData implementation.
|
||||
type UnmarshalFunc func([]byte) (ProviderOptionsData, error)
|
||||
|
||||
// providerRegistry uses sync.Map for lock-free reads after initialization.
|
||||
// All registrations happen in init() functions before concurrent access.
|
||||
var providerRegistry sync.Map
|
||||
|
||||
// RegisterProviderType registers a provider type ID with its unmarshal function.
|
||||
// Type IDs must be globally unique (e.g. "openai.options").
|
||||
// This should only be called during package initialization (init functions).
|
||||
func RegisterProviderType(typeID string, unmarshalFn UnmarshalFunc) {
|
||||
providerRegistry.Store(typeID, unmarshalFn)
|
||||
}
|
||||
|
||||
// unmarshalProviderData routes a typed payload to the correct constructor.
|
||||
func unmarshalProviderData(data []byte) (ProviderOptionsData, error) {
|
||||
var pj providerDataJSON
|
||||
if err := json.Unmarshal(data, &pj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
val, exists := providerRegistry.Load(pj.Type)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unknown provider data type: %s", pj.Type)
|
||||
}
|
||||
|
||||
unmarshalFn := val.(UnmarshalFunc) //nolint:forcetypeassert // type enforced by RegisterProviderType
|
||||
return unmarshalFn(pj.Data)
|
||||
}
|
||||
|
||||
// unmarshalProviderDataMap is a helper for unmarshaling maps of provider data.
|
||||
func unmarshalProviderDataMap(data map[string]json.RawMessage) (map[string]ProviderOptionsData, error) {
|
||||
result := make(map[string]ProviderOptionsData)
|
||||
for provider, rawData := range data {
|
||||
providerData, err := unmarshalProviderData(rawData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal provider data for %s: %w", provider, err)
|
||||
}
|
||||
result[provider] = providerData
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UnmarshalProviderOptions unmarshals a map of provider options by type.
|
||||
func UnmarshalProviderOptions(data map[string]json.RawMessage) (ProviderOptions, error) {
|
||||
return unmarshalProviderDataMap(data)
|
||||
}
|
||||
|
||||
// UnmarshalProviderMetadata unmarshals a map of provider metadata by type.
|
||||
func UnmarshalProviderMetadata(data map[string]json.RawMessage) (ProviderMetadata, error) {
|
||||
return unmarshalProviderDataMap(data)
|
||||
}
|
||||
|
||||
// MarshalProviderType marshals provider data with a type wrapper using generics.
|
||||
// To avoid infinite recursion, use the "type plain T" pattern before calling this.
|
||||
//
|
||||
// Usage in provider types:
|
||||
//
|
||||
// func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
// type plain ProviderOptions
|
||||
// return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
// }
|
||||
func MarshalProviderType[T any](typeID string, data T) ([]byte, error) {
|
||||
rawData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(providerDataJSON{
|
||||
Type: typeID,
|
||||
Data: json.RawMessage(rawData),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalProviderType unmarshals provider data without type wrapper using generics.
|
||||
// To avoid infinite recursion, unmarshal to a plain type first.
|
||||
// Note: This receives the inner 'data' field after type routing by the registry.
|
||||
//
|
||||
// Usage in provider types:
|
||||
//
|
||||
// func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
// type plain ProviderOptions
|
||||
// var p plain
|
||||
// if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// *o = ProviderOptions(p)
|
||||
// return nil
|
||||
// }
|
||||
func UnmarshalProviderType[T any](data []byte, target *T) error {
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,29 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/smithy-go/auth/bearer"
|
||||
)
|
||||
|
||||
func bedrockBasicAuthConfig(apiKey string) aws.Config {
|
||||
return aws.Config{
|
||||
Region: cmp.Or(os.Getenv("AWS_REGION"), "us-east-1"),
|
||||
BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}},
|
||||
}
|
||||
}
|
||||
|
||||
func bedrockPrefixModelWithRegion(modelID string) string {
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if len(region) < 2 {
|
||||
region = "us-east-1"
|
||||
}
|
||||
prefix := region[:2] + "."
|
||||
if strings.HasPrefix(modelID, prefix) {
|
||||
return modelID
|
||||
}
|
||||
return prefix + modelID
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/charmbracelet/anthropic-sdk-go"
|
||||
)
|
||||
|
||||
func toProviderErr(err error) error {
|
||||
var apiErr *anthropic.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return &fantasy.ProviderError{
|
||||
Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"),
|
||||
Message: apiErr.Error(),
|
||||
Cause: apiErr,
|
||||
URL: apiErr.Request.URL.String(),
|
||||
StatusCode: apiErr.StatusCode,
|
||||
RequestBody: apiErr.DumpRequest(true),
|
||||
ResponseHeaders: toHeaderMap(apiErr.Response.Header),
|
||||
ResponseBody: apiErr.DumpResponse(true),
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func toHeaderMap(in http.Header) (out map[string]string) {
|
||||
out = make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
if l := len(v); l > 0 {
|
||||
out[k] = v[l-1]
|
||||
in[strings.ToLower(k)] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type googleDummyTokenSource struct{}
|
||||
|
||||
func (googleDummyTokenSource) Token() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "dummy-token"}, nil
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Package anthropic provides an implementation of the fantasy AI SDK for Anthropic's language models.
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// Global type identifiers for Anthropic-specific provider data.
|
||||
const (
|
||||
TypeProviderOptions = Name + ".options"
|
||||
TypeReasoningOptionMetadata = Name + ".reasoning_metadata"
|
||||
TypeProviderCacheControl = Name + ".cache_control_options"
|
||||
)
|
||||
|
||||
// Register Anthropic provider-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeReasoningOptionMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ReasoningOptionMetadata
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeProviderCacheControl, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderCacheControlOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ProviderOptions represents additional options for the Anthropic provider.
|
||||
type ProviderOptions struct {
|
||||
SendReasoning *bool `json:"send_reasoning"`
|
||||
Thinking *ThinkingProviderOption `json:"thinking"`
|
||||
DisableParallelToolUse *bool `json:"disable_parallel_tool_use"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (o *ProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderOptions.
|
||||
func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderOptions.
|
||||
func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ThinkingProviderOption represents thinking options for the Anthropic provider.
|
||||
type ThinkingProviderOption struct {
|
||||
BudgetTokens int64 `json:"budget_tokens"`
|
||||
}
|
||||
|
||||
// ReasoningOptionMetadata represents reasoning metadata for the Anthropic provider.
|
||||
type ReasoningOptionMetadata struct {
|
||||
Signature string `json:"signature"`
|
||||
RedactedData string `json:"redacted_data"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ReasoningOptionMetadata) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ReasoningOptionMetadata.
|
||||
func (m ReasoningOptionMetadata) MarshalJSON() ([]byte, error) {
|
||||
type plain ReasoningOptionMetadata
|
||||
return fantasy.MarshalProviderType(TypeReasoningOptionMetadata, plain(m))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ReasoningOptionMetadata.
|
||||
func (m *ReasoningOptionMetadata) UnmarshalJSON(data []byte) error {
|
||||
type plain ReasoningOptionMetadata
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ReasoningOptionMetadata(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderCacheControlOptions represents cache control options for the Anthropic provider.
|
||||
type ProviderCacheControlOptions struct {
|
||||
CacheControl CacheControl `json:"cache_control"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ProviderCacheControlOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderCacheControlOptions.
|
||||
func (o ProviderCacheControlOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderCacheControlOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderCacheControl, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderCacheControlOptions.
|
||||
func (o *ProviderCacheControlOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderCacheControlOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderCacheControlOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CacheControl represents cache control settings for the Anthropic provider.
|
||||
type CacheControl struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// NewProviderOptions creates new provider options for the Anthropic provider.
|
||||
func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// NewProviderCacheControlOptions creates new cache control options for the Anthropic provider.
|
||||
func NewProviderCacheControlOptions(opts *ProviderCacheControlOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseOptions parses provider options from a map for the Anthropic provider.
|
||||
func ParseOptions(data map[string]any) (*ProviderOptions, error) {
|
||||
var options ProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# Google Provider
|
||||
|
||||
This document describes how to get an API keys for Google Gemini and Vertex.
|
||||
|
||||
## Gemini
|
||||
|
||||
Simply navigate to [this page](https://aistudio.google.com/apikey) in the
|
||||
Google AI Studio and create a new API key.
|
||||
|
||||
## Vertex
|
||||
|
||||
### Install `gcloud`
|
||||
|
||||
Install the `gcloud` command line tool. Install via Homebrew, Nix, or download
|
||||
it from [here](https://cloud.google.com/sdk/docs/install).
|
||||
|
||||
```bash
|
||||
# Homebrew
|
||||
brew install --cask google-cloud-sdk
|
||||
|
||||
# Nix
|
||||
nix-env -iA nixpkgs.google-cloud-sdk
|
||||
```
|
||||
|
||||
### Authenticate
|
||||
|
||||
Then authenticate with your Google account:
|
||||
|
||||
```bash
|
||||
gcloud auth login
|
||||
```
|
||||
|
||||
### Create And Setup Project
|
||||
|
||||
Navigate here to create a new project if you haven't already:
|
||||
https://console.cloud.google.com/projectcreate
|
||||
|
||||
Alternatively, you can create a new project via the command line:
|
||||
|
||||
```bash
|
||||
gcloud projects create {YOUR_PROJECT_ID} --name="{YOUR_PROJECT_NAME}"
|
||||
```
|
||||
|
||||
Set the project on your machine:
|
||||
|
||||
```bash
|
||||
gcloud config set project {YOUR_PROJECT_ID}
|
||||
```
|
||||
|
||||
Enable the Vertex AI API:
|
||||
|
||||
```bash
|
||||
gcloud services enable aiplatform.googleapis.com
|
||||
```
|
||||
|
||||
### Setup Env
|
||||
|
||||
Finally, you need to run this command to ensure that libraries will be able to
|
||||
find your credentials.
|
||||
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
@@ -1,15 +0,0 @@
|
||||
// Package google provides an implementation of the fantasy AI SDK for Google's language models.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cloud.google.com/go/auth"
|
||||
)
|
||||
|
||||
type dummyTokenProvider struct{}
|
||||
|
||||
// Token implements the auth.TokenProvider interface.
|
||||
func (dummyTokenProvider) Token(_ context.Context) (*auth.Token, error) {
|
||||
return &auth.Token{Value: "dummy-token"}, nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
func toProviderErr(err error) error {
|
||||
var apiErr genai.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
return &fantasy.ProviderError{
|
||||
Message: apiErr.Message,
|
||||
Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.Code), "provider request failed"),
|
||||
Cause: err,
|
||||
StatusCode: apiErr.Code,
|
||||
ResponseBody: []byte(apiErr.Message),
|
||||
}
|
||||
}
|
||||
Loaded 100 of 599 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user