Skip to content

Commit 9834302

Browse files
committed
console
1 parent 21a05e5 commit 9834302

12 files changed

Lines changed: 322 additions & 8 deletions

File tree

console.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/signal"
10+
"syscall"
11+
12+
"golang.org/x/term"
13+
14+
"github.com/projecteru2/cocoon/hypervisor"
15+
)
16+
17+
const escapeChar = 0x1D // ctrl+]
18+
19+
// escapeState tracks the two-state escape detection machine.
20+
type escapeState int
21+
22+
const (
23+
stateNormal escapeState = iota
24+
stateEscaped // ctrl+] received, waiting for command char
25+
)
26+
27+
func cmdConsole(ctx context.Context, hyper hypervisor.Hypervisor, args []string) {
28+
if len(args) == 0 {
29+
fatalf("usage: cocoon console <vm-ref>")
30+
}
31+
ref := args[0]
32+
33+
ptyPath, err := hyper.Console(ctx, ref)
34+
if err != nil {
35+
fatalf("console: %v", err)
36+
}
37+
38+
pty, err := os.OpenFile(ptyPath, os.O_RDWR, 0) //nolint:gosec
39+
if err != nil {
40+
fatalf("open PTY %s: %v", ptyPath, err)
41+
}
42+
defer pty.Close() //nolint:errcheck
43+
44+
fd := int(os.Stdin.Fd())
45+
if !term.IsTerminal(fd) {
46+
fatalf("stdin is not a terminal")
47+
}
48+
49+
oldState, err := term.MakeRaw(fd)
50+
if err != nil {
51+
fatalf("set raw mode: %v", err)
52+
}
53+
defer func() {
54+
_ = term.Restore(fd, oldState)
55+
fmt.Fprintf(os.Stderr, "\r\nDisconnected from %s.\r\n", ref)
56+
}()
57+
58+
// Absorb SIGINT/SIGTERM to prevent bypassing terminal restore.
59+
sigCh := make(chan os.Signal, 1)
60+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
61+
defer signal.Stop(sigCh)
62+
go func() {
63+
for range sigCh {
64+
}
65+
}()
66+
67+
cleanupWinch := handleSIGWINCH(os.Stdin, pty)
68+
defer cleanupWinch()
69+
70+
fmt.Fprintf(os.Stderr, "Connected to %s (escape: ^]).\r\n", ref)
71+
72+
if err := relayConsole(ctx, pty); err != nil {
73+
fmt.Fprintf(os.Stderr, "\r\nrelay error: %v\r\n", err)
74+
}
75+
}
76+
77+
// relayConsole runs bidirectional I/O between the user terminal and the PTY.
78+
func relayConsole(ctx context.Context, pty *os.File) error {
79+
ctx, cancel := context.WithCancel(ctx)
80+
defer cancel()
81+
82+
errCh := make(chan error, 2) //nolint:mnd
83+
84+
// PTY → stdout
85+
go func() {
86+
_, err := io.Copy(os.Stdout, pty)
87+
errCh <- err
88+
cancel()
89+
}()
90+
91+
// stdin → PTY (with escape detection)
92+
go func() {
93+
err := relayStdinToPTY(ctx, os.Stdin, pty)
94+
errCh <- err
95+
cancel()
96+
}()
97+
98+
select {
99+
case <-ctx.Done():
100+
select {
101+
case err := <-errCh:
102+
if err != nil && !isCleanExit(err) {
103+
return err
104+
}
105+
default:
106+
}
107+
return nil
108+
case err := <-errCh:
109+
if err == nil || isCleanExit(err) {
110+
select {
111+
case err2 := <-errCh:
112+
if err2 != nil && !isCleanExit(err2) {
113+
return err2
114+
}
115+
default:
116+
}
117+
return nil
118+
}
119+
return err
120+
}
121+
}
122+
123+
// relayStdinToPTY reads from stdin and writes to the PTY with escape detection.
124+
func relayStdinToPTY(ctx context.Context, stdin io.Reader, pty io.Writer) error {
125+
state := stateNormal
126+
buf := make([]byte, 1)
127+
128+
for {
129+
select {
130+
case <-ctx.Done():
131+
return nil
132+
default:
133+
}
134+
135+
n, err := stdin.Read(buf)
136+
if n == 0 || err != nil {
137+
return err
138+
}
139+
b := buf[0]
140+
141+
switch state {
142+
case stateNormal:
143+
if b == escapeChar {
144+
state = stateEscaped
145+
continue
146+
}
147+
if _, werr := pty.Write(buf[:1]); werr != nil {
148+
return werr
149+
}
150+
151+
case stateEscaped:
152+
state = stateNormal
153+
switch b {
154+
case '.':
155+
return nil // disconnect
156+
case '?':
157+
helpMsg := "\r\nSupported escape sequences:\r\n" +
158+
" ^]. Disconnect\r\n" +
159+
" ^]? This help\r\n" +
160+
" ^]^] Send ^]\r\n"
161+
_, _ = os.Stdout.Write([]byte(helpMsg))
162+
case escapeChar:
163+
if _, werr := pty.Write([]byte{escapeChar}); werr != nil {
164+
return werr
165+
}
166+
default:
167+
// Unrecognized: forward both bytes.
168+
if _, werr := pty.Write([]byte{escapeChar, b}); werr != nil {
169+
return werr
170+
}
171+
}
172+
}
173+
}
174+
}
175+
176+
// isCleanExit returns true for errors that indicate a normal PTY disconnect.
177+
func isCleanExit(err error) bool {
178+
return errors.Is(err, io.EOF) || errors.Is(err, syscall.EIO)
179+
}

console_darwin.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package main
2+
3+
import "os"
4+
5+
// handleSIGWINCH is a no-op on darwin. Console requires Linux.
6+
func handleSIGWINCH(_ *os.File, _ *os.File) func() {
7+
return func() {}
8+
}

console_linux.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"os/signal"
6+
"syscall"
7+
"unsafe"
8+
9+
"golang.org/x/term"
10+
)
11+
12+
// handleSIGWINCH propagates terminal size on connect and on each SIGWINCH.
13+
// Returns a cleanup function that stops the signal listener.
14+
func handleSIGWINCH(local *os.File, remote *os.File) func() {
15+
propagateTerminalSize(local, remote)
16+
17+
sigCh := make(chan os.Signal, 1)
18+
signal.Notify(sigCh, syscall.SIGWINCH)
19+
go func() {
20+
for range sigCh {
21+
propagateTerminalSize(local, remote)
22+
}
23+
}()
24+
25+
return func() { signal.Stop(sigCh) }
26+
}
27+
28+
func propagateTerminalSize(local *os.File, remote *os.File) {
29+
width, height, err := term.GetSize(int(local.Fd()))
30+
if err != nil {
31+
return
32+
}
33+
_ = setWinSize(remote, width, height)
34+
}
35+
36+
// winSize matches the kernel's struct winsize for the TIOCSWINSZ ioctl.
37+
type winSize struct {
38+
Rows uint16
39+
Cols uint16
40+
X uint16
41+
Y uint16
42+
}
43+
44+
func setWinSize(f *os.File, cols, rows int) error {
45+
ws := winSize{Rows: uint16(rows), Cols: uint16(cols)} //nolint:gosec
46+
_, _, errno := syscall.Syscall(
47+
syscall.SYS_IOCTL,
48+
f.Fd(),
49+
syscall.TIOCSWINSZ,
50+
uintptr(unsafe.Pointer(&ws)), //nolint:gosec
51+
)
52+
if errno != 0 {
53+
return errno
54+
}
55+
return nil
56+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/google/uuid v1.6.0
1010
github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c
1111
golang.org/x/sync v0.19.0
12+
golang.org/x/term v0.40.0
1213
)
1314

1415
require (

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
115115
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
116116
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
117117
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
118+
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
119+
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
118120
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
119121
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
120122
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=

hypervisor/cloudhypervisor/api.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,13 @@ type chSerial struct {
6565

6666
type chConsole struct {
6767
Mode string `json:"mode"`
68+
File string `json:"file,omitempty"`
69+
}
70+
71+
// chVMInfoResponse is the JSON shape returned by GET /api/v1/vm.info.
72+
// Only the fields we actually use are declared.
73+
type chVMInfoResponse struct {
74+
Config struct {
75+
Console chConsole `json:"console"`
76+
} `json:"config"`
6877
}

hypervisor/cloudhypervisor/cloudhypervisor.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cloudhypervisor
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"path/filepath"
78

@@ -53,6 +54,38 @@ func (ch *CloudHypervisor) Inspect(ctx context.Context, ref string) (*types.VMIn
5354
})
5455
}
5556

57+
// Console returns the PTY device path for a running VM's virtio-console.
58+
// Resolves ref to an exact ID, verifies the VM is running, then queries
59+
// GET /api/v1/vm.info to obtain the PTY path allocated by Cloud Hypervisor.
60+
func (ch *CloudHypervisor) Console(ctx context.Context, ref string) (string, error) {
61+
info, err := ch.Inspect(ctx, ref)
62+
if err != nil {
63+
return "", err
64+
}
65+
66+
pid, _ := utils.ReadPIDFile(ch.conf.CHVMPIDFile(info.ID))
67+
if !utils.VerifyProcess(pid, filepath.Base(ch.conf.CHBinary)) {
68+
return "", fmt.Errorf("VM %s is not running", info.ID)
69+
}
70+
71+
socketPath := ch.conf.CHVMSocketPath(info.ID)
72+
body, err := hypervisor.DoGET(ctx, socketPath, "/api/v1/vm.info")
73+
if err != nil {
74+
return "", fmt.Errorf("query vm.info: %w", err)
75+
}
76+
77+
var vmInfo chVMInfoResponse
78+
if err := json.Unmarshal(body, &vmInfo); err != nil {
79+
return "", fmt.Errorf("decode vm.info: %w", err)
80+
}
81+
82+
ptyPath := vmInfo.Config.Console.File
83+
if ptyPath == "" {
84+
return "", fmt.Errorf("no console PTY allocated for VM %s (mode: %s)", info.ID, vmInfo.Config.Console.Mode)
85+
}
86+
return ptyPath, nil
87+
}
88+
5689
// List returns VMInfo for all known VMs.
5790
// Runtime fields are populated for each entry.
5891
func (ch *CloudHypervisor) List(ctx context.Context) ([]*types.VMInfo, error) {

hypervisor/cloudhypervisor/conf.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func buildVMConfig(rec *hypervisor.VMRecord, serialLogPath string) *chVMConfig {
2626
RNG: chRNG{Src: "/dev/urandom"},
2727
Watchdog: true,
2828
Serial: chSerial{Mode: "File", File: serialLogPath},
29-
Console: chConsole{Mode: "Off"},
29+
Console: chConsole{Mode: "Pty"},
3030
}
3131

3232
// Balloon: 25% of memory, only when memory >= 256 MiB.

hypervisor/http.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,28 @@ func DoPUT(ctx context.Context, socketPath, path string, body []byte) error {
7171
return nil
7272
}
7373

74+
// DoGET sends a GET request over a Unix socket and returns the response body.
75+
func DoGET(ctx context.Context, socketPath, path string) ([]byte, error) {
76+
hc := NewSocketHTTPClient(socketPath)
77+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost"+path, nil)
78+
if err != nil {
79+
return nil, fmt.Errorf("build request %s: %w", path, err)
80+
}
81+
resp, err := hc.Do(req)
82+
if err != nil {
83+
return nil, fmt.Errorf("GET %s: %w", path, err)
84+
}
85+
defer resp.Body.Close() //nolint:errcheck
86+
body, _ := io.ReadAll(resp.Body)
87+
if resp.StatusCode != http.StatusOK {
88+
return nil, &APIError{
89+
Code: resp.StatusCode,
90+
Message: fmt.Sprintf("GET %s → %d: %s", path, resp.StatusCode, body),
91+
}
92+
}
93+
return body, nil
94+
}
95+
7496
// CheckSocket verifies that a Unix domain socket is connectable.
7597
func CheckSocket(socketPath string) error {
7698
conn, err := net.DialTimeout("unix", socketPath, 2*time.Second)

hypervisor/hypervisor.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ type Hypervisor interface {
1717
Type() string
1818

1919
Create(context.Context, *types.VMConfig, []*types.StorageConfig, *types.BootConfig) (*types.VMInfo, error)
20-
Start(context.Context, []string) ([]string, error)
21-
Stop(context.Context, []string) ([]string, error)
22-
Inspect(context.Context, string) (*types.VMInfo, error)
20+
Start(ctx context.Context, refs []string) ([]string, error)
21+
Stop(ctx context.Context, refs []string) ([]string, error)
22+
Inspect(ctx context.Context, ref string) (*types.VMInfo, error)
2323
List(context.Context) ([]*types.VMInfo, error)
24-
Delete(ctx context.Context, ids []string, force bool) ([]string, error)
25-
// TODO Console
24+
Delete(ctx context.Context, refs []string, force bool) ([]string, error)
25+
Console(ctx context.Context, ref string) (string, error)
26+
2627
// TODO SNAPSHOT
2728
// TODO RESTORE
28-
// TODO MIGRTE
29+
// TODO MIGRATE
2930
RegisterGC(*gc.Orchestrator)
3031
}

0 commit comments

Comments
 (0)