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>
This commit is contained in:
104
orchestrator/console_client.go
Normal file
104
orchestrator/console_client.go
Normal file
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user