mirror of
https://github.com/BishopFox/sliver
synced 2026-06-08 10:29:05 +00:00
610 lines
15 KiB
Go
610 lines
15 KiB
Go
//go:build client
|
|
|
|
package serverctx
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/huh/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/bishopfox/sliver/client/assets"
|
|
"github.com/bishopfox/sliver/client/console"
|
|
clienttheme "github.com/bishopfox/sliver/client/theme"
|
|
"github.com/bishopfox/sliver/client/transport"
|
|
"github.com/bishopfox/sliver/protobuf/commonpb"
|
|
"github.com/spf13/cobra"
|
|
"google.golang.org/grpc/connectivity"
|
|
)
|
|
|
|
// Commands returns client-only server connection context commands.
|
|
func Commands(con *console.SliverClient) []*cobra.Command {
|
|
root := &cobra.Command{
|
|
Use: "server",
|
|
Short: "Show and manage the current server connection",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
serverInfo(cmd, con)
|
|
},
|
|
}
|
|
|
|
switchCmd := &cobra.Command{
|
|
Use: "switch",
|
|
Short: "Switch to another server/operator profile",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
serverSwitch(cmd, con)
|
|
},
|
|
}
|
|
root.AddCommand(switchCmd)
|
|
|
|
return []*cobra.Command{root}
|
|
}
|
|
|
|
func serverInfo(_ *cobra.Command, con *console.SliverClient) {
|
|
details, state, ok := con.CurrentConnection()
|
|
if !ok || con.Rpc == nil {
|
|
con.PrintErrorf("Not connected\n")
|
|
return
|
|
}
|
|
|
|
var hostPort string
|
|
operator := ""
|
|
fingerprint := ""
|
|
if details != nil && details.Config != nil {
|
|
hostPort = fmt.Sprintf("%s:%d", details.Config.LHost, details.Config.LPort)
|
|
operator = operatorFromConfig(details.Config)
|
|
fingerprint = shortCertFingerprint(details.Config)
|
|
}
|
|
if operator == "" {
|
|
operator = "<unknown>"
|
|
}
|
|
if hostPort == "" {
|
|
hostPort = "<unknown>"
|
|
}
|
|
|
|
ver, err := con.Rpc.GetVersion(context.Background(), &commonpb.Empty{})
|
|
if err != nil {
|
|
con.PrintErrorf("GetVersion failed: %s\n", err)
|
|
return
|
|
}
|
|
|
|
const keyWidth = 8 // Align on ": " (e.g., "Operator", "Version")
|
|
|
|
label := func(key string) string {
|
|
// Bold "default/white" label keys, padded so colons align.
|
|
return console.StyleBoldGray.Render(fmt.Sprintf("%-*s:", keyWidth, key))
|
|
}
|
|
valueSecondary := func(s string) string { return console.StylePurple.Render(s) }
|
|
valueDim := func(s string) string { return console.StyleGray.Render(s) }
|
|
|
|
stateStyle := console.StyleGray
|
|
switch state {
|
|
case connectivity.Ready:
|
|
stateStyle = console.StyleGreen
|
|
case connectivity.Connecting:
|
|
stateStyle = console.StyleOrange
|
|
case connectivity.TransientFailure, connectivity.Shutdown:
|
|
stateStyle = console.StyleRed
|
|
case connectivity.Idle:
|
|
stateStyle = console.StyleOrange
|
|
}
|
|
|
|
// Server/operator values should be "default" terminal text (not themed colorized).
|
|
con.Printf("%s %s\n", label("Server"), hostPort)
|
|
if details != nil && details.ConfigKey != "" {
|
|
con.Printf("%s %s\n", label("Profile"), valueSecondary(details.ConfigKey))
|
|
}
|
|
con.Printf("%s %s\n", label("Operator"), operator)
|
|
if fingerprint != "" {
|
|
con.Printf("%s %s\n", label("Cert"), valueDim(fingerprint))
|
|
}
|
|
con.Printf("%s %s\n", label("gRPC"), stateStyle.Render(state.String()))
|
|
con.Printf("%s %s %s\n",
|
|
label("Version"),
|
|
console.StyleBold.Render(fmt.Sprintf("%d.%d.%d", ver.Major, ver.Minor, ver.Patch)),
|
|
valueDim("("+ver.Commit+")"),
|
|
)
|
|
}
|
|
|
|
func serverSwitch(_ *cobra.Command, con *console.SliverClient) {
|
|
configs := assets.GetConfigs()
|
|
if len(configs) == 0 {
|
|
con.PrintErrorf("No config files found at %s\n", assets.GetConfigDir())
|
|
return
|
|
}
|
|
|
|
keys := make([]string, 0, len(configs))
|
|
for k := range configs {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
mgr := getConsoleManager(con)
|
|
mgr.syncActiveFromConsole(con)
|
|
|
|
instances, activeID := mgr.snapshot()
|
|
result, err := runSwitchTUI(instances, activeID, keys, mgr.configInstanceCountsLocked(instances))
|
|
if err != nil {
|
|
con.PrintErrorf("TUI error: %s\n", err)
|
|
return
|
|
}
|
|
if result.Action == actionNone {
|
|
return
|
|
}
|
|
switch result.Action {
|
|
case actionSwitchExisting:
|
|
inst := mgr.findInstance(result.ExistingID)
|
|
if inst == nil || inst.Config == nil {
|
|
con.PrintErrorf("Invalid selection\n")
|
|
return
|
|
}
|
|
if inst.ID == activeID {
|
|
con.PrintInfof("Already on %s\n", inst.DisplayName())
|
|
return
|
|
}
|
|
|
|
rpc, conn, err := transport.MTLSConnect(inst.Config)
|
|
if err != nil {
|
|
con.PrintErrorf("Connection failed: %s\n", err)
|
|
return
|
|
}
|
|
|
|
if err := con.SetConnection(rpc, conn, &console.ConnectionDetails{ConfigKey: inst.ConfigKey, Config: inst.Config}); err != nil {
|
|
_ = transport.CloseGRPCConnection(conn)
|
|
con.PrintErrorf("Switch failed: %s\n", err)
|
|
return
|
|
}
|
|
|
|
mgr.setActive(inst.ID)
|
|
con.PrintSuccessf("Switched to %s:%d (%s)\n", inst.Config.LHost, inst.Config.LPort, inst.DisplayName())
|
|
|
|
case actionNewConsole:
|
|
cfg := configs[result.ConfigKey]
|
|
if cfg == nil {
|
|
con.PrintErrorf("Invalid selection\n")
|
|
return
|
|
}
|
|
|
|
rpc, conn, err := transport.MTLSConnect(cfg)
|
|
if err != nil {
|
|
con.PrintErrorf("Connection failed: %s\n", err)
|
|
return
|
|
}
|
|
|
|
if err := con.SetConnection(rpc, conn, &console.ConnectionDetails{ConfigKey: result.ConfigKey, Config: cfg}); err != nil {
|
|
_ = transport.CloseGRPCConnection(conn)
|
|
con.PrintErrorf("Switch failed: %s\n", err)
|
|
return
|
|
}
|
|
|
|
inst := mgr.addInstance(result.ConfigKey, cfg)
|
|
con.PrintSuccessf("Connected to %s:%d (%s)\n", cfg.LHost, cfg.LPort, inst.DisplayName())
|
|
}
|
|
}
|
|
|
|
var consoleManagers sync.Map // *console.SliverClient -> *consoleManager
|
|
|
|
type consoleManager struct {
|
|
mu sync.Mutex
|
|
nextID int
|
|
activeID string
|
|
insts []*consoleInstance
|
|
}
|
|
|
|
type consoleInstance struct {
|
|
ID string
|
|
ConfigKey string
|
|
Config *assets.ClientConfig
|
|
CreatedAt time.Time
|
|
LastUsed time.Time
|
|
}
|
|
|
|
func (i *consoleInstance) DisplayName() string {
|
|
if i == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("console-%s", i.ID)
|
|
}
|
|
|
|
func getConsoleManager(con *console.SliverClient) *consoleManager {
|
|
if con == nil {
|
|
return &consoleManager{}
|
|
}
|
|
if v, ok := consoleManagers.Load(con); ok {
|
|
return v.(*consoleManager)
|
|
}
|
|
m := &consoleManager{}
|
|
actual, _ := consoleManagers.LoadOrStore(con, m)
|
|
return actual.(*consoleManager)
|
|
}
|
|
|
|
func (m *consoleManager) syncActiveFromConsole(con *console.SliverClient) {
|
|
if m == nil || con == nil {
|
|
return
|
|
}
|
|
details, _, ok := con.CurrentConnection()
|
|
if !ok || details == nil || details.Config == nil || details.ConfigKey == "" {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// If we already have an active instance, keep it. Otherwise create one from the current connection.
|
|
if m.activeID != "" {
|
|
return
|
|
}
|
|
|
|
m.nextID++
|
|
id := fmt.Sprintf("%d", m.nextID)
|
|
now := time.Now()
|
|
m.activeID = id
|
|
m.insts = append(m.insts, &consoleInstance{
|
|
ID: id,
|
|
ConfigKey: details.ConfigKey,
|
|
Config: details.Config,
|
|
CreatedAt: now,
|
|
LastUsed: now,
|
|
})
|
|
}
|
|
|
|
func (m *consoleManager) snapshot() ([]*consoleInstance, string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
out := make([]*consoleInstance, 0, len(m.insts))
|
|
out = append(out, m.insts...)
|
|
return out, m.activeID
|
|
}
|
|
|
|
func (m *consoleManager) findInstance(id string) *consoleInstance {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, inst := range m.insts {
|
|
if inst != nil && inst.ID == id {
|
|
return inst
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *consoleManager) setActive(id string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.activeID = id
|
|
for _, inst := range m.insts {
|
|
if inst != nil && inst.ID == id {
|
|
inst.LastUsed = time.Now()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *consoleManager) addInstance(configKey string, cfg *assets.ClientConfig) *consoleInstance {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.nextID++
|
|
id := fmt.Sprintf("%d", m.nextID)
|
|
now := time.Now()
|
|
inst := &consoleInstance{
|
|
ID: id,
|
|
ConfigKey: configKey,
|
|
Config: cfg,
|
|
CreatedAt: now,
|
|
LastUsed: now,
|
|
}
|
|
m.insts = append(m.insts, inst)
|
|
m.activeID = id
|
|
return inst
|
|
}
|
|
|
|
func (m *consoleManager) configInstanceCountsLocked(insts []*consoleInstance) map[string]int {
|
|
// Caller provides insts snapshot; we just count.
|
|
counts := map[string]int{}
|
|
for _, inst := range insts {
|
|
if inst == nil {
|
|
continue
|
|
}
|
|
counts[inst.ConfigKey]++
|
|
}
|
|
return counts
|
|
}
|
|
|
|
type switchAction int
|
|
|
|
const (
|
|
actionNone switchAction = iota
|
|
actionSwitchExisting
|
|
actionNewConsole
|
|
)
|
|
|
|
type switchResult struct {
|
|
Action switchAction
|
|
ExistingID string
|
|
ConfigKey string
|
|
}
|
|
|
|
type switchTab int
|
|
|
|
const (
|
|
tabExisting switchTab = iota
|
|
tabNew
|
|
)
|
|
|
|
type switchTUIModel struct {
|
|
tab switchTab
|
|
existing *huh.Form
|
|
newConn *huh.Form
|
|
result switchResult
|
|
width int
|
|
height int
|
|
styles switchStyles
|
|
activeID string
|
|
counts map[string]int
|
|
initOnce bool
|
|
keyMap *huh.KeyMap
|
|
}
|
|
|
|
type switchStyles struct {
|
|
frame lipgloss.Style
|
|
header lipgloss.Style
|
|
tabActive lipgloss.Style
|
|
tabInactive lipgloss.Style
|
|
footer lipgloss.Style
|
|
footerDim lipgloss.Style
|
|
divider lipgloss.Style
|
|
}
|
|
|
|
func runSwitchTUI(instances []*consoleInstance, activeID string, configKeys []string, counts map[string]int) (switchResult, error) {
|
|
m := newSwitchTUIModel(instances, activeID, configKeys, counts)
|
|
prog := tea.NewProgram(m)
|
|
finalModel, err := prog.Run()
|
|
if err != nil {
|
|
return switchResult{}, err
|
|
}
|
|
out, ok := finalModel.(switchTUIModel)
|
|
if !ok {
|
|
return switchResult{}, fmt.Errorf("unexpected TUI state")
|
|
}
|
|
return out.result, nil
|
|
}
|
|
|
|
func newSwitchTUIModel(instances []*consoleInstance, activeID string, configKeys []string, counts map[string]int) switchTUIModel {
|
|
keyMap := huh.NewDefaultKeyMap()
|
|
// Free up tab for switching between views; use enter to select/advance.
|
|
keyMap.Select.Next = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select"))
|
|
keyMap.Select.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit"))
|
|
|
|
huhTheme := clienttheme.HuhTheme()
|
|
|
|
styles := switchStyles{
|
|
frame: lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(clienttheme.DefaultMod(300)).
|
|
Padding(0, 1),
|
|
header: lipgloss.NewStyle().MarginBottom(1),
|
|
tabActive: lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(clienttheme.DefaultMod(900)).
|
|
Background(clienttheme.Primary()).
|
|
Padding(0, 1),
|
|
tabInactive: lipgloss.NewStyle().
|
|
Foreground(clienttheme.DefaultMod(500)).
|
|
Background(clienttheme.DefaultMod(50)).
|
|
Padding(0, 1),
|
|
footer: lipgloss.NewStyle().MarginTop(1),
|
|
footerDim: lipgloss.NewStyle().Foreground(clienttheme.DefaultMod(400)),
|
|
divider: lipgloss.NewStyle().Foreground(clienttheme.DefaultMod(300)),
|
|
}
|
|
|
|
existingID := activeID
|
|
existingOptions := make([]huh.Option[string], 0, len(instances))
|
|
for _, inst := range instances {
|
|
if inst == nil {
|
|
continue
|
|
}
|
|
label := fmt.Sprintf("%s %s", inst.DisplayName(), inst.ConfigKey)
|
|
if inst.ID == activeID && activeID != "" {
|
|
label = fmt.Sprintf("* %s", label)
|
|
}
|
|
existingOptions = append(existingOptions, huh.NewOption(label, inst.ID))
|
|
}
|
|
|
|
if len(existingOptions) == 0 {
|
|
// Always provide at least one option so Huh doesn't error.
|
|
existingOptions = append(existingOptions, huh.NewOption("(no existing consoles)", ""))
|
|
}
|
|
|
|
existingSelect := huh.NewSelect[string]().
|
|
Title("Existing Consoles").
|
|
Description("Switch to an already-created console context. Use / to filter.").
|
|
Options(existingOptions...).
|
|
Key("existing").
|
|
Value(&existingID)
|
|
|
|
existingForm := huh.NewForm(huh.NewGroup(existingSelect)).
|
|
WithTheme(huhTheme).
|
|
WithKeyMap(keyMap).
|
|
WithShowHelp(false)
|
|
|
|
newKey := ""
|
|
configOptions := make([]huh.Option[string], 0, len(configKeys))
|
|
for _, k := range configKeys {
|
|
label := k
|
|
if n := counts[k]; n > 0 {
|
|
label = fmt.Sprintf("%s (existing: %d)", k, n)
|
|
}
|
|
configOptions = append(configOptions, huh.NewOption(label, k))
|
|
if newKey == "" {
|
|
newKey = k
|
|
}
|
|
}
|
|
newSelect := huh.NewSelect[string]().
|
|
Title("New Connection / Console").
|
|
Description("Create a new console context from a profile in ~/.sliver-client/configs. Use / to filter.").
|
|
Options(configOptions...).
|
|
Key("config").
|
|
Value(&newKey)
|
|
|
|
newForm := huh.NewForm(huh.NewGroup(newSelect)).
|
|
WithTheme(huhTheme).
|
|
WithKeyMap(keyMap).
|
|
WithShowHelp(false)
|
|
|
|
return switchTUIModel{
|
|
tab: tabExisting,
|
|
existing: existingForm,
|
|
newConn: newForm,
|
|
styles: styles,
|
|
activeID: activeID,
|
|
counts: counts,
|
|
keyMap: keyMap,
|
|
}
|
|
}
|
|
|
|
func (m switchTUIModel) Init() tea.Cmd {
|
|
// Initialize both forms so they're ready when switching tabs.
|
|
return tea.Batch(m.existing.Init(), m.newConn.Init())
|
|
}
|
|
|
|
func (m switchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
// Forward to both forms so they size correctly.
|
|
modelA, cmdA := m.existing.Update(msg)
|
|
modelB, cmdB := m.newConn.Update(msg)
|
|
m.existing = modelA.(*huh.Form)
|
|
m.newConn = modelB.(*huh.Form)
|
|
return m, tea.Batch(cmdA, cmdB)
|
|
|
|
case tea.KeyPressMsg:
|
|
switch msg.String() {
|
|
case "tab":
|
|
if m.tab == tabExisting {
|
|
m.tab = tabNew
|
|
} else {
|
|
m.tab = tabExisting
|
|
}
|
|
return m, nil
|
|
case "shift+tab":
|
|
if m.tab == tabExisting {
|
|
m.tab = tabNew
|
|
} else {
|
|
m.tab = tabExisting
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// Delegate to active tab's form.
|
|
switch m.tab {
|
|
case tabExisting:
|
|
model, cmd := m.existing.Update(msg)
|
|
m.existing = model.(*huh.Form)
|
|
if m.existing.State == huh.StateCompleted {
|
|
id := m.existing.GetString("existing")
|
|
if id != "" {
|
|
m.result = switchResult{Action: actionSwitchExisting, ExistingID: id}
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
if m.existing.State == huh.StateAborted {
|
|
m.result = switchResult{Action: actionNone}
|
|
return m, tea.Quit
|
|
}
|
|
return m, cmd
|
|
|
|
case tabNew:
|
|
model, cmd := m.newConn.Update(msg)
|
|
m.newConn = model.(*huh.Form)
|
|
if m.newConn.State == huh.StateCompleted {
|
|
key := m.newConn.GetString("config")
|
|
if key != "" {
|
|
m.result = switchResult{Action: actionNewConsole, ConfigKey: key}
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
if m.newConn.State == huh.StateAborted {
|
|
m.result = switchResult{Action: actionNone}
|
|
return m, tea.Quit
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m switchTUIModel) View() tea.View {
|
|
header := m.renderHeader()
|
|
|
|
body := ""
|
|
switch m.tab {
|
|
case tabExisting:
|
|
body = m.existing.View()
|
|
case tabNew:
|
|
body = m.newConn.View()
|
|
}
|
|
|
|
footer := m.renderFooter()
|
|
|
|
content := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
|
view := tea.NewView(m.styles.frame.Render(content))
|
|
view.AltScreen = true
|
|
return view
|
|
}
|
|
|
|
func (m switchTUIModel) renderHeader() string {
|
|
left := m.styles.tabInactive.Render("Existing")
|
|
right := m.styles.tabInactive.Render("New")
|
|
if m.tab == tabExisting {
|
|
left = m.styles.tabActive.Render("Existing")
|
|
} else {
|
|
right = m.styles.tabActive.Render("New")
|
|
}
|
|
line := lipgloss.JoinHorizontal(lipgloss.Left, left, m.styles.divider.Render(" "), right)
|
|
return m.styles.header.Render(line)
|
|
}
|
|
|
|
func (m switchTUIModel) renderFooter() string {
|
|
keys := "tab: switch view enter: select /: filter ctrl+c: cancel"
|
|
return m.styles.footerDim.Render(keys)
|
|
}
|
|
|
|
func operatorFromConfig(cfg *assets.ClientConfig) string {
|
|
if cfg == nil {
|
|
return ""
|
|
}
|
|
// Prefer the certificate CN, since config.Operator is mostly informational.
|
|
block, _ := pem.Decode([]byte(cfg.Certificate))
|
|
if block != nil {
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err == nil && cert.Subject.CommonName != "" {
|
|
return cert.Subject.CommonName
|
|
}
|
|
}
|
|
if strings.TrimSpace(cfg.Operator) != "" {
|
|
return strings.TrimSpace(cfg.Operator)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func shortCertFingerprint(cfg *assets.ClientConfig) string {
|
|
if cfg == nil {
|
|
return ""
|
|
}
|
|
digest := sha256.Sum256([]byte(cfg.Certificate))
|
|
return fmt.Sprintf("%x", digest[:8])
|
|
}
|