package sshtransport import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/pem" "errors" "fmt" "io" "log/slog" "maps" "net" "os" "os/exec" "path/filepath" "slices" "strings" "sync" "time" "golang.org/x/crypto/ssh" "github.com/johnandersen777/socialweb-computer-kcp/internal/irohdial" "github.com/johnandersen777/socialweb-computer-kcp/internal/kit/childproc" "github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt" ) const ( TerminatedSessionExitCode = 255 GuestPollInterval = 5 * time.Second DefaultGuestConnectTimeout = 30 * time.Second DefaultGuestPollConnectTimeout = 10 * time.Second ) type NonIrohGuestAddressError struct { Target string } func (e *NonIrohGuestAddressError) Error() string { return "guest address is not an iroh ticket (iroh://...): " + e.Target } type MissingGuestHostKeysError struct { Target string } func (e *MissingGuestHostKeysError) Error() string { return "no reported ssh host key for " + e.Target + "; refusing to connect without one" } type GuestHostKeyMismatchError struct { Alias string } func (e *GuestHostKeyMismatchError) Error() string { return "the guest's ssh host key is not the one it reported (" + e.Alias + "); refusing to authenticate" } type GuestHostKeys struct { Alias string Keys []string } type Dialer interface { Dial(ctx context.Context, target string) (net.Conn, error) } type UnixSocketDialer struct { Socket string Timeout time.Duration Log *slog.Logger } func (d UnixSocketDialer) log() *slog.Logger { if d.Log != nil { return d.Log } return slog.Default() } func (d UnixSocketDialer) Dial(ctx context.Context, target string) (net.Conn, error) { if err := irohdial.CheckTicket(target); err != nil { return nil, &NonIrohGuestAddressError{Target: target} } timeout := d.Timeout timeout = opt.Positive(timeout, DefaultGuestConnectTimeout) dialer := net.Dialer{Timeout: timeout} conn, err := dialer.DialContext(ctx, "unix", d.Socket) if err != nil { return nil, err } if _, err := conn.Write([]byte(target + "\n")); err != nil { conn.Close() return nil, err } d.log().Info("guest_iroh_dial_started", "socket", d.Socket) return conn, nil } type DumbpipeDialer struct { Path string Log *slog.Logger } func (d DumbpipeDialer) log() *slog.Logger { if d.Log != nil { return d.Log } return slog.Default() } func (d DumbpipeDialer) Dial(ctx context.Context, target string) (net.Conn, error) { if err := irohdial.CheckTicket(target); err != nil { return nil, &NonIrohGuestAddressError{Target: target} } cmd := exec.CommandContext(ctx, d.Path, "connect", target) stdin, err := cmd.StdinPipe() if err != nil { return nil, err } stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } tail := &tailWriter{limit: 2048} cmd.Stderr = childproc.FilterSecrets(tail) if err := cmd.Start(); err != nil { return nil, err } d.log().Info("guest_iroh_dial_started", "dumbpipe", d.Path, "pid", cmd.Process.Pid) stream := &dumbpipeConn{stdin: stdin, stdout: stdout, cmd: cmd, tail: tail, log: d.log()} return &streamConn{ReadWriteCloser: stream, local: dumbpipeAddr{target: "dumbpipe"}, remote: dumbpipeAddr{target: target}}, nil } type dumbpipeConn struct { stdin io.WriteCloser stdout io.ReadCloser cmd *exec.Cmd tail *tailWriter log *slog.Logger once sync.Once } func (c *dumbpipeConn) Read(p []byte) (int, error) { return c.stdout.Read(p) } func (c *dumbpipeConn) Write(p []byte) (int, error) { return c.stdin.Write(p) } func (c *dumbpipeConn) Close() error { c.once.Do(func() { c.stdin.Close() if c.cmd.Process != nil { _ = c.cmd.Process.Kill() } err := c.cmd.Wait() c.log.Info("guest_iroh_dial_ended", "exit", fmt.Sprint(err), "stderr", strings.TrimSpace(c.tail.String())) }) return nil } type streamConn struct { io.ReadWriteCloser local net.Addr remote net.Addr } func (c *streamConn) LocalAddr() net.Addr { return c.local } func (c *streamConn) RemoteAddr() net.Addr { return c.remote } func (c *streamConn) SetDeadline(t time.Time) error { if conn, ok := c.ReadWriteCloser.(net.Conn); ok { return conn.SetDeadline(t) } return nil } func (c *streamConn) SetReadDeadline(t time.Time) error { if conn, ok := c.ReadWriteCloser.(net.Conn); ok { return conn.SetReadDeadline(t) } return nil } func (c *streamConn) SetWriteDeadline(t time.Time) error { if conn, ok := c.ReadWriteCloser.(net.Conn); ok { return conn.SetWriteDeadline(t) } return nil } type dumbpipeAddr struct { target string } func (a dumbpipeAddr) Network() string { return "dumbpipe" } func (a dumbpipeAddr) String() string { return a.target } type tailWriter struct { mu sync.Mutex buf []byte limit int } func (t *tailWriter) Write(p []byte) (int, error) { t.mu.Lock() defer t.mu.Unlock() t.buf = append(t.buf, p...) if t.limit > 0 && len(t.buf) > t.limit { t.buf = t.buf[len(t.buf)-t.limit:] } return len(p), nil } func (t *tailWriter) String() string { t.mu.Lock() defer t.mu.Unlock() return string(t.buf) } type SessionProvider interface { GenerateKeypair(dir, vmName string) (string, string, error) PollReady(ctx context.Context, privateKeyPath, target string, timeout time.Duration, hostKeys GuestHostKeys) bool RunSession(ctx context.Context, privateKeyPath, target, program string, commandIO CommandIO, hostKeys GuestHostKeys) int } type GuestSessionProvider struct { Dialer Dialer Log *slog.Logger ConnectTimeout time.Duration } func (p *GuestSessionProvider) GenerateKeypair(dir, vmName string) (string, string, error) { return GenerateGuestKeypair(dir, vmName) } func NewGuestSessionProvider(dialer Dialer, log *slog.Logger) (*GuestSessionProvider, error) { if dialer == nil { return nil, errors.New("sshtransport: guest session provider has no dialer") } if log == nil { log = slog.Default() } return &GuestSessionProvider{Dialer: dialer, Log: log}, nil } func (p *GuestSessionProvider) log() *slog.Logger { if p.Log != nil { return p.Log } return slog.Default() } func (p *GuestSessionProvider) timeout() time.Duration { if p.ConnectTimeout > 0 { return p.ConnectTimeout } return DefaultGuestConnectTimeout } func GenerateGuestKeypair(dir, vmName string) (string, string, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return "", "", err } pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return "", "", err } block, err := ssh.MarshalPrivateKey(priv, "root@"+vmName) if err != nil { return "", "", err } tempDir, err := os.MkdirTemp(dir, "ssh-"+keyDirName(vmName)+"-") if err != nil { return "", "", err } path := filepath.Join(tempDir, "id_ed25519") if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil { os.RemoveAll(tempDir) return "", "", err } sshPublic, err := ssh.NewPublicKey(pub) if err != nil { os.RemoveAll(tempDir) return "", "", err } publicKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublic))) + " root@" + vmName return publicKey, path, nil } func RemoveGuestKeypair(privateKeyPath string) error { if privateKeyPath == "" { return nil } return os.RemoveAll(filepath.Dir(privateKeyPath)) } func keyDirName(vmName string) string { var b strings.Builder for _, r := range vmName { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': b.WriteRune(r) default: b.WriteRune('_') } if b.Len() >= 40 { break } } if b.Len() == 0 { return "guest" } return b.String() } func reportedKeySet(hostKeys GuestHostKeys) map[string]bool { accepted := make(map[string]bool, len(hostKeys.Keys)) for _, key := range hostKeys.Keys { if parts := strings.Fields(key); len(parts) >= 2 { accepted[parts[1]] = true continue } accepted[key] = true } return accepted } func reportedKeyAlgorithms(hostKeys GuestHostKeys) []string { seen := map[string]bool{} out := make([]string, 0, len(hostKeys.Keys)) add := func(algo string) { if algo == "" || seen[algo] { return } seen[algo] = true out = append(out, algo) } for _, line := range hostKeys.Keys { fields := strings.Fields(line) if len(fields) == 0 { continue } switch fields[0] { case ssh.KeyAlgoRSA: add(ssh.KeyAlgoRSASHA256) add(ssh.KeyAlgoRSASHA512) add(ssh.KeyAlgoRSA) default: add(fields[0]) } } return out } func (p *GuestSessionProvider) connect(ctx context.Context, target, privateKeyPath string, hostKeys GuestHostKeys) (*ssh.Client, net.Conn, error) { if err := irohdial.CheckTicket(target); err != nil { return nil, nil, &NonIrohGuestAddressError{Target: target} } if len(hostKeys.Keys) == 0 { return nil, nil, &MissingGuestHostKeysError{Target: target} } privateKey, err := os.ReadFile(privateKeyPath) if err != nil { return nil, nil, err } signer, err := ssh.ParsePrivateKey(privateKey) if err != nil { return nil, nil, err } conn, err := p.Dialer.Dial(ctx, target) if err != nil { return nil, nil, err } accepted := reportedKeySet(hostKeys) mismatch := false config := &ssh.ClientConfig{ User: "root", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, Timeout: p.timeout(), HostKeyAlgorithms: reportedKeyAlgorithms(hostKeys), HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { got := base64.StdEncoding.EncodeToString(key.Marshal()) if accepted[got] { return nil } mismatch = true p.log().Warn("guest_host_key_mismatch", "target", target, "alias", hostKeys.Alias) return fmt.Errorf("sshtransport: guest host key does not match %s", hostKeys.Alias) }, } _ = conn.SetDeadline(time.Now().Add(p.timeout())) clientConn, channels, requests, err := ssh.NewClientConn(conn, target, config) if err != nil { conn.Close() if mismatch { return nil, nil, &GuestHostKeyMismatchError{Alias: hostKeys.Alias} } return nil, nil, err } _ = conn.SetDeadline(time.Time{}) return ssh.NewClient(clientConn, channels, requests), conn, nil } func (p *GuestSessionProvider) PollReady(ctx context.Context, privateKeyPath, target string, timeout time.Duration, hostKeys GuestHostKeys) bool { if err := irohdial.CheckTicket(target); err != nil { p.log().Warn("guest_ssh_refused", "target", target, "err", err.Error()) return false } if len(hostKeys.Keys) == 0 { p.log().Warn("guest_ssh_refused", "target", target, "err", "no reported ssh host key") return false } if _, err := os.Stat(privateKeyPath); err != nil { return false } deadline := time.Now().Add(timeout) attempt := 0 for time.Now().Before(deadline) && ctx.Err() == nil { attempt++ ready := p.pollOnce(ctx, privateKeyPath, target, hostKeys) if ready { p.log().Info("guest_ssh_ready", "target", target, "attempt", attempt) return true } select { case <-ctx.Done(): return false case <-time.After(GuestPollInterval): } } if ctx.Err() != nil { p.log().Info("guest_ssh_poll_terminated", "target", target, "attempt", attempt) return false } p.log().Warn("guest_ssh_timeout", "target", target, "timeoutMs", timeout.Milliseconds()) return false } func (p *GuestSessionProvider) pollOnce(ctx context.Context, privateKeyPath, target string, hostKeys GuestHostKeys) bool { cctx, cancel := context.WithTimeout(ctx, DefaultGuestPollConnectTimeout) defer cancel() client, conn, err := p.connect(cctx, target, privateKeyPath, hostKeys) if err != nil { var mismatch *GuestHostKeyMismatchError if errors.As(err, &mismatch) { return false } p.log().Info("guest_ssh_poll", "target", target, "err", err) return false } defer client.Close() defer conn.Close() session, err := client.NewSession() if err != nil { return false } defer session.Close() if err := session.Run("true"); err != nil { return false } return true } func (p *GuestSessionProvider) RunSession(ctx context.Context, privateKeyPath, target, program string, commandIO CommandIO, hostKeys GuestHostKeys) int { return p.RunSessionEnv(ctx, privateKeyPath, target, program, nil, commandIO, hostKeys) } func (p *GuestSessionProvider) RunSessionEnv(ctx context.Context, privateKeyPath, target, program string, env map[string]string, commandIO CommandIO, hostKeys GuestHostKeys) int { if err := irohdial.CheckTicket(target); err != nil { p.log().Warn("guest_session_refused", "target", target, "err", err.Error()) _ = commandIO.WriteErr([]byte("guest session failed: " + err.Error() + "\n")) return 1 } if len(hostKeys.Keys) == 0 { err := &MissingGuestHostKeysError{Target: target} p.log().Warn("guest_session_refused", "target", target, "err", err.Error()) _ = commandIO.WriteErr([]byte("guest session failed: " + err.Error() + "\n")) return 1 } if ctx.Err() != nil { p.log().Info("guest_session_terminated", "target", target, "phase", "before_connect") return TerminatedSessionExitCode } client, conn, err := p.connect(ctx, target, privateKeyPath, hostKeys) if err != nil { if ctx.Err() != nil { p.log().Info("guest_session_terminated", "target", target, "phase", "connect") return TerminatedSessionExitCode } p.log().Warn("guest_session_failed", "target", target, "err", err) _ = commandIO.WriteErr([]byte("guest session failed: " + err.Error() + "\n")) return 1 } defer client.Close() defer conn.Close() session, err := client.NewSession() if err != nil { p.log().Warn("guest_session_failed", "target", target, "err", err) _ = commandIO.WriteErr([]byte("guest session failed: " + err.Error() + "\n")) return 1 } defer session.Close() for _, name := range slices.Sorted(maps.Keys(env)) { if err := session.Setenv(name, env[name]); err != nil { p.log().Warn("guest_session_env_refused", "target", target, "name", name, "err", err) _ = commandIO.WriteErr([]byte("guest session: the guest refused the environment variable " + name + "\n")) } } if pty := commandIO.PTY(); pty != nil { modes := ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, } if err := session.RequestPty("xterm", pty.Rows, pty.Cols, modes); err != nil { p.log().Warn("guest_pty_failed", "target", target, "err", err) _ = commandIO.WriteErr([]byte("guest session failed: " + err.Error() + "\n")) return 1 } } stdin, err := session.StdinPipe() if err != nil { return 1 } stdout, err := session.StdoutPipe() if err != nil { return 1 } stderr, err := session.StderrPipe() if err != nil { return 1 } if err := session.Start(program); err != nil { p.log().Warn("guest_exec_failed", "target", target, "err", err) _ = commandIO.WriteErr([]byte("guest session failed: " + err.Error() + "\n")) return 1 } p.log().Info("guest_session_started", "target", target) commandIO.OnData(func(chunk []byte) { if _, err := stdin.Write(chunk); err != nil { _ = stdin.Close() } }) commandIO.OnClose(func() { _ = stdin.Close() }) var copying sync.WaitGroup copying.Go(func() { _, _ = io.Copy(writeFunc(commandIO.Write), stdout) }) copying.Go(func() { _, _ = io.Copy(writeFunc(commandIO.WriteErr), stderr) }) done := make(chan struct{}) var watching sync.WaitGroup watching.Go(func() { select { case <-ctx.Done(): client.Close() case <-done: } }) err = session.Wait() close(done) watching.Wait() copying.Wait() if ctx.Err() != nil { p.log().Info("guest_session_terminated", "target", target, "phase", "session") return TerminatedSessionExitCode } if err == nil { p.log().Info("guest_session_exit", "target", target, "code", 0) return 0 } var exitErr *ssh.ExitError if errors.As(err, &exitErr) { p.log().Info("guest_session_exit", "target", target, "code", exitErr.ExitStatus()) return exitErr.ExitStatus() } p.log().Warn("guest_session_failed", "target", target, "err", err) _ = commandIO.WriteErr([]byte("guest session failed: " + err.Error() + "\n")) return 1 } type writeFunc func([]byte) error func (f writeFunc) Write(p []byte) (int, error) { if err := f(p); err != nil { return 0, err } return len(p), nil }