mirror of
https://github.com/tale/headplane.git
synced 2026-08-13 15:07:24 +00:00
chore: reorganize go code
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
// Config represents the configuration for the agent.
|
||||
type Config struct {
|
||||
Debug bool
|
||||
Hostname string
|
||||
TSControlURL string
|
||||
TSAuthKey string
|
||||
WorkDir string
|
||||
}
|
||||
|
||||
const (
|
||||
DebugEnv = "HEADPLANE_AGENT_DEBUG"
|
||||
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
|
||||
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
|
||||
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
|
||||
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
|
||||
)
|
||||
|
||||
// Load reads the agent configuration from environment variables.
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
Debug: false,
|
||||
Hostname: os.Getenv(HostnameEnv),
|
||||
TSControlURL: os.Getenv(TSControlURLEnv),
|
||||
TSAuthKey: os.Getenv(TSAuthKeyEnv),
|
||||
WorkDir: os.Getenv(WorkDirEnv),
|
||||
}
|
||||
|
||||
if os.Getenv(DebugEnv) == "true" {
|
||||
c.Debug = true
|
||||
}
|
||||
|
||||
if err := validateRequired(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateTSReady(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
+4
-32
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -31,10 +30,10 @@ import (
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/netstack"
|
||||
"tailscale.com/words"
|
||||
)
|
||||
|
||||
// ControlURL defines the URL to be used for connection to Control.
|
||||
@@ -59,7 +58,7 @@ func NewIPN(jsConfig js.Value) map[string]any {
|
||||
if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString {
|
||||
hostname = jsHostname.String()
|
||||
} else {
|
||||
hostname = generateHostname()
|
||||
hostname = "blah"
|
||||
}
|
||||
|
||||
lpc := getOrCreateLogPolicyConfig(store)
|
||||
@@ -126,7 +125,8 @@ func NewIPN(jsConfig js.Value) map[string]any {
|
||||
sys.NetstackRouter.Set(true)
|
||||
sys.Tun.Get().Start()
|
||||
|
||||
logid := lpc.PublicID
|
||||
logid := logid.PublicID{}
|
||||
|
||||
srv := ipnserver.New(logf, logid, sys.NetMon.Get())
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginDefault)
|
||||
if err != nil {
|
||||
@@ -595,34 +595,6 @@ func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M {
|
||||
return n
|
||||
}
|
||||
|
||||
func filterSlice[T any](a []T, f func(T) bool) []T {
|
||||
n := make([]T, 0, len(a))
|
||||
for _, e := range a {
|
||||
if f(e) {
|
||||
n = append(n, e)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func generateHostname() string {
|
||||
tails := words.Tails()
|
||||
scales := words.Scales()
|
||||
if rand.IntN(2) == 0 {
|
||||
// JavaScript
|
||||
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") })
|
||||
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") })
|
||||
} else {
|
||||
// WebAssembly
|
||||
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") })
|
||||
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") })
|
||||
}
|
||||
|
||||
tail := tails[rand.IntN(len(tails))]
|
||||
scale := scales[rand.IntN(len(scales))]
|
||||
return fmt.Sprintf("%s-%s", tail, scale)
|
||||
}
|
||||
|
||||
const logPolicyStateKey = "log-policy"
|
||||
|
||||
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/ipnserver"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/netstack"
|
||||
)
|
||||
|
||||
// Represents an in-state Tailscale backend that is WASM friendly.
|
||||
// The bare minimum to have userspace Wireguard networking is a dialer,
|
||||
// a server, and a backend.
|
||||
type TsWasmIpn struct {
|
||||
// The options used to initialize the TsWasmNet module.
|
||||
options *TsWasmNetOptions
|
||||
|
||||
// The Tailscale dialer, which is used to establish connections.
|
||||
dialer *tsdial.Dialer
|
||||
|
||||
// The Tailscale server, which handles incoming connections and requests.
|
||||
server *ipnserver.Server
|
||||
|
||||
// The Tailscale backend, which manages the local state and operations.
|
||||
backend *ipnlocal.LocalBackend
|
||||
}
|
||||
|
||||
// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options.
|
||||
// This intentionally does not initialize Logtail, as it is only available in
|
||||
// the Tailscale SaaS and not on self-hosted instances.
|
||||
func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) {
|
||||
logf := log.Printf // TODO: Update
|
||||
|
||||
netns.SetEnabled(false) // netns is a separate process (not WASM friendly)
|
||||
|
||||
// Base system (NewSystem() creates a bus automatically)
|
||||
// We supply an in-memory store
|
||||
sys := tsd.NewSystem()
|
||||
bus := sys.Bus.Get()
|
||||
sys.Set(new(mem.Store))
|
||||
|
||||
dialer := &tsdial.Dialer{Logf: logf}
|
||||
netmon, err := netmon.New(bus, logf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Userspace Wireguard engine
|
||||
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
||||
Dialer: dialer,
|
||||
NetMon: netmon,
|
||||
SetSubsystem: sys.Set,
|
||||
ControlKnobs: sys.ControlKnobs(),
|
||||
HealthTracker: sys.HealthTracker(),
|
||||
Metrics: sys.UserMetricsRegistry(),
|
||||
})
|
||||
|
||||
sys.Set(engine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tun := sys.Tun.Get()
|
||||
msock := sys.MagicSock.Get()
|
||||
dnsman := sys.DNSManager.Get()
|
||||
proxymap := sys.ProxyMapper()
|
||||
wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap)
|
||||
|
||||
sys.Set(wgstack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Configure the local Netstack and Dialer
|
||||
wgstack.ProcessLocalIPs = true
|
||||
wgstack.ProcessSubnets = true
|
||||
sys.NetstackRouter.Set(true)
|
||||
|
||||
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||
return wgstack.DialContextTCP(ctx, dst)
|
||||
}
|
||||
|
||||
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||
return wgstack.DialContextUDP(ctx, dst)
|
||||
}
|
||||
|
||||
// Dummy logid for the Tailscale backend
|
||||
logid := logid.PublicID{}
|
||||
tun.Start()
|
||||
|
||||
server := ipnserver.New(logf, logid, sys.NetMon.Get())
|
||||
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
|
||||
|
||||
backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = wgstack.Start(backend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server.SetLocalBackend(backend)
|
||||
registerNotifyCallback(callbacks, backend)
|
||||
|
||||
return &TsWasmIpn{
|
||||
options: options,
|
||||
dialer: dialer,
|
||||
server: server,
|
||||
backend: backend,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Starts the WASM backend which will connect to the Tailscale tailnet and
|
||||
// register an ephemeral node viewable in the Tailscale admin console.
|
||||
func (t *TsWasmIpn) Start(ctx context.Context) error {
|
||||
// Blank "socket" is a requirement for WASM
|
||||
// This NEEDS to happen before the LocalBackend is started,
|
||||
listener, err := safesocket.Listen("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create safesocket listener: %w", err)
|
||||
}
|
||||
|
||||
// Start the server BEFORE the LocalBackend is started
|
||||
go func() {
|
||||
err := t.server.Run(ctx, listener)
|
||||
if err != nil {
|
||||
// TODO: Handle this dispatch using a chan
|
||||
log.Printf("Failed to run Tailscale server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the LocalBackend
|
||||
err = t.backend.Start(ipn.Options{
|
||||
AuthKey: t.options.PreAuthKey,
|
||||
UpdatePrefs: &ipn.Prefs{
|
||||
ControlURL: t.options.ControlURL,
|
||||
Hostname: t.options.Hostname,
|
||||
WantRunning: true,
|
||||
RunWebClient: false,
|
||||
LoggedOut: false,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start Tailscale backend: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"syscall/js"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// Maps ipn.State values to their string representations for the frontend.
|
||||
var BackendState = map[ipn.State]string{
|
||||
ipn.NoState: "NoState",
|
||||
ipn.Stopped: "Stopped",
|
||||
ipn.Starting: "Starting",
|
||||
ipn.Running: "Running",
|
||||
ipn.InUseOtherUser: "InUseOtherUser",
|
||||
ipn.NeedsMachineAuth: "NeedsMachineAuth",
|
||||
ipn.NeedsLogin: "NeedsLogin",
|
||||
}
|
||||
|
||||
// Represents the callbacks that the TsWasmNet module can invoke to register
|
||||
// data retrieval and notifications on the frontend.
|
||||
type TsWasmNetCallbacks struct {
|
||||
// Changes in the backend state.
|
||||
NotifyState func(ipn.State)
|
||||
|
||||
// Updates to the backend's network map.
|
||||
NotifyNetMap func(*netmap.NetworkMap)
|
||||
|
||||
// If interactive login is required, this passes a login URL.
|
||||
NotifyBrowseToURL func(string)
|
||||
|
||||
// If the process panics, this function is called in go.recover.
|
||||
NotifyPanicRecover func(string)
|
||||
}
|
||||
|
||||
// Parses a JavaScript object containing the necessary callbacks for the
|
||||
// TsWasmNet module to properly interact with the frontend.
|
||||
func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) {
|
||||
if obj.IsUndefined() || obj.IsNull() {
|
||||
return nil, errors.New("callbacks object is undefined or null")
|
||||
}
|
||||
|
||||
state, err := validateCallback("NotifyState", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyState: %w", err)
|
||||
}
|
||||
|
||||
// TODO: This is complicated, as the NetworkMap is a complex type.
|
||||
_, err = validateCallback("NotifyNetMap", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyNetMap: %w", err)
|
||||
}
|
||||
|
||||
browseURL, err := validateCallback("NotifyBrowseToURL", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyBrowseToURL: %w", err)
|
||||
}
|
||||
|
||||
panicRecover, err := validateCallback("NotifyPanicRecover", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyPanicRecover: %w", err)
|
||||
}
|
||||
|
||||
return &TsWasmNetCallbacks{
|
||||
NotifyState: func(ipnState ipn.State) {
|
||||
state.Invoke(BackendState[ipnState])
|
||||
},
|
||||
|
||||
NotifyNetMap: func(nm *netmap.NetworkMap) {
|
||||
// TODO: This is complicated
|
||||
},
|
||||
|
||||
NotifyBrowseToURL: func(url string) {
|
||||
browseURL.Invoke(url)
|
||||
},
|
||||
|
||||
NotifyPanicRecover: func(msg string) {
|
||||
panicRecover.Invoke(msg)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validates the specified key is a JS function and returns it.
|
||||
func validateCallback(key string, obj js.Value) (*js.Value, error) {
|
||||
val := obj.Get(key)
|
||||
if val.IsUndefined() || val.IsNull() {
|
||||
return nil, errors.New("callback is undefined or null")
|
||||
}
|
||||
|
||||
if val.Type() != js.TypeFunction {
|
||||
return nil, errors.New("callback is not a function")
|
||||
}
|
||||
|
||||
return &val, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
// Represents the options needed to initialize the TsWasmNet module.
|
||||
type TsWasmNetOptions struct {
|
||||
// Tailscale control URL, e.g., "https://controlplane.tailscale.com"
|
||||
ControlURL string
|
||||
|
||||
// Pre-authentication key for the Tailnet
|
||||
PreAuthKey string
|
||||
|
||||
// Optional hostname, autogenerated if not provided.
|
||||
Hostname string
|
||||
}
|
||||
|
||||
// Parses the provided JS object to validate and extract the TsWasmNetOptions.
|
||||
func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
|
||||
if obj.IsUndefined() || obj.IsNull() {
|
||||
return nil, errors.New("TsWasmNetOptions cannot be undefined or null")
|
||||
}
|
||||
|
||||
cUrl := safeString("ControlURL", obj)
|
||||
preAuthKey := safeString("PreAuthKey", obj)
|
||||
hostname := safeString("Hostname", obj)
|
||||
|
||||
if cUrl == "" || preAuthKey == "" || hostname == "" {
|
||||
return nil, errors.New("missing required fields in TsWasmNetOptions")
|
||||
}
|
||||
|
||||
return &TsWasmNetOptions{
|
||||
ControlURL: cUrl,
|
||||
PreAuthKey: preAuthKey,
|
||||
Hostname: hostname,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Retrieves a string value from a JS object safely.
|
||||
func safeString(key string, obj js.Value) string {
|
||||
if obj.IsUndefined() || obj.IsNull() {
|
||||
return ""
|
||||
}
|
||||
|
||||
val := obj.Get(key)
|
||||
if val.IsUndefined() || val.IsNull() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return val.String()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
)
|
||||
|
||||
func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) {
|
||||
lb.SetNotifyCallback(func(n ipn.Notify) {
|
||||
// Panics should be treated with care in a JS/wasm environment.
|
||||
// If a panic occurs, notify the user and either automatically reload
|
||||
// or give the option to reload.
|
||||
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec != nil {
|
||||
callbacks.NotifyPanicRecover(fmt.Sprint(rec))
|
||||
}
|
||||
}()
|
||||
|
||||
if n.State != nil {
|
||||
callbacks.NotifyState(*n.State)
|
||||
|
||||
if *n.State == ipn.NeedsLogin {
|
||||
// If the state is NeedsLogin, we need to force an interactive login.
|
||||
go forceInteractiveLogin(lb)
|
||||
}
|
||||
}
|
||||
|
||||
if n.BrowseToURL != nil {
|
||||
callbacks.NotifyBrowseToURL(*n.BrowseToURL)
|
||||
}
|
||||
|
||||
log.Printf("NOTIFY: %+v", n)
|
||||
|
||||
// if nm := n.NetMap; nm != nil {
|
||||
// jsNetMap := jsNetMap{
|
||||
// Self: jsNetMapSelfNode{
|
||||
// jsNetMapNode: jsNetMapNode{
|
||||
// Name: nm.Name,
|
||||
// Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
|
||||
// NodeKey: nm.NodeKey.String(),
|
||||
// MachineKey: nm.MachineKey.String(),
|
||||
// },
|
||||
// MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
|
||||
// },
|
||||
// Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
|
||||
// name := p.Name()
|
||||
// if name == "" {
|
||||
// // In practice this should only happen for Hello.
|
||||
// name = p.Hostinfo().Hostname()
|
||||
// }
|
||||
// addrs := make([]string, p.Addresses().Len())
|
||||
// for i, ap := range p.Addresses().All() {
|
||||
// addrs[i] = ap.Addr().String()
|
||||
// }
|
||||
// return jsNetMapPeerNode{
|
||||
// jsNetMapNode: jsNetMapNode{
|
||||
// Name: name,
|
||||
// Addresses: addrs,
|
||||
// MachineKey: p.Machine().String(),
|
||||
// NodeKey: p.Key().String(),
|
||||
// },
|
||||
// Online: p.Online().Clone(),
|
||||
// TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
|
||||
// }
|
||||
// }),
|
||||
// LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
|
||||
// }
|
||||
// if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
|
||||
// jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
|
||||
// } else {
|
||||
// log.Printf("Could not generate JSON netmap: %v", err)
|
||||
// }
|
||||
// }
|
||||
// if n.BrowseToURL != nil {
|
||||
// jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
|
||||
// }
|
||||
})
|
||||
}
|
||||
|
||||
// To get auth to work, even with a pre-auth key, we need to
|
||||
// force an interactive login on the NeedsLogin state.
|
||||
func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
|
||||
err := lb.StartLoginInteractive(context.Background())
|
||||
if err != nil {
|
||||
fmt.Printf("Error starting interactive login: %v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
// "encoding/json"
|
||||
"os"
|
||||
// "sync"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/tale/headplane/agent/internal/sshutil"
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
// "tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Represents messages from the Headplane master
|
||||
type RecvMessage struct {
|
||||
NodeIDs []string
|
||||
}
|
||||
|
||||
type CborMessage struct {
|
||||
Op string `cbor:"op"`
|
||||
Payload cbor.RawMessage `cbor:"payload"`
|
||||
}
|
||||
|
||||
type SendMessage struct {
|
||||
Type string
|
||||
Data any
|
||||
}
|
||||
|
||||
// Starts listening for messages from stdin
|
||||
func FollowMaster(agent *tsnet.TSAgent) {
|
||||
log := util.GetLogger()
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
log.Info("Listening for messages from Headplane master on stdin")
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
var msg CborMessage
|
||||
decoder := cbor.NewDecoder(bytes.NewReader(line))
|
||||
err := decoder.Decode(&msg)
|
||||
|
||||
if err != nil {
|
||||
log.Error("Unable to decode message from master: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("Received message from master: %s", msg)
|
||||
switch msg.Op {
|
||||
case "ssh_conn":
|
||||
var sshPayload sshutil.SSHConnectPayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH connect payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.StartWebSSH(agent, sshPayload)
|
||||
continue
|
||||
|
||||
case "ssh_term":
|
||||
var sshPayload sshutil.SSHClosePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH close payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.CloseWebSSH(agent, sshPayload)
|
||||
continue
|
||||
|
||||
case "ssh_resize":
|
||||
var sshPayload sshutil.SSHResizePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH resize payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.ResizeWebSSH(agent, sshPayload)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// var msg RecvMessage
|
||||
// err := json.Unmarshal(line, &msg)
|
||||
// if err != nil {
|
||||
// var cborMsg CborMessage
|
||||
// dec := cbor.NewDecoder(bytes.NewReader(line))
|
||||
// err := dec.Decode(&cborMsg)
|
||||
|
||||
// if err == nil {
|
||||
// log.Info("Unmarshalled CBOR message: %s", cborMsg)
|
||||
// var sshPayload SSHConnect
|
||||
// err = cbor.Unmarshal(cborMsg.Payload, &sshPayload)
|
||||
// sshutil.OpenSshPty(agent, sshutil.SshConnectParams{
|
||||
// Hostname: sshPayload.Hostname,
|
||||
// Port: sshPayload.Port,
|
||||
// Username: sshPayload.Username,
|
||||
// Id: sshPayload.SessionId,
|
||||
// })
|
||||
|
||||
// return;
|
||||
// }
|
||||
|
||||
// log.Error("Unable to unmarshal message: %s", err)
|
||||
// log.Debug("Full Error: %v", err)
|
||||
// continue
|
||||
// }
|
||||
|
||||
// log.Debug("Recieved message from master: %v", line)
|
||||
|
||||
// if len(msg.NodeIDs) == 0 {
|
||||
// log.Debug("Message recieved had no node IDs")
|
||||
// log.Debug("Full message: %s", line)
|
||||
// continue
|
||||
// }
|
||||
|
||||
// // Accumulate the results since we invoke via gofunc
|
||||
// results := make(map[string]*tailcfg.HostinfoView)
|
||||
// mu := sync.Mutex{}
|
||||
// wg := sync.WaitGroup{}
|
||||
|
||||
// for _, nodeID := range msg.NodeIDs {
|
||||
// wg.Add(1)
|
||||
// go func(nodeID string) {
|
||||
// defer wg.Done()
|
||||
// result, err := agent.GetStatusForPeer(nodeID)
|
||||
// if err != nil {
|
||||
// log.Error("Unable to get status for node %s: %s", nodeID, err)
|
||||
// return
|
||||
// }
|
||||
|
||||
// if result == nil {
|
||||
// log.Debug("No status for node %s", nodeID)
|
||||
// return
|
||||
// }
|
||||
|
||||
// mu.Lock()
|
||||
// results[nodeID] = result
|
||||
// mu.Unlock()
|
||||
// }(nodeID)
|
||||
// }
|
||||
|
||||
// wg.Wait()
|
||||
|
||||
// // Send the results back to the Headplane master
|
||||
// log.Debug("Sending status back to master: %v", results)
|
||||
// log.Msg(&SendMessage{
|
||||
// Type: "status",
|
||||
// Data: results,
|
||||
// })
|
||||
// }
|
||||
|
||||
// if err := scanner.Err(); err != nil {
|
||||
// log.Fatal("Error reading from stdin: %s", err)
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Checks to make sure all required environment variables are set
|
||||
func validateRequired(config *Config) error {
|
||||
if config.Hostname == "" {
|
||||
return fmt.Errorf("%s is required", HostnameEnv)
|
||||
}
|
||||
|
||||
if config.TSControlURL == "" {
|
||||
return fmt.Errorf("%s is required", TSControlURLEnv)
|
||||
}
|
||||
|
||||
if config.TSAuthKey == "" {
|
||||
return fmt.Errorf("%s is required", TSAuthKeyEnv)
|
||||
}
|
||||
|
||||
if config.WorkDir == "" {
|
||||
return fmt.Errorf("%s is required", WorkDirEnv)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pings the Tailscale control server to make sure it's up and running
|
||||
func validateTSReady(config *Config) error {
|
||||
testURL := config.TSControlURL
|
||||
if strings.HasSuffix(testURL, "/") {
|
||||
testURL = testURL[:len(testURL)-1]
|
||||
}
|
||||
|
||||
testURL = fmt.Sprintf("%s/health", testURL)
|
||||
resp, err := http.Get(testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect to TS control server: %s", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("Failed to connect to TS control server: %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type SSHConnectPayload struct {
|
||||
SessionId string `cbor:"sessionId"`
|
||||
Username string `cbor:"username"`
|
||||
Hostname string `cbor:"hostname"`
|
||||
Port int `cbor:"port"`
|
||||
}
|
||||
|
||||
type SSHClosePayload struct {
|
||||
SessionId string `cbor:"sessionId"`
|
||||
}
|
||||
|
||||
type SSHResizePayload struct {
|
||||
SessionId string `cbor:"sessionId"`
|
||||
Width int `cbor:"width"`
|
||||
Height int `cbor:"height"`
|
||||
}
|
||||
|
||||
func connectToTailscaleSSH(agent *tsnet.TSAgent, params SSHConnectPayload) (*ssh.Client, error) {
|
||||
log := util.GetLogger()
|
||||
addr := strings.Join([]string{params.Hostname, ":", strconv.Itoa(params.Port)}, "")
|
||||
|
||||
log.Debug("Initiating Tailscale SSH connection to %s@%s", params.Username, addr)
|
||||
tailnetConn, err := agent.Dial(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug("Routed connection via tsnet to %s", addr)
|
||||
config := &ssh.ClientConfig{
|
||||
User: params.Username,
|
||||
// This isn't a concern because we are only dialing within the Tailnet
|
||||
// and every device is trusted and *should* be ACL accessible.
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
conn, chans, reqs, err := ssh.NewClientConn(tailnetConn, addr, config)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// At this point we have successfully connected to the node
|
||||
sshClient := ssh.NewClient(conn, chans, reqs)
|
||||
version := string(sshClient.ServerVersion())
|
||||
|
||||
if !strings.Contains(version, "Tailscale") {
|
||||
conn.Close()
|
||||
return nil, errors.New("server is not running Tailscale SSH")
|
||||
}
|
||||
|
||||
log.Info("Connected to %s@%s:%d via Tailscale SSH (%s)", params.Username, params.Hostname, params.Port, version)
|
||||
return sshClient, nil
|
||||
}
|
||||
|
||||
func StartWebSSH(agent *tsnet.TSAgent, params SSHConnectPayload) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if agent == nil {
|
||||
log.Error("tsnet.TSAgent is not initialized correctly")
|
||||
return
|
||||
}
|
||||
|
||||
if params.Hostname == "" || params.Port <= 0 || params.Username == "" || params.SessionId == "" {
|
||||
log.Error("Invalid SSH connection parameters: %v", params)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := connectToTailscaleSSH(agent, params)
|
||||
if err != nil {
|
||||
log.Error("Failed to connect to Tailscale SSH for (%s): %s", params.SessionId, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Everything in the func is related to the SSH session.
|
||||
// Each session runs in its own goroutine, allowing concurrency.
|
||||
go func() {
|
||||
log.Debug("Creating SSH session for session ID: %s", params.SessionId)
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
log.Error("Failed to create new SSH session: %s", err)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
|
||||
// Resize event is possible via the control channel later
|
||||
err = sess.RequestPty("xterm-256color", 24, 80, modes)
|
||||
if err != nil {
|
||||
log.Error("Failed to request PTY for (%s): %s", params.SessionId, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err := registerSessionChans(params.SessionId, sess)
|
||||
if err != nil {
|
||||
log.Error("Failed to register session channels for (%s): %s", params.SessionId, err)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Input buffer handler
|
||||
go func() {
|
||||
for data := range ctx.InputCh {
|
||||
_, err := ctx.Stdin.Write(data)
|
||||
if err != nil {
|
||||
log.Error("Failed to write to SSH stdin: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Spin up a shell and wait for the pty to terminate
|
||||
err = sess.Shell()
|
||||
if err != nil {
|
||||
log.Error("Failed to start shell for (%s): %s", params.SessionId, err)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// This spawns 2 goroutins for stdout and stderr
|
||||
dispatchSSHStdout(params.SessionId, ctx.Stdout, ctx.Stderr)
|
||||
|
||||
log.Info("Opened an SSH PTY for %s", params.SessionId)
|
||||
sess.Wait()
|
||||
sess.Close()
|
||||
client.Close()
|
||||
|
||||
log.Info("SSH session for %s closed", params.SessionId)
|
||||
RemoveSession(params.SessionId)
|
||||
}()
|
||||
}
|
||||
|
||||
func CloseWebSSH(agent *tsnet.TSAgent, params SSHClosePayload) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if agent == nil {
|
||||
log.Error("tsnet.TSAgent is not initialized correctly")
|
||||
return
|
||||
}
|
||||
|
||||
if params.SessionId == "" {
|
||||
log.Error("Invalid SSH close parameters: %v", params)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("Closing SSH session for session ID: %s", params.SessionId)
|
||||
ctx, ok := lookupSession(params.SessionId)
|
||||
if !ok {
|
||||
log.Info("No active SSH session found for session ID: %s", params.SessionId)
|
||||
return
|
||||
}
|
||||
|
||||
RemoveSession(ctx.ID)
|
||||
log.Info("SSH session for %s closed", params.SessionId)
|
||||
}
|
||||
|
||||
func ResizeWebSSH(agent *tsnet.TSAgent, params SSHResizePayload) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if agent == nil {
|
||||
log.Error("tsnet.TSAgent is not initialized correctly")
|
||||
return
|
||||
}
|
||||
|
||||
if params.SessionId == "" || params.Width <= 0 || params.Height <= 0 {
|
||||
log.Error("Invalid SSH resize parameters: %v", params)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("Resizing SSH session for session ID: %s to %dx%d", params.SessionId, params.Width, params.Height)
|
||||
ctx, ok := lookupSession(params.SessionId)
|
||||
if !ok {
|
||||
log.Info("No active SSH session found for session ID: %s", params.SessionId)
|
||||
return
|
||||
}
|
||||
|
||||
err := ctx.Session.WindowChange(params.Height, params.Width)
|
||||
if err != nil {
|
||||
log.Error("Failed to resize SSH session for (%s): %s", params.SessionId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Resized SSH session for %s to %dx%d", params.SessionId, params.Width, params.Height)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// An SSH frame is used to wrap raw binary data to and from an SSH session
|
||||
// in order to allow multiplexing connections over a single file descriptor.
|
||||
//
|
||||
// In practice, this is how we can easily support multiple SSH connections
|
||||
// through the 2 file descriptors created by the parent node process.
|
||||
//
|
||||
// This is the format of an SSH frame:
|
||||
// - Magic: The first 4 bytes are HPLS (0x48504C53) to identify the frame.
|
||||
// - Version Byte: The first byte is the version of the frame format.
|
||||
// - Channel Type: The second byte indicates the type of channel.
|
||||
// - Session ID: The next bytes are the length and actual session ID.
|
||||
// - Payload: The remaining bytes are the payload length and actual data.
|
||||
//
|
||||
// +---------+----------+--------------+-------------+----------+
|
||||
// | Magic | Version | Channel Type | SID Length | SID |
|
||||
// | 4 bytes | 1 byte | 1 byte | 1 byte (S) | S bytes |
|
||||
// +---------+----------+--------------+------------------------+
|
||||
// | Payload Length | Payload |
|
||||
// | 4 bytes (u32, P) | P bytes |
|
||||
// +--------------------+---------------------------------------+
|
||||
|
||||
const (
|
||||
MagicString = "HPLS"
|
||||
VersionByte = 1
|
||||
)
|
||||
|
||||
type ChannelType int
|
||||
|
||||
const (
|
||||
ChannelTypeStdin ChannelType = iota
|
||||
ChannelTypeStdout
|
||||
ChannelTypeStderr
|
||||
)
|
||||
|
||||
type SSHFrame struct {
|
||||
ChannelType ChannelType
|
||||
SessionID string
|
||||
Payload []byte
|
||||
|
||||
Length func() int
|
||||
}
|
||||
|
||||
type HPLSFrame1 struct{}
|
||||
|
||||
func (t HPLSFrame1) Encode(frame SSHFrame) ([]byte, error) {
|
||||
frameChan := frame.ChannelType
|
||||
switch frameChan {
|
||||
case ChannelTypeStdin, ChannelTypeStdout, ChannelTypeStderr:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid channel type: %d", frameChan)
|
||||
}
|
||||
|
||||
if len(frame.SessionID) == 0 {
|
||||
return nil, fmt.Errorf("session ID cannot be empty")
|
||||
}
|
||||
|
||||
if len(frame.Payload) == 0 {
|
||||
return nil, fmt.Errorf("payload cannot be empty")
|
||||
}
|
||||
|
||||
sid := []byte(frame.SessionID)
|
||||
if len(sid) > 255 {
|
||||
return nil, fmt.Errorf("session ID exceeds 255 byte limit")
|
||||
}
|
||||
|
||||
if len(frame.Payload) > 0xFFFFFFFF {
|
||||
return nil, fmt.Errorf("payload exceeds 4GB limit")
|
||||
}
|
||||
|
||||
frameLen := 4 // Magic
|
||||
frameLen += 1 // Version byte
|
||||
frameLen += 1 // Channel type
|
||||
frameLen += 1 + len(sid) // Session ID length + SID
|
||||
frameLen += 4 + len(frame.Payload) // Payload length + Payload
|
||||
|
||||
buf := make([]byte, frameLen)
|
||||
copy(buf[0:4], []byte(MagicString))
|
||||
buf[4] = VersionByte
|
||||
buf[5] = byte(frameChan)
|
||||
buf[6] = byte(len(sid))
|
||||
|
||||
offset := 7 + len(sid)
|
||||
copy(buf[7:offset], sid)
|
||||
|
||||
binary.BigEndian.PutUint32(buf[offset:offset+4], uint32(len(frame.Payload)))
|
||||
copy(buf[offset+4:], frame.Payload)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func (t HPLSFrame1) Decode(buf []byte) (SSHFrame, error) {
|
||||
frame := SSHFrame{}
|
||||
if len(buf) < 5 || string(buf[0:4]) != MagicString || buf[4] != VersionByte {
|
||||
return frame, fmt.Errorf("illegal HPLS1 frame format")
|
||||
}
|
||||
|
||||
frame.ChannelType = ChannelType(buf[5])
|
||||
if frame.ChannelType < ChannelTypeStdin || frame.ChannelType > ChannelTypeStderr {
|
||||
return frame, fmt.Errorf("invalid channel type: %d", frame.ChannelType)
|
||||
}
|
||||
|
||||
sidLen := int(buf[6])
|
||||
if len(buf) < 7+sidLen+4 {
|
||||
return frame, fmt.Errorf("buffer too short for session ID and payload length")
|
||||
}
|
||||
|
||||
frame.SessionID = string(buf[7 : 7+sidLen])
|
||||
payloadLen := int(binary.BigEndian.Uint32(buf[7+sidLen:]))
|
||||
if len(buf) < 7+sidLen+4+payloadLen {
|
||||
return frame, fmt.Errorf("buffer too short for payload")
|
||||
}
|
||||
|
||||
frame.Payload = buf[7+sidLen+4 : 7+sidLen+4+payloadLen]
|
||||
frame.Length = func() int {
|
||||
return 7 + sidLen + 4 + payloadLen
|
||||
}
|
||||
|
||||
return frame, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
|
||||
type FrameBatcher struct {
|
||||
mu sync.Mutex
|
||||
buffer []byte
|
||||
writer io.Writer
|
||||
timer *time.Timer
|
||||
interval time.Duration
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewFrameBatcher(writer io.Writer, interval time.Duration) *FrameBatcher {
|
||||
return &FrameBatcher{
|
||||
writer: writer,
|
||||
interval: interval,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *FrameBatcher) QueueMsg(msg []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.buffer = append(b.buffer, msg...)
|
||||
if b.timer != nil {
|
||||
b.timer.Stop()
|
||||
}
|
||||
|
||||
b.timer = time.AfterFunc(b.interval, b.flush)
|
||||
}
|
||||
|
||||
func (b *FrameBatcher) flush() {
|
||||
log := util.GetLogger()
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if len(b.buffer) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := b.writer.Write(b.buffer)
|
||||
if err != nil {
|
||||
log.Error("Failed to write batched message: %v", err)
|
||||
}
|
||||
|
||||
b.buffer = nil
|
||||
}
|
||||
|
||||
func (b *FrameBatcher) Close() {
|
||||
close(b.done)
|
||||
b.flush()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
|
||||
// The file descriptors attached by the parent node process
|
||||
const (
|
||||
InputFd = 3
|
||||
OutputFd = 4
|
||||
)
|
||||
|
||||
// DispatchSSHStdin listens for SSH stdin frames on the InputFd file descriptor
|
||||
// and writes the payload to the appropriate session's stdin.
|
||||
//
|
||||
// This function runs in a goroutine in main and is responsible for dispatching
|
||||
// to ALL connections, not just its own like dispatchSSHStdout does.
|
||||
func DispatchSSHStdin() {
|
||||
log := util.GetLogger()
|
||||
|
||||
log.Debug("Opening file descriptor: %d for SSH stdin", InputFd)
|
||||
fd := os.NewFile(InputFd, "ssh_stdin")
|
||||
if fd == nil {
|
||||
log.Error("Failed to open file descriptor %d for SSH stdin", InputFd)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Listening for SSH stdin on fd %d", InputFd)
|
||||
go func() {
|
||||
buffer := make([]byte, 8192)
|
||||
hpls1 := HPLSFrame1{}
|
||||
|
||||
for {
|
||||
// This is the only check where we can detect if the descriptor
|
||||
// was closed so we can return and exit the goroutine.
|
||||
bufCount, err := fd.Read(buffer)
|
||||
if err != nil {
|
||||
log.Error("Failed to read from SSH stdin: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have 0 EOF, which means the descriptor was closed.
|
||||
if bufCount == 0 {
|
||||
log.Info("SSH stdin closed, stopping listener")
|
||||
return
|
||||
}
|
||||
|
||||
offset := 0
|
||||
for offset < bufCount {
|
||||
frame, err := hpls1.Decode(buffer[offset:bufCount])
|
||||
if err != nil {
|
||||
// We need to wait for more data to decode the frame
|
||||
break
|
||||
}
|
||||
|
||||
if frame.ChannelType != ChannelTypeStdin {
|
||||
log.Error("Received invalid channel type: %d, expected %d", frame.ChannelType, ChannelTypeStdin)
|
||||
continue
|
||||
}
|
||||
|
||||
offset += frame.Length()
|
||||
log.Debug("Received SSH stdin frame: %s", frame.SessionID)
|
||||
sess, ok := lookupSession(frame.SessionID)
|
||||
if !ok {
|
||||
log.Error("Invalid session ID: %s", frame.SessionID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Write the payload to the session's stdin
|
||||
writeCount, err := sess.Stdin.Write(frame.Payload)
|
||||
if err != nil {
|
||||
log.Error("Failed to write to session stdin: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("Wrote %d bytes to session %s stdin", writeCount, frame.SessionID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func dispatchSSHStdout(id string, stdout io.Reader, stderr io.Reader) {
|
||||
log := util.GetLogger()
|
||||
|
||||
log.Debug("Opening file descriptor: %d for SSH stdout", OutputFd)
|
||||
fd := os.NewFile(OutputFd, "ssh_stdout")
|
||||
if fd == nil {
|
||||
log.Error("Failed to open file descriptor %d for SSH stdout", OutputFd)
|
||||
return
|
||||
}
|
||||
|
||||
batcher := NewFrameBatcher(fd, 10*time.Millisecond) // Roughly 60fps
|
||||
|
||||
go readerStreamRoutine(StreamRoutine{
|
||||
SessionID: id,
|
||||
Reader: stdout,
|
||||
Writer: batcher,
|
||||
ChannelType: ChannelTypeStdout,
|
||||
})
|
||||
|
||||
go readerStreamRoutine(StreamRoutine{
|
||||
SessionID: id,
|
||||
Reader: stderr,
|
||||
Writer: batcher,
|
||||
ChannelType: ChannelTypeStderr,
|
||||
})
|
||||
}
|
||||
|
||||
type StreamRoutine struct {
|
||||
SessionID string
|
||||
Reader io.Reader
|
||||
Writer *FrameBatcher
|
||||
ChannelType ChannelType
|
||||
}
|
||||
|
||||
func readerStreamRoutine(routine StreamRoutine) {
|
||||
hpls1 := HPLSFrame1{}
|
||||
buf := make([]byte, 16384) // 16 KiB buffer
|
||||
for {
|
||||
byteCount, err := routine.Reader.Read(buf)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
util.GetLogger().Error("Failed to read from reader: %v", err)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
frame, err := hpls1.Encode(SSHFrame{
|
||||
ChannelType: routine.ChannelType,
|
||||
SessionID: routine.SessionID,
|
||||
Payload: buf[:byteCount],
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
util.GetLogger().Error("Failed to encode frame: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// if _, err := routine.Writer.Write(frame); err != nil {
|
||||
// util.GetLogger().Error("Failed to write frame to writer: %v", err)
|
||||
// break
|
||||
// }
|
||||
//
|
||||
|
||||
routine.Writer.QueueMsg(frame)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type SessionContext struct {
|
||||
ID string
|
||||
Session *ssh.Session
|
||||
Stdin io.WriteCloser
|
||||
Stdout io.Reader
|
||||
Stderr io.Reader
|
||||
InputCh chan []byte
|
||||
}
|
||||
|
||||
var sessions = make(map[string]*SessionContext)
|
||||
var sessionsLock sync.RWMutex
|
||||
|
||||
func registerSessionChans(id string, session *ssh.Session) (*SessionContext, error) {
|
||||
log := util.GetLogger()
|
||||
|
||||
sessionsLock.Lock()
|
||||
defer sessionsLock.Unlock()
|
||||
|
||||
if _, exists := sessions[id]; exists {
|
||||
return sessions[id], nil
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to create stdin pipe: " + err.Error())
|
||||
}
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
stdin.Close()
|
||||
return nil, errors.New("failed to create stdout pipe: " + err.Error())
|
||||
}
|
||||
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
stdin.Close()
|
||||
return nil, errors.New("failed to create stderr pipe: " + err.Error())
|
||||
}
|
||||
|
||||
ctx := &SessionContext{
|
||||
ID: id,
|
||||
Session: session,
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
// Buffered channel to queue input data
|
||||
InputCh: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
sessions[id] = ctx
|
||||
log.Debug("Registered session %s with stdin, stdout, and stderr pipes", id)
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func lookupSession(id string) (*SessionContext, bool) {
|
||||
sessionsLock.RLock()
|
||||
defer sessionsLock.RUnlock()
|
||||
|
||||
sessionContext, exists := sessions[id]
|
||||
return sessionContext, exists
|
||||
}
|
||||
|
||||
func RemoveSession(id string) {
|
||||
sessionsLock.Lock()
|
||||
defer sessionsLock.Unlock()
|
||||
|
||||
if sessionContext, exists := sessions[id]; exists {
|
||||
sessionContext.Stdin.Close() // Close the stdin pipe
|
||||
sessionContext.Session.Close() // Close the SSH session
|
||||
delete(sessions, id) // Remove from the map
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
|
||||
"go4.org/mem"
|
||||
)
|
||||
|
||||
// Returns the raw hostinfo for a peer based on node ID.
|
||||
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if !strings.HasPrefix(id, "nodekey:") {
|
||||
log.Debug("Node ID with missing prefix: %s", id)
|
||||
return nil, fmt.Errorf("invalid node ID: %s", id)
|
||||
}
|
||||
|
||||
log.Debug("Querying status of peer: %s", id)
|
||||
status, err := s.Lc.Status(context.Background())
|
||||
if err != nil {
|
||||
log.Debug("Failed to get status: %s", err)
|
||||
return nil, fmt.Errorf("failed to get status: %w", err)
|
||||
}
|
||||
|
||||
// We need to convert from 64 char hex to 32 byte raw.
|
||||
bytes, err := hex.DecodeString(id[8:])
|
||||
if err != nil {
|
||||
log.Debug("Failed to decode hex: %s", err)
|
||||
return nil, fmt.Errorf("failed to decode hex: %w", err)
|
||||
}
|
||||
|
||||
raw := mem.B(bytes)
|
||||
if raw.Len() != 32 {
|
||||
log.Debug("Invalid node ID length: %d", raw.Len())
|
||||
return nil, fmt.Errorf("invalid node ID length: %d", raw.Len())
|
||||
}
|
||||
|
||||
nodeKey := key.NodePublicFromRaw32(raw)
|
||||
peer := status.Peer[nodeKey]
|
||||
if peer == nil {
|
||||
// Check if we are on Self.
|
||||
if status.Self.PublicKey == nodeKey {
|
||||
peer = status.Self
|
||||
} else {
|
||||
log.Debug("Peer not found in status: %s", id)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
ip := peer.TailscaleIPs[0].String()
|
||||
whois, err := s.Lc.WhoIs(context.Background(), ip)
|
||||
if err != nil {
|
||||
log.Debug("Failed to get whois: %s", err)
|
||||
return nil, fmt.Errorf("failed to get whois: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("Got whois for peer %s: %v", id, whois)
|
||||
return &whois.Node.Hostinfo, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/config"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
// Wrapper type so we can add methods to the server.
|
||||
type TSAgent struct {
|
||||
*tsnet.Server
|
||||
Lc *tailscale.LocalClient
|
||||
ID string
|
||||
}
|
||||
|
||||
// Creates a new tsnet agent and returns an instance of the server.
|
||||
func NewAgent(cfg *config.Config) *TSAgent {
|
||||
log := util.GetLogger()
|
||||
|
||||
dir, err := filepath.Abs(cfg.WorkDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get absolute path: %s", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Fatal("Cannot create agent work directory: %s", err)
|
||||
}
|
||||
|
||||
server := &tsnet.Server{
|
||||
Dir: dir,
|
||||
Hostname: cfg.Hostname,
|
||||
ControlURL: cfg.TSControlURL,
|
||||
AuthKey: cfg.TSAuthKey,
|
||||
Logf: func(string, ...any) {}, // Disabled by default
|
||||
UserLogf: log.Info,
|
||||
}
|
||||
|
||||
if cfg.Debug {
|
||||
server.Logf = log.Debug
|
||||
}
|
||||
|
||||
return &TSAgent{server, nil, ""}
|
||||
}
|
||||
|
||||
// Starts the tsnet agent and sets the node ID.
|
||||
func (s *TSAgent) Connect() {
|
||||
log := util.GetLogger()
|
||||
|
||||
// Waits until the agent is up and running.
|
||||
status, err := s.Up(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to Tailnet: %s", err)
|
||||
}
|
||||
|
||||
s.Lc, err = s.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize local Tailscale client: %s", err)
|
||||
}
|
||||
|
||||
id, err := status.Self.PublicKey.MarshalText()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to marshal public key: %s", err)
|
||||
}
|
||||
|
||||
log.Info("Connected to Tailnet (PublicKey: %s)", status.Self.PublicKey)
|
||||
s.ID = string(id)
|
||||
}
|
||||
|
||||
// Shuts down the tsnet agent.
|
||||
func (s *TSAgent) Shutdown() {
|
||||
s.Close()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LevelInfo LogLevel = "info"
|
||||
LevelDebug LogLevel = "debug"
|
||||
LevelError LogLevel = "error"
|
||||
LevelFatal LogLevel = "fatal"
|
||||
LevelMsg LogLevel = "msg"
|
||||
)
|
||||
|
||||
type LogMessage struct {
|
||||
Level LogLevel
|
||||
Time string
|
||||
Message any
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
debugEnabled bool
|
||||
encoder *json.Encoder
|
||||
pool *sync.Pool
|
||||
}
|
||||
|
||||
var logger = NewLogger()
|
||||
|
||||
func GetLogger() *Logger {
|
||||
return logger
|
||||
}
|
||||
|
||||
func NewLogger() *Logger {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
return &Logger{
|
||||
encoder: enc,
|
||||
pool: &sync.Pool{
|
||||
New: func() any {
|
||||
return &LogMessage{}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) SetDebug(enabled bool) {
|
||||
if enabled {
|
||||
l.debugEnabled = true
|
||||
l.Info("Enabling Debug logging for headplane-agent")
|
||||
l.Info("Be careful, this will spam a lot of information")
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) log(level LogLevel, format string, v ...any) {
|
||||
msg := fmt.Sprintf(format, v...)
|
||||
timestamp := time.Now().Format(time.RFC3339)
|
||||
|
||||
// Manually construct compact JSON line for performance
|
||||
line := `{"Level":"` + string(level) +
|
||||
`","Time":"` + timestamp +
|
||||
`","Message":"` + escapeString(msg) + `"}` + "\n"
|
||||
|
||||
if level == LevelError || level == LevelFatal {
|
||||
os.Stderr.WriteString(line)
|
||||
}
|
||||
|
||||
// Always write to stdout but also write to stderr for errors
|
||||
os.Stdout.WriteString(line)
|
||||
if level == LevelFatal {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(format string, v ...any) {
|
||||
if l.debugEnabled {
|
||||
l.log(LevelDebug, format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) }
|
||||
func (l *Logger) Error(format string, v ...any) { l.log(LevelError, format, v...) }
|
||||
func (l *Logger) Fatal(format string, v ...any) { l.log(LevelFatal, format, v...) }
|
||||
|
||||
func (l *Logger) Msg(obj any) {
|
||||
entry := l.pool.Get().(*LogMessage)
|
||||
defer l.pool.Put(entry)
|
||||
|
||||
entry.Level = LevelMsg
|
||||
entry.Time = time.Now().Format(time.RFC3339)
|
||||
entry.Message = obj
|
||||
|
||||
// Because the encoder is tied to STDOUT we get a message
|
||||
_ = l.encoder.Encode(entry)
|
||||
|
||||
// Reset the entry for reuse
|
||||
entry.Level = ""
|
||||
entry.Time = ""
|
||||
entry.Message = nil
|
||||
}
|
||||
|
||||
func escapeString(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 16) // pre-grow to reduce reallocs
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch c {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\b':
|
||||
b.WriteString(`\b`)
|
||||
case '\f':
|
||||
b.WriteString(`\f`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
if c < 0x20 {
|
||||
// Control characters like 0x01, 0x07 (bell), etc.
|
||||
fmt.Fprintf(&b, `\u%04x`, c)
|
||||
} else {
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user