Files
firecracker-orchestrator/orchestrator/console_client.go
Honza Novak 9089cbdbe9 feat: add serial console access via PTY + Unix socket proxy
Each spawned clone now runs under a _console-proxy daemon that connects
firecracker's ttyS0 (stdin/stdout) to a PTY and serves it on a Unix
socket at clones/<id>/console.sock for the VM's lifetime.

  sudo ./fc-orch spawn 1
  sudo ./fc-orch console 1   # Ctrl+] to detach

spawnOne delegates VM startup to the proxy process (Setsid, detached)
and waits for console.sock to appear before returning. Kill continues
to work via PID files — proxy and firecracker PIDs are both recorded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:24:09 +00:00

105 lines
2.6 KiB
Go

package orchestrator
import (
"context"
"fmt"
"io"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"sync"
"syscall"
"golang.org/x/term"
)
// ConnectConsole attaches the calling terminal to the serial console of the
// clone with the given ID. The connection is made over the Unix socket at
// {cloneDir}/console.sock served by the console-proxy process.
//
// Escape sequence: Ctrl+] (byte 0x1D) detaches without stopping the VM.
func ConnectConsole(cfg Config, id int) error {
sockPath := filepath.Join(cfg.BaseDir, "clones", strconv.Itoa(id), "console.sock")
if _, err := os.Stat(sockPath); err != nil {
return fmt.Errorf("clone %d has no console socket — is it running?", id)
}
conn, err := net.Dial("unix", sockPath)
if err != nil {
return fmt.Errorf("connect to console: %w", err)
}
defer conn.Close()
if !term.IsTerminal(int(os.Stdin.Fd())) {
return fmt.Errorf("console requires an interactive terminal")
}
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("set raw terminal: %w", err)
}
defer term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck
// Ensure the terminal is restored even on SIGTERM / SIGHUP.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
go func() {
<-sigCh
term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck
os.Exit(0)
}()
fmt.Fprintf(os.Stderr, "Connected to clone %d console. Escape: Ctrl+]\r\n", id)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Add(2)
// stdin → VM (with Ctrl+] escape detection)
go func() {
defer wg.Done()
io.Copy(conn, &escapeReader{r: os.Stdin, cancel: cancel}) //nolint:errcheck
// Half-close so the proxy knows we're done sending.
if uc, ok := conn.(*net.UnixConn); ok {
uc.CloseWrite() //nolint:errcheck
}
}()
// VM → stdout
go func() {
defer wg.Done()
io.Copy(os.Stdout, conn) //nolint:errcheck
cancel() // VM exited or proxy closed the connection
}()
<-ctx.Done()
conn.Close() // unblock both goroutines
wg.Wait()
fmt.Fprintf(os.Stderr, "\r\nDetached from clone %d.\r\n", id)
return nil
}
// escapeReader wraps an io.Reader and intercepts Ctrl+] (0x1D), calling cancel
// and returning io.EOF when the escape byte is seen.
type escapeReader struct {
r io.Reader
cancel context.CancelFunc
}
func (e *escapeReader) Read(p []byte) (int, error) {
n, err := e.r.Read(p)
for i := 0; i < n; i++ {
if p[i] == 0x1D { // Ctrl+]
e.cancel()
// Return only the bytes before the escape so nothing leaks to the VM.
return i, io.EOF
}
}
return n, err
}