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 }