mirror of
https://github.com/tale/headplane.git
synced 2026-08-06 20:33:13 +00:00
feat: initial webssh tooling
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/tale/headplane/agent/internal/config"
|
||||
"github.com/tale/headplane/agent/internal/hpagent"
|
||||
"github.com/tale/headplane/agent/internal/sshutil"
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
@@ -31,5 +34,6 @@ func main() {
|
||||
ID: agent.ID,
|
||||
})
|
||||
|
||||
sshutil.StartInputReader(os.NewFile(3, "sshin"))
|
||||
hpagent.FollowMaster(agent)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ package hpagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
// "encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
// "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"
|
||||
// "tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Represents messages from the Headplane master
|
||||
@@ -16,6 +19,25 @@ type RecvMessage struct {
|
||||
NodeIDs []string
|
||||
}
|
||||
|
||||
type CborMessage struct {
|
||||
Op string `cbor:"op"`
|
||||
Payload cbor.RawMessage `cbor:"payload"`
|
||||
}
|
||||
|
||||
type SSHConnect struct {
|
||||
SessionId string `cbor:"sessionId"`
|
||||
Username string `cbor:"username"`
|
||||
Hostname string `cbor:"hostname"`
|
||||
Port int `cbor:"port"`
|
||||
}
|
||||
|
||||
type SSHMessage struct {
|
||||
op string
|
||||
username string
|
||||
hostname string
|
||||
Id string
|
||||
}
|
||||
|
||||
type SendMessage struct {
|
||||
Type string
|
||||
Data any
|
||||
@@ -25,63 +47,111 @@ type SendMessage struct {
|
||||
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()
|
||||
log.Info("Got bytes delimited by newline");
|
||||
|
||||
var msg CborMessage
|
||||
decoder := cbor.NewDecoder(bytes.NewReader(line))
|
||||
err := decoder.Decode(&msg)
|
||||
|
||||
var msg RecvMessage
|
||||
err := json.Unmarshal(line, &msg)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal message: %s", err)
|
||||
log.Debug("Full Error: %v", err)
|
||||
continue
|
||||
log.Error("Unable to decode message from master: %s", 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
|
||||
log.Debug("Received message from master: %s", msg)
|
||||
var sshPayload SSHConnect
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH connect payload: %s", err)
|
||||
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,
|
||||
log.Info("Opening SSH PTY for session %s to %s@%s:%d", sshPayload.SessionId, sshPayload.Username, sshPayload.Hostname, sshPayload.Port)
|
||||
sshutil.OpenSshPty(agent, sshutil.SshConnectParams{
|
||||
Hostname: sshPayload.Hostname,
|
||||
Port: sshPayload.Port,
|
||||
Username: sshPayload.Username,
|
||||
Id: sshPayload.SessionId,
|
||||
})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatal("Error reading from stdin: %s", err)
|
||||
}
|
||||
|
||||
|
||||
// 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,161 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type SshConnectParams struct {
|
||||
Hostname string
|
||||
Port int
|
||||
Username string
|
||||
Id string
|
||||
}
|
||||
|
||||
func dialAndValidateTailscaleSSH(agent *tsnet.TSAgent, params SshConnectParams) (*ssh.Client, error) {
|
||||
log := util.GetLogger()
|
||||
|
||||
addr := strings.Join([]string{params.Hostname, ":", strconv.Itoa(params.Port)}, "")
|
||||
|
||||
log.Debug("Attempting to dial %s via Tailscale SSH", addr)
|
||||
conn, err := agent.Dial(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
log.Error("Failed to connect to Tailscale SSH: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug("Connected to Tailscale SSH at %s", addr)
|
||||
config := &ssh.ClientConfig{
|
||||
User: params.Username,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
if err != nil {
|
||||
log.Error("Failed to create SSH client connection: %s", err)
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := ssh.NewClient(clientConn, chans, reqs)
|
||||
sVer := string(client.ServerVersion())
|
||||
|
||||
if !strings.Contains(sVer, "Tailscale") {
|
||||
log.Error("Connected to non-Tailscale SSH server: %s", sVer)
|
||||
conn.Close()
|
||||
return nil, errors.New("not a Tailscale SSH server")
|
||||
}
|
||||
|
||||
log.Info("Connected to SSH server running %s at %s", client.ServerVersion(), addr)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func bindStdinToFd(sess *ssh.Session, fd int) error {
|
||||
log := util.GetLogger()
|
||||
|
||||
sshIn := os.NewFile(uintptr(fd), "sshInput")
|
||||
if sshIn == nil {
|
||||
log.Error("Failed to create file from stdin fd %d", fd)
|
||||
return errors.New("failed to create file from stdin fd")
|
||||
}
|
||||
|
||||
stdin, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
log.Error("Failed to get stdin pipe: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
go io.Copy(stdin, sshIn) // From Node → SSH session
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindStdoutToFd(sess *ssh.Session, fd int) error {
|
||||
log := util.GetLogger()
|
||||
|
||||
sshOut := os.NewFile(uintptr(fd), "sshOutput")
|
||||
if sshOut == nil {
|
||||
log.Error("Failed to create file from stdout fd %d", fd)
|
||||
return errors.New("failed to create file from stdout fd")
|
||||
}
|
||||
|
||||
stdout, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Error("Failed to get stdout pipe: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
go io.Copy(sshOut, stdout) // From SSH → Node
|
||||
return nil
|
||||
}
|
||||
|
||||
func OpenSshPty(agent *tsnet.TSAgent, params SshConnectParams) (*ssh.Client, error) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if agent == nil {
|
||||
log.Error("Tailscale agent is nil")
|
||||
return nil, errors.New("tailscale agent is nil")
|
||||
}
|
||||
|
||||
if params.Hostname == "" || params.Port <= 0 || params.Username == "" {
|
||||
log.Error("Invalid SSH connection parameters: %+v", params)
|
||||
return nil, errors.New("invalid SSH connection parameters")
|
||||
}
|
||||
|
||||
client, err := dialAndValidateTailscaleSSH(agent, params)
|
||||
if err != nil {
|
||||
log.Error("Failed to open SSH pty: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
log.Error("Failed to create new SSH session: %s", err)
|
||||
client.Close()
|
||||
}
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1, // enable echoing
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
|
||||
if err := sess.RequestPty("xterm-256color", 80, 40, modes); err != nil {
|
||||
log.Error("Failed to request PTY: %s", err)
|
||||
client.Close()
|
||||
}
|
||||
|
||||
ctx := addSession(params.Id, sess)
|
||||
go func() {
|
||||
for data := range ctx.InputCh {
|
||||
if _, err := ctx.Stdin.Write(data); err != nil {
|
||||
log.Error("Failed to write to SSH stdin: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}();
|
||||
|
||||
if err := sess.Shell(); err != nil {
|
||||
log.Error("Failed to start shell: %s", err)
|
||||
client.Close()
|
||||
}
|
||||
|
||||
|
||||
log.Info("Successfully opened SSH pty for %s@%s:%d", params.Username, params.Hostname, params.Port)
|
||||
go streamSSHOutput(params.Id, ctx.Stdout, os.NewFile(4, "sshOutput"))
|
||||
sess.Wait();
|
||||
sess.Close();
|
||||
log.Info("SSH session %s closed (goSide)", params.Id)
|
||||
RemoveSession(params.Id)
|
||||
}()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func encodeFrame(id string, data []byte) ([]byte, error) {
|
||||
sid := []byte(id)
|
||||
if len(sid) > 255 {
|
||||
return nil, fmt.Errorf("session ID too long")
|
||||
}
|
||||
|
||||
payloadLen := len(data)
|
||||
buf := make([]byte, 1+len(sid)+4+payloadLen)
|
||||
|
||||
buf[0] = byte(len(sid)) // SID length
|
||||
copy(buf[1:], sid) // SID
|
||||
binary.BigEndian.PutUint32(buf[1+len(sid):], uint32(payloadLen)) // Payload length
|
||||
copy(buf[1+len(sid)+4:], data) // Payload
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func decodeFrame(buf []byte) (id string, payload []byte, ok bool) {
|
||||
if len(buf) < 5 {
|
||||
return "", nil, false
|
||||
}
|
||||
sidLen := int(buf[0])
|
||||
if len(buf) < 1+sidLen+4 {
|
||||
return "", nil, false
|
||||
}
|
||||
id = string(buf[1 : 1+sidLen])
|
||||
payloadLen := int(binary.BigEndian.Uint32(buf[1+sidLen:]))
|
||||
|
||||
if len(buf) < 1+sidLen+4+payloadLen {
|
||||
return "", nil, false
|
||||
}
|
||||
payload = buf[1+sidLen+4 : 1+sidLen+4+payloadLen]
|
||||
return id, payload, true
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
|
||||
func StartInputReader(fd3 io.Reader) {
|
||||
log := util.GetLogger();
|
||||
|
||||
log.Info("Starting SSH fd3 input reader")
|
||||
go func() {
|
||||
buffer := make([]byte, 8192)
|
||||
for {
|
||||
n, err := fd3.Read(buffer)
|
||||
if err != nil {
|
||||
log.Error("fd3 read error: %v", err)
|
||||
return
|
||||
}
|
||||
offset := 0
|
||||
for offset < n {
|
||||
id, payload, ok := decodeFrame(buffer[offset:n])
|
||||
if !ok {
|
||||
break // Wait for more data
|
||||
}
|
||||
offset += 1 + len(id) + 4 + len(payload)
|
||||
|
||||
sessionsMu.RLock()
|
||||
sess, ok := sessions[id]
|
||||
sessionsMu.RUnlock()
|
||||
if !ok {
|
||||
log.Error("invalid session id: %s", id)
|
||||
continue
|
||||
}
|
||||
_, err := sess.Stdin.Write(payload)
|
||||
if err != nil {
|
||||
log.Error("failed to write to session stdin: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func streamSSHOutput(id string, r io.Reader, fd4 io.Writer) {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
frame, _ := encodeFrame(id, buf[:n])
|
||||
fd4.Write(frame)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type SessionContext struct {
|
||||
ID string
|
||||
Session *ssh.Session
|
||||
Stdin io.WriteCloser
|
||||
Stdout io.Reader
|
||||
InputCh chan []byte
|
||||
}
|
||||
|
||||
var sessions = make(map[string]*SessionContext)
|
||||
var sessionsMu sync.RWMutex
|
||||
|
||||
func addSession(id string, session *ssh.Session) *SessionContext {
|
||||
sessionsMu.Lock()
|
||||
defer sessionsMu.Unlock()
|
||||
|
||||
if _, exists := sessions[id]; exists {
|
||||
return nil // Session with this ID already exists
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return nil // Handle error appropriately in production code
|
||||
}
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
stdin.Close() // Close stdin if stdout pipe creation fails
|
||||
return nil // Handle error appropriately in production code
|
||||
}
|
||||
|
||||
sessionContext := &SessionContext{
|
||||
ID: id,
|
||||
Session: session,
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
}
|
||||
|
||||
sessions[id] = sessionContext
|
||||
return sessionContext
|
||||
}
|
||||
|
||||
func GetSession(id string) *SessionContext {
|
||||
sessionsMu.RLock()
|
||||
defer sessionsMu.RUnlock()
|
||||
|
||||
return sessions[id] // Returns nil if session does not exist
|
||||
}
|
||||
|
||||
func RemoveSession(id string) {
|
||||
sessionsMu.Lock()
|
||||
defer sessionsMu.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
|
||||
}
|
||||
}
|
||||
@@ -107,11 +107,34 @@ func (l *Logger) Msg(obj any) {
|
||||
}
|
||||
|
||||
func escapeString(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`"`, `\"`,
|
||||
`\`, `\\`,
|
||||
"\n", `\n`,
|
||||
"\t", `\t`,
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
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