diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index caf47c339..cbb5a50dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,6 +113,7 @@ jobs: done test -f "$HYPEMAN_WINDOWS_OVMF_CODE" test -f "$HYPEMAN_WINDOWS_OVMF_VARS" + test -r /ci/windows/image-agent.qcow2 - name: Test Windows hypervisor primitives run: | @@ -131,6 +132,24 @@ jobs: done exit 1 + - name: Test Windows guest control + run: | + make build-embedded + TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" + for attempt in 1 2 3; do + if sudo env \ + "PATH=$TEST_PATH" \ + "CI=true" \ + "HYPEMAN_RUN_WINDOWS_GUEST_CONTROL_INTEGRATION=1" \ + "HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \ + "HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \ + go test -count=1 -run '^TestWindowsGuestAgentIntegration$' -timeout 2m ./lib/instances; then + exit 0 + fi + test "$attempt" = 3 || sleep 5 + done + exit 1 + # Slash-command runs are maintainer-approved and need authenticated pulls # for images that are not covered by the prewarm cache. - name: Login to Docker Hub @@ -173,7 +192,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | for attempt in 1 2 3; do - if make build; then + if make build && make build-windows-guest-agent; then exit 0 fi if [ "$attempt" -lt 3 ]; then diff --git a/.gitignore b/.gitignore index 614d5996b..9f783dea6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ cloud-hypervisor cloud-hypervisor/** lib/system/exec_agent/exec-agent lib/system/guest_agent/guest-agent +lib/system/guest_agent/hypeman-guest-agent.exe lib/system/init/init lib/hypervisor/vz/vz-shim/vz-shim diff --git a/Makefile b/Makefile index fae025efd..99b92a400 100644 --- a/Makefile +++ b/Makefile @@ -235,6 +235,12 @@ lib/system/guest_agent/guest-agent: lib/system/guest_agent/*.go @echo "Building guest-agent for Linux..." cd lib/system/guest_agent && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o guest-agent . +lib/system/guest_agent/hypeman-guest-agent.exe: lib/system/guest_agent/*.go + @echo "Building guest-agent for Windows..." + cd lib/system/guest_agent && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o hypeman-guest-agent.exe . + +build-windows-guest-agent: lib/system/guest_agent/hypeman-guest-agent.exe + # Build init binary (runs as PID 1 in guest VM) for embedding # Cross-compile for Linux since it runs inside the VM lib/system/init/init: lib/system/init/*.go diff --git a/cmd/api/api/exec.go b/cmd/api/api/exec.go index b6f93102c..969faf141 100644 --- a/cmd/api/api/exec.go +++ b/cmd/api/api/exec.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" @@ -40,6 +41,7 @@ type ExecRequest struct { WaitForAgent int32 `json:"wait_for_agent,omitempty"` // seconds to wait for guest agent to be ready Rows uint32 `json:"rows,omitempty"` // Initial terminal rows (0 = default) Cols uint32 `json:"cols,omitempty"` // Initial terminal cols (0 = default) + Session string `json:"session,omitempty"` // system (default) or desktop (Windows) } // ResizeMessage represents a window resize control message @@ -106,9 +108,22 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) { return } - // Default command if not specified + session := guest.ExecSession_EXEC_SESSION_SYSTEM + switch strings.ToLower(execReq.Session) { + case "", "system": + case "desktop": + session = guest.ExecSession_EXEC_SESSION_DESKTOP + default: + ws.WriteMessage(websocket.TextMessage, []byte(`{"error":"session must be system or desktop"}`)) + return + } + if len(execReq.Command) == 0 { - execReq.Command = []string{"/bin/sh"} + if strings.HasPrefix(inst.Platform, "windows/") { + execReq.Command = []string{"cmd.exe"} + } else { + execReq.Command = []string{"/bin/sh"} + } } // Get JWT subject for audit logging (if available) @@ -170,6 +185,7 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) { WaitForAgent: time.Duration(execReq.WaitForAgent) * time.Second, Rows: execReq.Rows, Cols: execReq.Cols, + Session: session, ResizeChan: resizeChan, }) diff --git a/go.mod b/go.mod index cf5b2da13..18e8f8eba 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.4 require ( al.essio.dev/pkg/shellescape v1.6.0 github.com/Code-Hex/vz/v3 v3.7.1 + github.com/aymanbagabas/go-pty v0.2.2 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/creack/pty v1.1.24 github.com/cyphar/filepath-securejoin v0.6.1 diff --git a/go.sum b/go.sum index 679631b00..cb7cf9470 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/aymanbagabas/go-pty v0.2.2 h1:YZREB4eSj+1xdbbItIokX0ekjjeifgJOA+ZvxU4/WM8= +github.com/aymanbagabas/go-pty v0.2.2/go.mod h1:gfvlwH+0U66BCwxJREjJaAOEs9H1OFf3YFjI9WSiZ04= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -125,6 +127,8 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGh github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d h1:nP8SfQJqruIVSWYJTuYc37jLHEY1Z0fF+zKSrs3K/C8= +github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= @@ -264,6 +268,8 @@ github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= +github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7 h1:dtiVT4SeBUc/vHtwI2HjDZN+FCKTstQBxugIxJEGo9g= +github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7/go.mod h1:PW3wGFCHjdHxAhra5FKvcARbCGqGfentYuPKmuhv8DY= github.com/u-root/u-root v0.15.0 h1:8JXfjAA/Vs8EXfZUA2ftvoHbiYYLdaU8umJ461aq+Jw= github.com/u-root/u-root v0.15.0/go.mod h1:/0Qr7qJeDwWxoKku2xKQ4Szc+SwBE3g9VE8jNiamsmc= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= @@ -356,6 +362,8 @@ golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/lib/guest/client.go b/lib/guest/client.go index b6dcd2e8e..c5d95f8fe 100644 --- a/lib/guest/client.go +++ b/lib/guest/client.go @@ -147,6 +147,7 @@ type ExecOptions struct { WaitForAgent time.Duration // Max time to wait for agent to be ready (0 = no wait, fail immediately) Rows uint32 // Initial terminal rows (0 = default 24) Cols uint32 // Initial terminal cols (0 = default 80) + Session ExecSession // SYSTEM service session or active Windows desktop session ResizeChan <-chan *WindowSize // Optional: channel to receive resize events (pointer to avoid copying mutex) } @@ -477,6 +478,7 @@ func execIntoInstanceOnce(ctx context.Context, dialer hypervisor.VsockDialer, op TimeoutSeconds: opts.Timeout, Rows: opts.Rows, Cols: opts.Cols, + Session: opts.Session, }, }, }); err != nil { diff --git a/lib/guest/guest.pb.go b/lib/guest/guest.pb.go index a239fc970..4554f413d 100644 --- a/lib/guest/guest.pb.go +++ b/lib/guest/guest.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.10 -// protoc v6.33.4 +// protoc v3.21.12 // source: lib/guest/guest.proto package guest @@ -21,6 +21,52 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type ExecSession int32 + +const ( + ExecSession_EXEC_SESSION_SYSTEM ExecSession = 0 + ExecSession_EXEC_SESSION_DESKTOP ExecSession = 1 +) + +// Enum value maps for ExecSession. +var ( + ExecSession_name = map[int32]string{ + 0: "EXEC_SESSION_SYSTEM", + 1: "EXEC_SESSION_DESKTOP", + } + ExecSession_value = map[string]int32{ + "EXEC_SESSION_SYSTEM": 0, + "EXEC_SESSION_DESKTOP": 1, + } +) + +func (x ExecSession) Enum() *ExecSession { + p := new(ExecSession) + *p = x + return p +} + +func (x ExecSession) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExecSession) Descriptor() protoreflect.EnumDescriptor { + return file_lib_guest_guest_proto_enumTypes[0].Descriptor() +} + +func (ExecSession) Type() protoreflect.EnumType { + return &file_lib_guest_guest_proto_enumTypes[0] +} + +func (x ExecSession) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExecSession.Descriptor instead. +func (ExecSession) EnumDescriptor() ([]byte, []int) { + return file_lib_guest_guest_proto_rawDescGZIP(), []int{0} +} + // ExecRequest represents messages from client to server type ExecRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -130,6 +176,7 @@ type ExecStart struct { TimeoutSeconds int32 `protobuf:"varint,5,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` // Execution timeout in seconds (0 = no timeout) Rows uint32 `protobuf:"varint,6,opt,name=rows,proto3" json:"rows,omitempty"` // Initial terminal rows (0 = default 24) Cols uint32 `protobuf:"varint,7,opt,name=cols,proto3" json:"cols,omitempty"` // Initial terminal cols (0 = default 80) + Session ExecSession `protobuf:"varint,8,opt,name=session,proto3,enum=guest.ExecSession" json:"session,omitempty"` // SYSTEM service session or active desktop user unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -213,6 +260,13 @@ func (x *ExecStart) GetCols() uint32 { return 0 } +func (x *ExecStart) GetSession() ExecSession { + if x != nil { + return x.Session + } + return ExecSession_EXEC_SESSION_SYSTEM +} + // WindowSize represents terminal window dimensions for resize events type WindowSize struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1389,7 +1443,7 @@ const file_lib_guest_guest_proto_rawDesc = "" + "\x05start\x18\x01 \x01(\v2\x10.guest.ExecStartH\x00R\x05start\x12\x16\n" + "\x05stdin\x18\x02 \x01(\fH\x00R\x05stdin\x12+\n" + "\x06resize\x18\x03 \x01(\v2\x11.guest.WindowSizeH\x00R\x06resizeB\t\n" + - "\arequest\"\xff\x01\n" + + "\arequest\"\xad\x02\n" + "\tExecStart\x12\x18\n" + "\acommand\x18\x01 \x03(\tR\acommand\x12\x10\n" + "\x03tty\x18\x02 \x01(\bR\x03tty\x12+\n" + @@ -1397,7 +1451,8 @@ const file_lib_guest_guest_proto_rawDesc = "" + "\x03cwd\x18\x04 \x01(\tR\x03cwd\x12'\n" + "\x0ftimeout_seconds\x18\x05 \x01(\x05R\x0etimeoutSeconds\x12\x12\n" + "\x04rows\x18\x06 \x01(\rR\x04rows\x12\x12\n" + - "\x04cols\x18\a \x01(\rR\x04cols\x1a6\n" + + "\x04cols\x18\a \x01(\rR\x04cols\x12,\n" + + "\asession\x18\b \x01(\x0e2\x12.guest.ExecSessionR\asession\x1a6\n" + "\bEnvEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"4\n" + @@ -1479,7 +1534,10 @@ const file_lib_guest_guest_proto_rawDesc = "" + "\x04ipv4\x18\x03 \x01(\tR\x04ipv4\x12\x16\n" + "\x06prefix\x18\x04 \x01(\rR\x06prefix\x12\x18\n" + "\agateway\x18\x05 \x01(\tR\agateway\"\x1c\n" + - "\x1aReconfigureNetworkResponse2\xae\x03\n" + + "\x1aReconfigureNetworkResponse*@\n" + + "\vExecSession\x12\x17\n" + + "\x13EXEC_SESSION_SYSTEM\x10\x00\x12\x18\n" + + "\x14EXEC_SESSION_DESKTOP\x10\x012\xae\x03\n" + "\fGuestService\x123\n" + "\x04Exec\x12\x12.guest.ExecRequest\x1a\x13.guest.ExecResponse(\x010\x01\x12F\n" + "\vCopyToGuest\x12\x19.guest.CopyToGuestRequest\x1a\x1a.guest.CopyToGuestResponse(\x01\x12L\n" + @@ -1500,55 +1558,58 @@ func file_lib_guest_guest_proto_rawDescGZIP() []byte { return file_lib_guest_guest_proto_rawDescData } +var file_lib_guest_guest_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_lib_guest_guest_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_lib_guest_guest_proto_goTypes = []any{ - (*ExecRequest)(nil), // 0: guest.ExecRequest - (*ExecStart)(nil), // 1: guest.ExecStart - (*WindowSize)(nil), // 2: guest.WindowSize - (*ExecResponse)(nil), // 3: guest.ExecResponse - (*CopyToGuestRequest)(nil), // 4: guest.CopyToGuestRequest - (*CopyToGuestStart)(nil), // 5: guest.CopyToGuestStart - (*CopyToGuestEnd)(nil), // 6: guest.CopyToGuestEnd - (*CopyToGuestResponse)(nil), // 7: guest.CopyToGuestResponse - (*CopyFromGuestRequest)(nil), // 8: guest.CopyFromGuestRequest - (*CopyFromGuestResponse)(nil), // 9: guest.CopyFromGuestResponse - (*CopyFromGuestHeader)(nil), // 10: guest.CopyFromGuestHeader - (*CopyFromGuestEnd)(nil), // 11: guest.CopyFromGuestEnd - (*CopyFromGuestError)(nil), // 12: guest.CopyFromGuestError - (*StatPathRequest)(nil), // 13: guest.StatPathRequest - (*StatPathResponse)(nil), // 14: guest.StatPathResponse - (*ShutdownRequest)(nil), // 15: guest.ShutdownRequest - (*ShutdownResponse)(nil), // 16: guest.ShutdownResponse - (*ReconfigureNetworkRequest)(nil), // 17: guest.ReconfigureNetworkRequest - (*ReconfigureNetworkResponse)(nil), // 18: guest.ReconfigureNetworkResponse - nil, // 19: guest.ExecStart.EnvEntry + (ExecSession)(0), // 0: guest.ExecSession + (*ExecRequest)(nil), // 1: guest.ExecRequest + (*ExecStart)(nil), // 2: guest.ExecStart + (*WindowSize)(nil), // 3: guest.WindowSize + (*ExecResponse)(nil), // 4: guest.ExecResponse + (*CopyToGuestRequest)(nil), // 5: guest.CopyToGuestRequest + (*CopyToGuestStart)(nil), // 6: guest.CopyToGuestStart + (*CopyToGuestEnd)(nil), // 7: guest.CopyToGuestEnd + (*CopyToGuestResponse)(nil), // 8: guest.CopyToGuestResponse + (*CopyFromGuestRequest)(nil), // 9: guest.CopyFromGuestRequest + (*CopyFromGuestResponse)(nil), // 10: guest.CopyFromGuestResponse + (*CopyFromGuestHeader)(nil), // 11: guest.CopyFromGuestHeader + (*CopyFromGuestEnd)(nil), // 12: guest.CopyFromGuestEnd + (*CopyFromGuestError)(nil), // 13: guest.CopyFromGuestError + (*StatPathRequest)(nil), // 14: guest.StatPathRequest + (*StatPathResponse)(nil), // 15: guest.StatPathResponse + (*ShutdownRequest)(nil), // 16: guest.ShutdownRequest + (*ShutdownResponse)(nil), // 17: guest.ShutdownResponse + (*ReconfigureNetworkRequest)(nil), // 18: guest.ReconfigureNetworkRequest + (*ReconfigureNetworkResponse)(nil), // 19: guest.ReconfigureNetworkResponse + nil, // 20: guest.ExecStart.EnvEntry } var file_lib_guest_guest_proto_depIdxs = []int32{ - 1, // 0: guest.ExecRequest.start:type_name -> guest.ExecStart - 2, // 1: guest.ExecRequest.resize:type_name -> guest.WindowSize - 19, // 2: guest.ExecStart.env:type_name -> guest.ExecStart.EnvEntry - 5, // 3: guest.CopyToGuestRequest.start:type_name -> guest.CopyToGuestStart - 6, // 4: guest.CopyToGuestRequest.end:type_name -> guest.CopyToGuestEnd - 10, // 5: guest.CopyFromGuestResponse.header:type_name -> guest.CopyFromGuestHeader - 11, // 6: guest.CopyFromGuestResponse.end:type_name -> guest.CopyFromGuestEnd - 12, // 7: guest.CopyFromGuestResponse.error:type_name -> guest.CopyFromGuestError - 0, // 8: guest.GuestService.Exec:input_type -> guest.ExecRequest - 4, // 9: guest.GuestService.CopyToGuest:input_type -> guest.CopyToGuestRequest - 8, // 10: guest.GuestService.CopyFromGuest:input_type -> guest.CopyFromGuestRequest - 13, // 11: guest.GuestService.StatPath:input_type -> guest.StatPathRequest - 15, // 12: guest.GuestService.Shutdown:input_type -> guest.ShutdownRequest - 17, // 13: guest.GuestService.ReconfigureNetwork:input_type -> guest.ReconfigureNetworkRequest - 3, // 14: guest.GuestService.Exec:output_type -> guest.ExecResponse - 7, // 15: guest.GuestService.CopyToGuest:output_type -> guest.CopyToGuestResponse - 9, // 16: guest.GuestService.CopyFromGuest:output_type -> guest.CopyFromGuestResponse - 14, // 17: guest.GuestService.StatPath:output_type -> guest.StatPathResponse - 16, // 18: guest.GuestService.Shutdown:output_type -> guest.ShutdownResponse - 18, // 19: guest.GuestService.ReconfigureNetwork:output_type -> guest.ReconfigureNetworkResponse - 14, // [14:20] is the sub-list for method output_type - 8, // [8:14] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 2, // 0: guest.ExecRequest.start:type_name -> guest.ExecStart + 3, // 1: guest.ExecRequest.resize:type_name -> guest.WindowSize + 20, // 2: guest.ExecStart.env:type_name -> guest.ExecStart.EnvEntry + 0, // 3: guest.ExecStart.session:type_name -> guest.ExecSession + 6, // 4: guest.CopyToGuestRequest.start:type_name -> guest.CopyToGuestStart + 7, // 5: guest.CopyToGuestRequest.end:type_name -> guest.CopyToGuestEnd + 11, // 6: guest.CopyFromGuestResponse.header:type_name -> guest.CopyFromGuestHeader + 12, // 7: guest.CopyFromGuestResponse.end:type_name -> guest.CopyFromGuestEnd + 13, // 8: guest.CopyFromGuestResponse.error:type_name -> guest.CopyFromGuestError + 1, // 9: guest.GuestService.Exec:input_type -> guest.ExecRequest + 5, // 10: guest.GuestService.CopyToGuest:input_type -> guest.CopyToGuestRequest + 9, // 11: guest.GuestService.CopyFromGuest:input_type -> guest.CopyFromGuestRequest + 14, // 12: guest.GuestService.StatPath:input_type -> guest.StatPathRequest + 16, // 13: guest.GuestService.Shutdown:input_type -> guest.ShutdownRequest + 18, // 14: guest.GuestService.ReconfigureNetwork:input_type -> guest.ReconfigureNetworkRequest + 4, // 15: guest.GuestService.Exec:output_type -> guest.ExecResponse + 8, // 16: guest.GuestService.CopyToGuest:output_type -> guest.CopyToGuestResponse + 10, // 17: guest.GuestService.CopyFromGuest:output_type -> guest.CopyFromGuestResponse + 15, // 18: guest.GuestService.StatPath:output_type -> guest.StatPathResponse + 17, // 19: guest.GuestService.Shutdown:output_type -> guest.ShutdownResponse + 19, // 20: guest.GuestService.ReconfigureNetwork:output_type -> guest.ReconfigureNetworkResponse + 15, // [15:21] is the sub-list for method output_type + 9, // [9:15] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_lib_guest_guest_proto_init() } @@ -1582,13 +1643,14 @@ func file_lib_guest_guest_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_lib_guest_guest_proto_rawDesc), len(file_lib_guest_guest_proto_rawDesc)), - NumEnums: 0, + NumEnums: 1, NumMessages: 20, NumExtensions: 0, NumServices: 1, }, GoTypes: file_lib_guest_guest_proto_goTypes, DependencyIndexes: file_lib_guest_guest_proto_depIdxs, + EnumInfos: file_lib_guest_guest_proto_enumTypes, MessageInfos: file_lib_guest_guest_proto_msgTypes, }.Build() File_lib_guest_guest_proto = out.File diff --git a/lib/guest/guest.proto b/lib/guest/guest.proto index 317c21b3e..41b55771e 100644 --- a/lib/guest/guest.proto +++ b/lib/guest/guest.proto @@ -34,6 +34,11 @@ message ExecRequest { } } +enum ExecSession { + EXEC_SESSION_SYSTEM = 0; + EXEC_SESSION_DESKTOP = 1; +} + // ExecStart initiates command execution message ExecStart { repeated string command = 1; // Command and arguments @@ -43,6 +48,7 @@ message ExecStart { int32 timeout_seconds = 5; // Execution timeout in seconds (0 = no timeout) uint32 rows = 6; // Initial terminal rows (0 = default 24) uint32 cols = 7; // Initial terminal cols (0 = default 80) + ExecSession session = 8; // SYSTEM service session or active desktop user } // WindowSize represents terminal window dimensions for resize events diff --git a/lib/guest/guest_grpc.pb.go b/lib/guest/guest_grpc.pb.go index f93631d93..acad4a3d1 100644 --- a/lib/guest/guest_grpc.pb.go +++ b/lib/guest/guest_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v6.33.4 +// - protoc v3.21.12 // source: lib/guest/guest.proto package guest diff --git a/lib/hypervisor/socket_cache_key.go b/lib/hypervisor/socket_cache_key_unix.go similarity index 95% rename from lib/hypervisor/socket_cache_key.go rename to lib/hypervisor/socket_cache_key_unix.go index efd31cd0d..723f65c0a 100644 --- a/lib/hypervisor/socket_cache_key.go +++ b/lib/hypervisor/socket_cache_key_unix.go @@ -1,3 +1,5 @@ +//go:build !windows + package hypervisor import ( diff --git a/lib/hypervisor/socket_cache_key_windows.go b/lib/hypervisor/socket_cache_key_windows.go new file mode 100644 index 000000000..d7c171cb3 --- /dev/null +++ b/lib/hypervisor/socket_cache_key_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package hypervisor + +func SocketCacheKey(socketPath string) string { return socketPath } diff --git a/lib/instances/create.go b/lib/instances/create.go index d4ff30db4..3d31f1a08 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -385,7 +385,7 @@ func (m *manager) createInstance( Entrypoint: req.Entrypoint, Cmd: req.Cmd, SkipKernelHeaders: req.SkipKernelHeaders, - SkipGuestAgent: req.SkipGuestAgent || windows, + SkipGuestAgent: req.SkipGuestAgent, EnableRosetta: enableRosetta, SnapshotPolicy: cloneSnapshotPolicy(req.SnapshotPolicy), AutoStandby: cloneAutoStandbyPolicy(req.AutoStandby), diff --git a/lib/instances/query.go b/lib/instances/query.go index 98c5359e0..30ad4d6d8 100644 --- a/lib/instances/query.go +++ b/lib/instances/query.go @@ -316,7 +316,13 @@ func (m *manager) hydrateBootMarkersFromLogs(ctx context.Context, stored *Stored stored.GuestAgentReadyAt = guestAgentReadyAt hydrated = true } - if needAgent && stored.GuestAgentReadyAt == nil && stored.ProgramStartedAt != nil && m.hydrateGuestAgentReadyFromProbe(ctx, stored) { + if isWindowsPlatform(stored.Platform) && (needProgram || needAgent) && m.hydrateGuestAgentReadyFromProbe(ctx, stored) { + if stored.ProgramStartedAt == nil { + startedAt := *stored.GuestAgentReadyAt + stored.ProgramStartedAt = &startedAt + } + hydrated = true + } else if needAgent && stored.GuestAgentReadyAt == nil && stored.ProgramStartedAt != nil && m.hydrateGuestAgentReadyFromProbe(ctx, stored) { hydrated = true } if hydrated { @@ -446,8 +452,12 @@ func probeGuestAgentReady(ctx context.Context, stored *StoredMetadata) bool { probeCtx, cancel := context.WithTimeout(ctx, guestAgentReadyProbeTimeout) defer cancel() + command := []string{"/bin/true"} + if isWindowsPlatform(stored.Platform) { + command = []string{"cmd.exe", "/d", "/c", "exit", "0"} + } exit, err := guest.ExecIntoInstance(probeCtx, dialer, guest.ExecOptions{ - Command: []string{"/bin/true"}, + Command: command, Timeout: int32(guestAgentReadyProbeTimeout / time.Second), WaitForAgent: guestAgentReadyProbeWait, }) diff --git a/lib/instances/query_test.go b/lib/instances/query_test.go index 8bb0bb464..6bab797f8 100644 --- a/lib/instances/query_test.go +++ b/lib/instances/query_test.go @@ -438,6 +438,26 @@ func TestHydrateBootMarkersUsesGuestAgentProbeWhenReadyMarkerMissing(t *testing. assert.Equal(t, 1, probeCalls) } +func TestHydrateBootMarkersUsesWindowsGuestAgentAsBootMarker(t *testing.T) { + t.Parallel() + + readyAt := time.Date(2026, 3, 8, 12, 0, 2, 0, time.UTC) + m := &manager{ + paths: paths.New(t.TempDir()), + now: func() time.Time { return readyAt }, + guestAgentReadyProbe: func(context.Context, *StoredMetadata) bool { + return true + }, + } + meta := &StoredMetadata{Id: "windows-instance", Platform: "windows/amd64"} + meta.Phases.Record(phasetracking.PhaseInitializing, readyAt.Add(-time.Second)) + + require.True(t, m.hydrateBootMarkersFromLogs(context.Background(), meta)) + require.Equal(t, readyAt, *meta.ProgramStartedAt) + require.Equal(t, readyAt, *meta.GuestAgentReadyAt) + assert.Equal(t, StateRunning, deriveRunningState(meta)) +} + func TestParseBootMarkers_IgnoresStaleMarkersBeforeBootStart(t *testing.T) { t.Parallel() diff --git a/lib/instances/windows_guest_agent_integration_linux_test.go b/lib/instances/windows_guest_agent_integration_linux_test.go new file mode 100644 index 000000000..fd5b0259d --- /dev/null +++ b/lib/instances/windows_guest_agent_integration_linux_test.go @@ -0,0 +1,159 @@ +//go:build linux && amd64 + +package instances + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/guest" + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWindowsGuestAgentIntegration(t *testing.T) { + if os.Getenv("HYPEMAN_RUN_WINDOWS_GUEST_CONTROL_INTEGRATION") != "1" { + t.Skip("run by the dedicated Windows guest-control CI gate") + } + fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_IMAGE") + if fixture == "" { + fixture = "/ci/windows/image-agent.qcow2" + } + if _, err := os.Stat(fixture); err != nil { + if os.Getenv("CI") == "true" { + t.Fatalf("required Windows guest-agent fixture is missing: %s", fixture) + } + t.Skipf("Windows guest-agent fixture is unavailable: %s", fixture) + } + acquireHeavyIO(t) + + manager, dataDir := setupTestManagerForQEMU(t) + p := paths.New(dataDir) + const digestHex = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + image := &images.Image{ + Name: "registry.example/windows/image:guest-agent-integration", + Digest: "sha256:" + digestHex, + Platform: "windows/amd64", + Status: images.StatusReady, + Machine: &images.MachineImage{ + Kind: images.MachineImageWindowsImage, + Base: "registry.example/windows/base@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + TPM: "2.0", + SecureBoot: "required", + VirtualSize: 80 << 30, + }, + } + manager.imageManager = windowsFixtureImageManager{image: image} + imagePath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine) + require.NoError(t, err) + require.NoError(t, forkvm.CopyRegularFile(fixture, imagePath)) + require.NoError(t, os.Chmod(imagePath, 0444)) + + ctx := context.Background() + instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{ + Name: "windows-guest-agent-integration", + Image: image.Name, + Platform: "windows/amd64", + Size: 8 << 30, + Vcpus: 4, + Hypervisor: hypervisor.TypeQEMU, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, instance.Id) }) + + require.Eventually(t, func() bool { + current, err := manager.GetInstance(ctx, instance.Id) + return err == nil && current.State == StateRunning + }, 4*time.Minute, time.Second, "Windows guest agent did not become ready") + + dialer, err := manager.GetVsockDialer(ctx, instance.Id) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + jobStart := time.Now() + exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `Copy-Item "$env:SystemRoot\System32\ping.exe" "$env:TEMP\hypeman-job-child.exe" -Force; & "$env:TEMP\hypeman-job-child.exe" -n 60 127.0.0.1`}, + Stdout: &stdout, + Stderr: &stderr, + Timeout: 2, + }) + require.NoError(t, err, stderr.String()) + assert.Less(t, time.Since(jobStart), 10*time.Second, "timed out process tree did not terminate promptly") + + time.Sleep(5 * time.Second) + stdout.Reset() + stderr.Reset() + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `if (Get-Process hypeman-job-child -ErrorAction SilentlyContinue) { exit 42 }`}, + Stdout: &stdout, + Stderr: &stderr, + Timeout: 15, + }) + require.NoError(t, err, stderr.String()) + require.Equal(t, 0, exit.Code, "job object left a child process running") + + stdout.Reset() + stderr.Reset() + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write('HYPEMAN_SYSTEM_OK')"}, + Stdout: &stdout, + Stderr: &stderr, + Timeout: 30, + }) + require.NoError(t, err, stderr.String()) + require.Equal(t, 0, exit.Code) + assert.Equal(t, "HYPEMAN_SYSTEM_OK", stdout.String()) + + stdout.Reset() + stderr.Reset() + resizes := make(chan *guest.WindowSize, 1) + resizes <- &guest.WindowSize{Rows: 37, Cols: 101} + close(resizes) + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"cmd.exe", "/d", "/c", "ping -n 2 127.0.0.1 >nul & echo HYPEMAN_CONPTY_OK"}, + Stdout: &stdout, + Stderr: &stderr, + TTY: true, + Rows: 31, + Cols: 97, + ResizeChan: resizes, + Timeout: 30, + }) + require.NoError(t, err, stderr.String()) + require.Equal(t, 0, exit.Code) + assert.Contains(t, stdout.String(), "HYPEMAN_CONPTY_OK") + + stdout.Reset() + exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: []string{"cmd.exe", "/d", "/c", "echo", "HYPEMAN_DESKTOP_OK"}, + Stdout: &stdout, + Session: guest.ExecSession_EXEC_SESSION_DESKTOP, + Timeout: 30, + }) + require.NoError(t, err) + require.Equal(t, 0, exit.Code) + assert.Contains(t, stdout.String(), "HYPEMAN_DESKTOP_OK") + + source := filepath.Join(t.TempDir(), "roundtrip.txt") + require.NoError(t, os.WriteFile(source, []byte("HYPEMAN_COPY_OK"), 0644)) + require.NoError(t, guest.CopyToInstance(ctx, dialer, guest.CopyToInstanceOptions{ + SrcPath: source, + DstPath: `C:\ProgramData\Hypeman\roundtrip.txt`, + })) + destination := t.TempDir() + require.NoError(t, guest.CopyFromInstance(ctx, dialer, guest.CopyFromInstanceOptions{ + SrcPath: `C:\ProgramData\Hypeman\roundtrip.txt`, + DstPath: destination, + })) + contents, err := os.ReadFile(filepath.Join(destination, "roundtrip.txt")) + require.NoError(t, err) + assert.Equal(t, "HYPEMAN_COPY_OK", string(contents)) +} diff --git a/lib/instances/windows_integration_test_helpers_linux_test.go b/lib/instances/windows_integration_test_helpers_linux_test.go new file mode 100644 index 000000000..1c8b36143 --- /dev/null +++ b/lib/instances/windows_integration_test_helpers_linux_test.go @@ -0,0 +1,26 @@ +//go:build linux && amd64 + +package instances + +import ( + "context" + + "github.com/kernel/hypeman/lib/images" +) + +type windowsFixtureImageManager struct { + images.Manager + image *images.Image +} + +func (m windowsFixtureImageManager) CreateImage(context.Context, images.CreateImageRequest) (*images.Image, error) { + copy := *m.image + return ©, nil +} + +func (m windowsFixtureImageManager) GetImage(context.Context, string) (*images.Image, error) { + copy := *m.image + return ©, nil +} + +func (m windowsFixtureImageManager) WaitForReady(context.Context, string) error { return nil } diff --git a/lib/system/README.md b/lib/system/README.md index 886dc3d72..520507743 100644 --- a/lib/system/README.md +++ b/lib/system/README.md @@ -55,6 +55,23 @@ Instance B (running): kernel ch-6.12.8-kernel-1.2-20251213 Both work independently ``` +## Windows guest service + +Windows images install `hypeman-guest-agent.exe` as the automatic `HypemanGuestAgent` LocalSystem service alongside the signed virtio-win VioSock driver. It serves the same gRPC guest protocol as Linux on port 2222, so host readiness, exec, copy, stat, networking, identity, and shutdown operations do not require a Windows-specific transport contract. + +The virtio-win provider assigns its Winsock address-family number dynamically. The service opens `\\.\Viosock`, queries that family through the driver's `IOCTL_VM_SOCKETS_GET_AF`, and wraps the resulting Winsock handles as Go `net.Listener` and `net.Conn` values. Go's `net` package does not natively create sockets for this provider. Connection deadlines are currently no-ops; RPC cancellation closes the connection, and the service does not promise independent socket-level read or write deadlines. + +Commands run in one of two explicit sessions: + +- `SYSTEM` inherits the service account and is used for automation. +- `DESKTOP` obtains a token for the active interactive session so UI processes appear on that desktop. + +Interactive commands use ConPTY and accept terminal input and resize messages through the existing streaming Exec RPC. Non-interactive commands use ordinary redirected handles. + +Windows has no Unix process-group equivalent. Commands start suspended, are assigned to a kill-on-close Job Object, and are then resumed. Closing the RPC, reaching its timeout, or completing cleanup synchronously terminates the job, including descendants, so a command cannot leave a child process behind. Starting suspended closes the race where the root process could spawn a child before job assignment. + +The generated executable and Windows driver packages are release inputs and are not committed to this repository. + ## Go Init Binary The init binary (`lib/system/init/`) is a Go program that runs as PID 1 in the guest VM. diff --git a/lib/system/guest_agent/cp.go b/lib/system/guest_agent/cp.go index c2ad5b020..c4d412107 100644 --- a/lib/system/guest_agent/cp.go +++ b/lib/system/guest_agent/cp.go @@ -7,7 +7,6 @@ import ( "log" "os" "path/filepath" - "syscall" "time" pb "github.com/kernel/hypeman/lib/guest" @@ -140,7 +139,7 @@ func (s *guestServer) CopyToGuest(stream pb.GuestService_CopyToGuestServer) erro // Only chown when both UID and GID are explicitly set (non-zero) // to avoid accidentally setting one to root (0) when only the other is specified if start.Uid > 0 && start.Gid > 0 { - if err := os.Chown(start.Path, int(start.Uid), int(start.Gid)); err != nil { + if err := setFileOwnership(start.Path, start.Uid, start.Gid); err != nil { log.Printf("[guest-agent] warning: failed to set ownership on %s: %v", start.Path, err) } } @@ -210,11 +209,7 @@ func (s *guestServer) copyFromGuestFile(fullPath, relativePath string, info os.F } // Extract UID/GID from file info - var uid, gid uint32 - if stat, ok := info.Sys().(*syscall.Stat_t); ok { - uid = stat.Uid - gid = stat.Gid - } + uid, gid := fileOwnership(info) // Send header header := &pb.CopyFromGuestHeader{ @@ -361,12 +356,7 @@ func (s *guestServer) copyFromGuestDir(rootPath string, followLinks bool, stream isFinal := i == len(entries)-1 if e.info.IsDir() { - // Extract UID/GID from file info - var uid, gid uint32 - if stat, ok := e.info.Sys().(*syscall.Stat_t); ok { - uid = stat.Uid - gid = stat.Gid - } + uid, gid := fileOwnership(e.info) // Send directory header if err := stream.Send(&pb.CopyFromGuestResponse{ diff --git a/lib/system/guest_agent/exec.go b/lib/system/guest_agent/exec.go index 409754c65..5f2573cd2 100644 --- a/lib/system/guest_agent/exec.go +++ b/lib/system/guest_agent/exec.go @@ -3,15 +3,11 @@ package main import ( "context" "fmt" - "io" "log" "os" - "os/exec" "strings" - "sync" "time" - "github.com/creack/pty" pb "github.com/kernel/hypeman/lib/guest" ) @@ -30,16 +26,15 @@ func (s *guestServer) Exec(stream pb.GuestService_ExecServer) error { return fmt.Errorf("first message must be ExecStart") } - command := start.Command - if len(command) == 0 { - command = []string{"/bin/sh"} + if len(start.Command) == 0 { + start.Command = defaultCommand() } log.Printf("[guest-agent] exec: command=%v tty=%v cwd=%s timeout=%d", - command, start.Tty, start.Cwd, start.TimeoutSeconds) + start.Command, start.Tty, start.Cwd, start.TimeoutSeconds) - // Create context with timeout if specified - ctx := context.Background() + // Windows ties process lifetime to the RPC stream; Unix keeps the existing behavior. + ctx := execContext(stream.Context()) if start.TimeoutSeconds > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(start.TimeoutSeconds)*time.Second) @@ -52,221 +47,6 @@ func (s *guestServer) Exec(stream pb.GuestService_ExecServer) error { return s.executeNoTTY(ctx, stream, start) } -// executeNoTTY executes command without TTY -func (s *guestServer) executeNoTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { - // Run command directly - guest-agent is already running in container namespace - if len(start.Command) == 0 { - return fmt.Errorf("empty command") - } - - cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...) - - // Set up environment (no TTY defaults for non-TTY mode) - cmd.Env = s.buildEnv(start.Env, false) - - // Set up working directory - if start.Cwd != "" { - cmd.Dir = start.Cwd - } - - stdin, _ := cmd.StdinPipe() - stdout, _ := cmd.StdoutPipe() - stderr, _ := cmd.StderrPipe() - - if err := cmd.Start(); err != nil { - return fmt.Errorf("start command: %w", err) - } - - // Mutex to protect concurrent stream.Send calls (gRPC streams are not thread-safe) - var sendMu sync.Mutex - - // Use WaitGroup to ensure all output is read before sending - var wg sync.WaitGroup - var stdoutData, stderrData []byte - - // Handle stdin in background - go func() { - defer stdin.Close() - for { - req, err := stream.Recv() - if err != nil { - return - } - if data := req.GetStdin(); data != nil { - stdin.Write(data) - } - } - }() - - // Read all stdout/stderr BEFORE calling Wait() - Wait() closes the pipes! - wg.Add(1) - go func() { - defer wg.Done() - data, _ := io.ReadAll(stdout) - stdoutData = data - }() - - wg.Add(1) - go func() { - defer wg.Done() - data, _ := io.ReadAll(stderr) - stderrData = data - }() - - // Wait for all reads to complete FIRST (before Wait closes pipes) - wg.Wait() - - // Now safe to call Wait - pipes are fully drained - waitErr := cmd.Wait() - - // Now stream output in chunks (streaming compatible) - const chunkSize = 32 * 1024 - for i := 0; i < len(stdoutData); i += chunkSize { - end := i + chunkSize - if end > len(stdoutData) { - end = len(stdoutData) - } - sendMu.Lock() - stream.Send(&pb.ExecResponse{ - Response: &pb.ExecResponse_Stdout{Stdout: stdoutData[i:end]}, - }) - sendMu.Unlock() - } - for i := 0; i < len(stderrData); i += chunkSize { - end := i + chunkSize - if end > len(stderrData) { - end = len(stderrData) - } - sendMu.Lock() - stream.Send(&pb.ExecResponse{ - Response: &pb.ExecResponse_Stderr{Stderr: stderrData[i:end]}, - }) - sendMu.Unlock() - } - - exitCode := int32(0) - if cmd.ProcessState != nil { - exitCode = int32(cmd.ProcessState.ExitCode()) - } else if waitErr != nil { - // If killed by timeout, exit with 124 (GNU timeout convention) - exitCode = 124 - } - - log.Printf("[guest-agent] command finished with exit code: %d", exitCode) - - // Send exit code - return stream.Send(&pb.ExecResponse{ - Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}, - }) -} - -// executeTTY executes command with TTY -func (s *guestServer) executeTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { - // Run command directly with PTY - guest-agent is already running in container namespace - // This ensures PTY and shell are in the same namespace, fixing Ctrl+C signal handling - if len(start.Command) == 0 { - return fmt.Errorf("empty command") - } - - cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...) - - // Set up environment (TTY mode adds TERM default) - cmd.Env = s.buildEnv(start.Env, true) - - // Set up working directory - if start.Cwd != "" { - cmd.Dir = start.Cwd - } - - // Set up initial window size (use defaults if not specified) - ws := &pty.Winsize{ - Rows: uint16(start.Rows), - Cols: uint16(start.Cols), - } - if ws.Rows == 0 { - ws.Rows = 24 - } - if ws.Cols == 0 { - ws.Cols = 80 - } - - // Start with PTY and initial window size - ptmx, err := pty.StartWithSize(cmd, ws) - if err != nil { - return fmt.Errorf("start pty: %w", err) - } - defer ptmx.Close() - - // Mutex to protect concurrent stream.Send calls (gRPC streams are not thread-safe) - var sendMu sync.Mutex - - // Use WaitGroup to ensure all output is sent before exit code - var wg sync.WaitGroup - - // Handle stdin and resize in background - go func() { - for { - req, err := stream.Recv() - if err != nil { - return - } - - if data := req.GetStdin(); data != nil { - ptmx.Write(data) - } - - // Handle window resize - if resize := req.GetResize(); resize != nil { - pty.Setsize(ptmx, &pty.Winsize{ - Rows: uint16(resize.Rows), - Cols: uint16(resize.Cols), - }) - } - } - }() - - // Stream output - wg.Add(1) - go func() { - defer wg.Done() - buf := make([]byte, 32*1024) - for { - n, err := ptmx.Read(buf) - if n > 0 { - sendMu.Lock() - stream.Send(&pb.ExecResponse{ - Response: &pb.ExecResponse_Stdout{Stdout: buf[:n]}, - }) - sendMu.Unlock() - } - if err != nil { - return - } - } - }() - - // Wait for command or context cancellation - waitErr := cmd.Wait() - - // Wait for all output to be sent - wg.Wait() - - exitCode := int32(0) - if cmd.ProcessState != nil { - exitCode = int32(cmd.ProcessState.ExitCode()) - } else if waitErr != nil { - // If killed by timeout, exit with 124 (GNU timeout convention) - exitCode = 124 - } - - log.Printf("[guest-agent] TTY command finished with exit code: %d", exitCode) - - // Send exit code - return stream.Send(&pb.ExecResponse{ - Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}, - }) -} - // buildEnv constructs environment variables by merging provided env with defaults. // When tty is true, adds sensible defaults for interactive terminal sessions. // User-provided env vars override both base environment and defaults. diff --git a/lib/system/guest_agent/exec_no_tty_unix.go b/lib/system/guest_agent/exec_no_tty_unix.go new file mode 100644 index 000000000..e772316fd --- /dev/null +++ b/lib/system/guest_agent/exec_no_tty_unix.go @@ -0,0 +1,90 @@ +//go:build !windows + +package main + +import ( + "context" + "fmt" + "io" + "log" + "os/exec" + "sync" + + pb "github.com/kernel/hypeman/lib/guest" +) + +func (s *guestServer) executeNoTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { + if len(start.Command) == 0 { + return fmt.Errorf("empty command") + } + + cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...) + cleanup, err := configureExecCommand(cmd, start.Session) + if err != nil { + return err + } + defer cleanup() + cmd.Env = s.buildEnv(start.Env, false) + if start.Cwd != "" { + cmd.Dir = start.Cwd + } + + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + if err := cmd.Start(); err != nil { + return fmt.Errorf("start command: %w", err) + } + + var sendMu sync.Mutex + var wg sync.WaitGroup + var stdoutData, stderrData []byte + go func() { + defer stdin.Close() + for { + req, err := stream.Recv() + if err != nil { + return + } + if data := req.GetStdin(); data != nil { + _, _ = stdin.Write(data) + } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + stdoutData, _ = io.ReadAll(stdout) + }() + wg.Add(1) + go func() { + defer wg.Done() + stderrData, _ = io.ReadAll(stderr) + }() + wg.Wait() + waitErr := cmd.Wait() + + const chunkSize = 32 * 1024 + for i := 0; i < len(stdoutData); i += chunkSize { + end := min(i+chunkSize, len(stdoutData)) + sendMu.Lock() + _ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stdout{Stdout: stdoutData[i:end]}}) + sendMu.Unlock() + } + for i := 0; i < len(stderrData); i += chunkSize { + end := min(i+chunkSize, len(stderrData)) + sendMu.Lock() + _ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stderr{Stderr: stderrData[i:end]}}) + sendMu.Unlock() + } + + exitCode := int32(0) + if cmd.ProcessState != nil { + exitCode = int32(cmd.ProcessState.ExitCode()) + } else if waitErr != nil { + exitCode = 124 + } + log.Printf("[guest-agent] command finished with exit code: %d", exitCode) + return stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}}) +} diff --git a/lib/system/guest_agent/exec_no_tty_windows.go b/lib/system/guest_agent/exec_no_tty_windows.go new file mode 100644 index 000000000..2e1129727 --- /dev/null +++ b/lib/system/guest_agent/exec_no_tty_windows.go @@ -0,0 +1,110 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "io" + "log" + "os" + "time" + + pb "github.com/kernel/hypeman/lib/guest" +) + +func (s *guestServer) executeNoTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { + if len(start.Command) == 0 { + return fmt.Errorf("empty command") + } + + cmd := execCommand(ctx, start.Command[0], start.Command[1:]...) + cleanup, err := configureExecCommand(cmd, start.Session) + if err != nil { + return err + } + defer cleanup() + cmd.Env = s.buildEnv(start.Env, false) + if start.Cwd != "" { + cmd.Dir = start.Cwd + } + + stdin, _ := cmd.StdinPipe() + stdoutFile, err := os.CreateTemp("", "hypeman-exec-stdout-*") + if err != nil { + return fmt.Errorf("create stdout capture: %w", err) + } + defer func() { + stdoutFile.Close() + os.Remove(stdoutFile.Name()) + }() + stderrFile, err := os.CreateTemp("", "hypeman-exec-stderr-*") + if err != nil { + return fmt.Errorf("create stderr capture: %w", err) + } + defer func() { + stderrFile.Close() + os.Remove(stderrFile.Name()) + }() + cmd.Stdout = stdoutFile + cmd.Stderr = stderrFile + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start command: %w", err) + } + jobCleanup, err := attachProcessJob(ctx, cmd.Process, time.Duration(start.TimeoutSeconds)*time.Second) + if err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return fmt.Errorf("attach process job: %w", err) + } + defer jobCleanup() + + go func() { + defer stdin.Close() + for { + req, err := stream.Recv() + if err != nil { + return + } + if data := req.GetStdin(); data != nil { + _, _ = stdin.Write(data) + } + } + }() + + waitErr := cmd.Wait() + if _, err := stdoutFile.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("rewind stdout capture: %w", err) + } + stdout, err := io.ReadAll(stdoutFile) + if err != nil { + return fmt.Errorf("read stdout capture: %w", err) + } + if _, err := stderrFile.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("rewind stderr capture: %w", err) + } + stderr, err := io.ReadAll(stderrFile) + if err != nil { + return fmt.Errorf("read stderr capture: %w", err) + } + + const chunkSize = 32 * 1024 + for i := 0; i < len(stdout); i += chunkSize { + end := min(i+chunkSize, len(stdout)) + _ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stdout{Stdout: stdout[i:end]}}) + } + for i := 0; i < len(stderr); i += chunkSize { + end := min(i+chunkSize, len(stderr)) + _ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stderr{Stderr: stderr[i:end]}}) + } + + exitCode := int32(0) + if cmd.ProcessState != nil { + exitCode = int32(cmd.ProcessState.ExitCode()) + } else if waitErr != nil { + exitCode = 124 + } + log.Printf("[guest-agent] command finished with exit code: %d", exitCode) + return stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}}) +} diff --git a/lib/system/guest_agent/exec_session_unix.go b/lib/system/guest_agent/exec_session_unix.go new file mode 100644 index 000000000..3d7c1187e --- /dev/null +++ b/lib/system/guest_agent/exec_session_unix.go @@ -0,0 +1,26 @@ +//go:build !windows + +package main + +import ( + "context" + "fmt" + "os/exec" + + pb "github.com/kernel/hypeman/lib/guest" +) + +func defaultCommand() []string { return []string{"/bin/sh"} } + +func execContext(context.Context) context.Context { return context.Background() } + +func configureExecCommand(_ *exec.Cmd, session pb.ExecSession) (func(), error) { + switch session { + case pb.ExecSession_EXEC_SESSION_SYSTEM: + return func() {}, nil + case pb.ExecSession_EXEC_SESSION_DESKTOP: + return nil, fmt.Errorf("desktop execution sessions are only supported on Windows") + default: + return nil, fmt.Errorf("unknown execution session %d", session) + } +} diff --git a/lib/system/guest_agent/exec_session_windows.go b/lib/system/guest_agent/exec_session_windows.go new file mode 100644 index 000000000..b8a92e589 --- /dev/null +++ b/lib/system/guest_agent/exec_session_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "os/exec" + "syscall" + "time" + "unsafe" + + pb "github.com/kernel/hypeman/lib/guest" + "golang.org/x/sys/windows" +) + +func defaultCommand() []string { return []string{"cmd.exe"} } + +func execContext(streamCtx context.Context) context.Context { return streamCtx } + +func execCommand(_ context.Context, name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) +} + +func activeDesktopToken() (windows.Token, error) { + var sessions *windows.WTS_SESSION_INFO + var count uint32 + if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil { + return 0, fmt.Errorf("enumerate desktop sessions: %w", err) + } + if sessions != nil { + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions))) + } + + for _, session := range unsafe.Slice(sessions, count) { + if session.State != windows.WTSActive { + continue + } + var token windows.Token + if err := windows.WTSQueryUserToken(session.SessionID, &token); err == nil { + return token, nil + } + } + return 0, fmt.Errorf("no active desktop user session") +} + +func executionToken(session pb.ExecSession) (syscall.Token, func(), error) { + switch session { + case pb.ExecSession_EXEC_SESSION_SYSTEM: + return 0, func() {}, nil + case pb.ExecSession_EXEC_SESSION_DESKTOP: + default: + return 0, nil, fmt.Errorf("unknown execution session %d", session) + } + token, err := activeDesktopToken() + if err != nil { + return 0, nil, err + } + return syscall.Token(token), func() { _ = token.Close() }, nil +} + +func configureExecCommand(cmd *exec.Cmd, session pb.ExecSession) (func(), error) { + token, cleanup, err := executionToken(session) + if err != nil { + return nil, err + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + Token: token, + CreationFlags: windows.CREATE_SUSPENDED, + } + cmd.WaitDelay = 2 * time.Second + return cleanup, nil +} diff --git a/lib/system/guest_agent/exec_tty_unix.go b/lib/system/guest_agent/exec_tty_unix.go new file mode 100644 index 000000000..d1550a932 --- /dev/null +++ b/lib/system/guest_agent/exec_tty_unix.go @@ -0,0 +1,83 @@ +//go:build !windows + +package main + +import ( + "context" + "fmt" + "log" + "os/exec" + "sync" + + "github.com/creack/pty" + pb "github.com/kernel/hypeman/lib/guest" +) + +func (s *guestServer) executeTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { + if len(start.Command) == 0 { + return fmt.Errorf("empty command") + } + + cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...) + cmd.Env = s.buildEnv(start.Env, true) + cmd.Dir = start.Cwd + + ws := &pty.Winsize{Rows: uint16(start.Rows), Cols: uint16(start.Cols)} + if ws.Rows == 0 { + ws.Rows = 24 + } + if ws.Cols == 0 { + ws.Cols = 80 + } + + ptmx, err := pty.StartWithSize(cmd, ws) + if err != nil { + return fmt.Errorf("start pty: %w", err) + } + defer ptmx.Close() + + var sendMu sync.Mutex + var wg sync.WaitGroup + go func() { + for { + req, err := stream.Recv() + if err != nil { + return + } + if data := req.GetStdin(); data != nil { + _, _ = ptmx.Write(data) + } + if resize := req.GetResize(); resize != nil { + _ = pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(resize.Rows), Cols: uint16(resize.Cols)}) + } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 32*1024) + for { + n, err := ptmx.Read(buf) + if n > 0 { + sendMu.Lock() + _ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stdout{Stdout: buf[:n]}}) + sendMu.Unlock() + } + if err != nil { + return + } + } + }() + + waitErr := cmd.Wait() + wg.Wait() + exitCode := int32(0) + if cmd.ProcessState != nil { + exitCode = int32(cmd.ProcessState.ExitCode()) + } else if waitErr != nil { + exitCode = 124 + } + log.Printf("[guest-agent] TTY command finished with exit code: %d", exitCode) + return stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}}) +} diff --git a/lib/system/guest_agent/exec_tty_windows.go b/lib/system/guest_agent/exec_tty_windows.go new file mode 100644 index 000000000..1428f3940 --- /dev/null +++ b/lib/system/guest_agent/exec_tty_windows.go @@ -0,0 +1,108 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "log" + "sync" + "syscall" + "time" + + pty "github.com/aymanbagabas/go-pty" + pb "github.com/kernel/hypeman/lib/guest" + "golang.org/x/sys/windows" +) + +func (s *guestServer) executeTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { + if len(start.Command) == 0 { + return fmt.Errorf("empty command") + } + + console, err := pty.New() + if err != nil { + return fmt.Errorf("create ConPTY: %w", err) + } + defer console.Close() + + cols, rows := int(start.Cols), int(start.Rows) + if cols == 0 { + cols = 80 + } + if rows == 0 { + rows = 24 + } + if err := console.Resize(cols, rows); err != nil { + return fmt.Errorf("resize ConPTY: %w", err) + } + + cmd := console.CommandContext(ctx, start.Command[0], start.Command[1:]...) + cmd.Env = s.buildEnv(start.Env, true) + cmd.Dir = start.Cwd + token, cleanup, err := executionToken(start.Session) + if err != nil { + return err + } + defer cleanup() + cmd.SysProcAttr = &syscall.SysProcAttr{ + Token: token, + CreationFlags: windows.CREATE_SUSPENDED, + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start ConPTY command: %w", err) + } + jobCleanup, err := attachProcessJob(ctx, cmd.Process, time.Duration(start.TimeoutSeconds)*time.Second) + if err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return fmt.Errorf("attach ConPTY process job: %w", err) + } + defer jobCleanup() + + var sendMu sync.Mutex + var wg sync.WaitGroup + go func() { + for { + req, err := stream.Recv() + if err != nil { + return + } + if data := req.GetStdin(); data != nil { + _, _ = console.Write(data) + } + if resize := req.GetResize(); resize != nil { + _ = console.Resize(int(resize.Cols), int(resize.Rows)) + } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 32*1024) + for { + n, err := console.Read(buf) + if n > 0 { + sendMu.Lock() + _ = stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_Stdout{Stdout: buf[:n]}}) + sendMu.Unlock() + } + if err != nil { + return + } + } + }() + + waitErr := cmd.Wait() + _ = console.Close() + wg.Wait() + exitCode := int32(0) + if cmd.ProcessState != nil { + exitCode = int32(cmd.ProcessState.ExitCode()) + } else if waitErr != nil { + exitCode = 124 + } + log.Printf("[guest-agent] ConPTY command finished with exit code: %d", exitCode) + return stream.Send(&pb.ExecResponse{Response: &pb.ExecResponse_ExitCode{ExitCode: exitCode}}) +} diff --git a/lib/system/guest_agent/listener_unix.go b/lib/system/guest_agent/listener_unix.go new file mode 100644 index 000000000..edc2f2543 --- /dev/null +++ b/lib/system/guest_agent/listener_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package main + +import ( + "net" + + "github.com/mdlayher/vsock" +) + +func listenVSock(port uint32) (net.Listener, error) { + return vsock.Listen(port, nil) +} + +func defaultReadyFilePath() string { + return "/run/hypeman/guest-agent-ready" +} diff --git a/lib/system/guest_agent/listener_windows.go b/lib/system/guest_agent/listener_windows.go new file mode 100644 index 000000000..05b8c427e --- /dev/null +++ b/lib/system/guest_agent/listener_windows.go @@ -0,0 +1,204 @@ +//go:build windows + +package main + +import ( + "fmt" + "io" + "net" + "runtime" + "sync" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + ioctlGetViosockAF = 0x0801300c + vmAddrCIDAny = ^uint32(0) + sockStream = 1 + socketError = ^uintptr(0) +) + +type rawSockaddrVM struct { + Family uint16 + Reserved1 uint16 + Port uint32 + CID uint32 + Zero [4]byte +} + +type vsockAddr struct { + cid uint32 + port uint32 +} + +func (a vsockAddr) Network() string { return "vsock" } +func (a vsockAddr) String() string { return fmt.Sprintf("%d:%d", a.cid, a.port) } + +var ( + winsockOnce sync.Once + winsockErr error + ws2DLL = windows.NewLazySystemDLL("ws2_32.dll") + bindProc = ws2DLL.NewProc("bind") + acceptProc = ws2DLL.NewProc("accept") +) + +func initializeWinsock() error { + winsockOnce.Do(func() { + var data windows.WSAData + winsockErr = windows.WSAStartup(0x202, &data) + }) + return winsockErr +} + +func viosockAddressFamily() (int32, error) { + devicePath, err := windows.UTF16PtrFromString(`\\.\Viosock`) + if err != nil { + return 0, err + } + device, err := windows.CreateFile(devicePath, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, 0, 0) + if err != nil { + return 0, fmt.Errorf("open Viosock device: %w", err) + } + defer windows.CloseHandle(device) + + var family uint32 + var returned uint32 + if err := windows.DeviceIoControl( + device, + ioctlGetViosockAF, + nil, + 0, + (*byte)(unsafe.Pointer(&family)), + uint32(unsafe.Sizeof(family)), + &returned, + nil, + ); err != nil { + return 0, fmt.Errorf("query Viosock address family: %w", err) + } + if returned != uint32(unsafe.Sizeof(family)) || family == 0 { + return 0, fmt.Errorf("Viosock returned invalid address family %d", family) + } + return int32(family), nil +} + +func listenVSock(port uint32) (net.Listener, error) { + if err := initializeWinsock(); err != nil { + return nil, fmt.Errorf("initialize Winsock: %w", err) + } + family, err := viosockAddressFamily() + if err != nil { + return nil, err + } + + handle, err := windows.WSASocket(family, sockStream, 0, nil, 0, windows.WSA_FLAG_OVERLAPPED) + if err != nil { + return nil, fmt.Errorf("create vsock: %w", err) + } + closeOnError := true + defer func() { + if closeOnError { + _ = windows.Closesocket(handle) + } + }() + + addr := rawSockaddrVM{Family: uint16(family), Port: port, CID: vmAddrCIDAny} + result, _, callErr := bindProc.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&addr)), + unsafe.Sizeof(addr), + ) + if result == socketError { + return nil, fmt.Errorf("bind vsock port %d: %w", port, callErr) + } + if err := windows.Listen(handle, 128); err != nil { + return nil, fmt.Errorf("listen on vsock port %d: %w", port, err) + } + + closeOnError = false + return &windowsVSockListener{handle: handle, addr: vsockAddr{cid: vmAddrCIDAny, port: port}}, nil +} + +type windowsVSockListener struct { + handle windows.Handle + addr vsockAddr + once sync.Once +} + +func (l *windowsVSockListener) Accept() (net.Conn, error) { + var peer rawSockaddrVM + peerLen := int32(unsafe.Sizeof(peer)) + result, _, callErr := acceptProc.Call( + uintptr(l.handle), + uintptr(unsafe.Pointer(&peer)), + uintptr(unsafe.Pointer(&peerLen)), + ) + if result == socketError { + return nil, fmt.Errorf("accept vsock connection: %w", callErr) + } + handle := windows.Handle(result) + return &windowsVSockConn{handle: handle, local: l.addr, remote: vsockAddr{cid: peer.CID, port: peer.Port}}, nil +} + +func (l *windowsVSockListener) Close() error { + var err error + l.once.Do(func() { err = windows.Closesocket(l.handle) }) + return err +} + +func (l *windowsVSockListener) Addr() net.Addr { return l.addr } + +type windowsVSockConn struct { + handle windows.Handle + local net.Addr + remote net.Addr + once sync.Once +} + +func (c *windowsVSockConn) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + buf := windows.WSABuf{Len: uint32(len(p)), Buf: &p[0]} + var received, flags uint32 + err := windows.WSARecv(c.handle, &buf, 1, &received, &flags, nil, nil) + runtime.KeepAlive(p) + if err != nil { + return int(received), err + } + if received == 0 { + return 0, io.EOF + } + return int(received), nil +} + +func (c *windowsVSockConn) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + buf := windows.WSABuf{Len: uint32(len(p)), Buf: &p[0]} + var sent uint32 + err := windows.WSASend(c.handle, &buf, 1, &sent, 0, nil, nil) + runtime.KeepAlive(p) + return int(sent), err +} + +func (c *windowsVSockConn) Close() error { + var err error + c.once.Do(func() { err = windows.Closesocket(c.handle) }) + return err +} +func (c *windowsVSockConn) LocalAddr() net.Addr { return c.local } +func (c *windowsVSockConn) RemoteAddr() net.Addr { return c.remote } +func (c *windowsVSockConn) SetDeadline(time.Time) error { return nil } +func (c *windowsVSockConn) SetReadDeadline(time.Time) error { return nil } +func (c *windowsVSockConn) SetWriteDeadline(time.Time) error { return nil } + +func defaultReadyFilePath() string { + return `C:\ProgramData\Hypeman\guest-agent-ready` +} + +var _ net.Listener = (*windowsVSockListener)(nil) +var _ net.Conn = (*windowsVSockConn)(nil) diff --git a/lib/system/guest_agent/main.go b/lib/system/guest_agent/main.go index 84fd2a5da..2a47926f1 100644 --- a/lib/system/guest_agent/main.go +++ b/lib/system/guest_agent/main.go @@ -3,20 +3,19 @@ package main import ( "fmt" "log" + "net" "os" "path/filepath" "strconv" "time" pb "github.com/kernel/hypeman/lib/guest" - "github.com/mdlayher/vsock" "google.golang.org/grpc" ) const ( - readySentinelPrefix = "HYPEMAN-AGENT-READY" - defaultReadyFilePath = "/run/hypeman/guest-agent-ready" - readyFDEnv = "HYPEMAN_AGENT_READY_FD" + readySentinelPrefix = "HYPEMAN-AGENT-READY" + readyFDEnv = "HYPEMAN_AGENT_READY_FD" ) // guestServer implements the gRPC GuestService @@ -25,12 +24,17 @@ type guestServer struct { } func main() { - // Listen on vsock port 2222 with retries - var l *vsock.Listener + if err := runPlatform(runGuestAgent); err != nil { + log.Fatalf("[guest-agent] failed: %v", err) + } +} + +func runGuestAgent() error { + var l net.Listener var err error for i := 0; i < 10; i++ { - l, err = vsock.Listen(2222, nil) + l, err = listenVSock(2222) if err == nil { break } @@ -39,7 +43,7 @@ func main() { } if err != nil { - log.Fatalf("[guest-agent] failed to listen on vsock port 2222 after retries: %v", err) + return fmt.Errorf("listen on vsock port 2222 after retries: %w", err) } defer l.Close() @@ -60,14 +64,15 @@ func main() { // Serve gRPC over vsock if err := grpcServer.Serve(l); err != nil { - log.Fatalf("[guest-agent] gRPC server failed: %v", err) + return fmt.Errorf("serve gRPC: %w", err) } + return nil } func writeReadyFile() error { path := os.Getenv("HYPEMAN_AGENT_READY_FILE") if path == "" { - path = defaultReadyFilePath + path = defaultReadyFilePath() } if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err diff --git a/lib/system/guest_agent/network.go b/lib/system/guest_agent/network_linux.go similarity index 99% rename from lib/system/guest_agent/network.go rename to lib/system/guest_agent/network_linux.go index 66b71bad8..3b01f5e53 100644 --- a/lib/system/guest_agent/network.go +++ b/lib/system/guest_agent/network_linux.go @@ -1,3 +1,5 @@ +//go:build linux + package main import ( diff --git a/lib/system/guest_agent/network_windows.go b/lib/system/guest_agent/network_windows.go new file mode 100644 index 000000000..9ebff6cb0 --- /dev/null +++ b/lib/system/guest_agent/network_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package main + +import ( + "context" + + pb "github.com/kernel/hypeman/lib/guest" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (s *guestServer) ReconfigureNetwork(context.Context, *pb.ReconfigureNetworkRequest) (*pb.ReconfigureNetworkResponse, error) { + return nil, status.Error(codes.Unimplemented, "Windows network reconfiguration is not available") +} diff --git a/lib/system/guest_agent/ownership_unix.go b/lib/system/guest_agent/ownership_unix.go new file mode 100644 index 000000000..19174cc70 --- /dev/null +++ b/lib/system/guest_agent/ownership_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package main + +import ( + "os" + "syscall" +) + +func setFileOwnership(path string, uid, gid uint32) error { + return os.Chown(path, int(uid), int(gid)) +} + +func fileOwnership(info os.FileInfo) (uint32, uint32) { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + return stat.Uid, stat.Gid + } + return 0, 0 +} diff --git a/lib/system/guest_agent/ownership_windows.go b/lib/system/guest_agent/ownership_windows.go new file mode 100644 index 000000000..1376d0fe1 --- /dev/null +++ b/lib/system/guest_agent/ownership_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package main + +import "os" + +func setFileOwnership(string, uint32, uint32) error { return nil } +func fileOwnership(os.FileInfo) (uint32, uint32) { return 0, 0 } diff --git a/lib/system/guest_agent/process_job_windows.go b/lib/system/guest_agent/process_job_windows.go new file mode 100644 index 000000000..ce75d8b64 --- /dev/null +++ b/lib/system/guest_agent/process_job_windows.go @@ -0,0 +1,116 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "os" + "sync" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +func attachProcessJob(ctx context.Context, process *os.Process, timeout time.Duration) (func(), error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, fmt.Errorf("create job object: %w", err) + } + + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + windows.CloseHandle(job) + return nil, fmt.Errorf("configure job object: %w", err) + } + + processHandle, err := windows.OpenProcess( + windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, + false, + uint32(process.Pid), + ) + if err != nil { + windows.CloseHandle(job) + return nil, fmt.Errorf("open process for job assignment: %w", err) + } + defer windows.CloseHandle(processHandle) + if err := windows.AssignProcessToJobObject(job, processHandle); err != nil { + windows.CloseHandle(job) + return nil, fmt.Errorf("assign process to job object: %w", err) + } + if err := resumeProcess(process.Pid); err != nil { + windows.CloseHandle(job) + return nil, err + } + + done := make(chan struct{}) + var doneOnce sync.Once + var terminateOnce sync.Once + terminateJob := func() { + terminateOnce.Do(func() { + _ = windows.TerminateJobObject(job, 124) + _ = windows.CloseHandle(job) + }) + } + cleanup := func() { + doneOnce.Do(func() { close(done) }) + terminateJob() + } + go func() { + var timeoutC <-chan time.Time + if timeout > 0 { + timer := time.NewTimer(timeout) + defer timer.Stop() + timeoutC = timer.C + } + select { + case <-ctx.Done(): + terminateJob() + case <-timeoutC: + terminateJob() + case <-done: + } + }() + return cleanup, nil +} + +func resumeProcess(pid int) error { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) + if err != nil { + return fmt.Errorf("list suspended process threads: %w", err) + } + defer windows.CloseHandle(snapshot) + + entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))} + if err := windows.Thread32First(snapshot, &entry); err != nil { + return fmt.Errorf("read suspended process threads: %w", err) + } + for { + if entry.OwnerProcessID == uint32(pid) { + thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID) + if err != nil { + return fmt.Errorf("open suspended process thread: %w", err) + } + _, resumeErr := windows.ResumeThread(thread) + windows.CloseHandle(thread) + if resumeErr != nil { + return fmt.Errorf("resume process thread: %w", resumeErr) + } + return nil + } + if err := windows.Thread32Next(snapshot, &entry); err != nil { + if err == windows.ERROR_NO_MORE_FILES { + break + } + return fmt.Errorf("read suspended process threads: %w", err) + } + } + return fmt.Errorf("suspended process thread not found") +} diff --git a/lib/system/guest_agent/service_unix.go b/lib/system/guest_agent/service_unix.go new file mode 100644 index 000000000..968d220da --- /dev/null +++ b/lib/system/guest_agent/service_unix.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func runPlatform(run func() error) error { return run() } diff --git a/lib/system/guest_agent/service_windows.go b/lib/system/guest_agent/service_windows.go new file mode 100644 index 000000000..b2133de64 --- /dev/null +++ b/lib/system/guest_agent/service_windows.go @@ -0,0 +1,52 @@ +//go:build windows + +package main + +import ( + "fmt" + + "golang.org/x/sys/windows/svc" +) + +const windowsServiceName = "HypemanGuestAgent" + +func runPlatform(run func() error) error { + isService, err := svc.IsWindowsService() + if err != nil { + return fmt.Errorf("detect Windows service: %w", err) + } + if !isService { + return run() + } + return svc.Run(windowsServiceName, &guestAgentService{run: run}) +} + +type guestAgentService struct { + run func() error +} + +func (s *guestAgentService) Execute(_ []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + const accepted = svc.AcceptStop | svc.AcceptShutdown + status <- svc.Status{State: svc.StartPending} + errCh := make(chan error, 1) + go func() { errCh <- s.run() }() + status <- svc.Status{State: svc.Running, Accepts: accepted} + + for { + select { + case err := <-errCh: + if err != nil { + return true, 1 + } + return false, 0 + case req := <-requests: + switch req.Cmd { + case svc.Interrogate: + status <- req.CurrentStatus + case svc.Stop, svc.Shutdown: + status <- svc.Status{State: svc.StopPending} + return false, 0 + } + } + } +} diff --git a/lib/system/guest_agent/shutdown.go b/lib/system/guest_agent/shutdown_linux.go similarity index 97% rename from lib/system/guest_agent/shutdown.go rename to lib/system/guest_agent/shutdown_linux.go index e29690aef..5eed220cb 100644 --- a/lib/system/guest_agent/shutdown.go +++ b/lib/system/guest_agent/shutdown_linux.go @@ -1,3 +1,5 @@ +//go:build linux + package main import ( diff --git a/lib/system/guest_agent/shutdown_windows.go b/lib/system/guest_agent/shutdown_windows.go new file mode 100644 index 000000000..6dcbe4275 --- /dev/null +++ b/lib/system/guest_agent/shutdown_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "log" + "os/exec" + + pb "github.com/kernel/hypeman/lib/guest" +) + +func (s *guestServer) Shutdown(context.Context, *pb.ShutdownRequest) (*pb.ShutdownResponse, error) { + log.Printf("[guest-agent] Windows shutdown requested") + if err := exec.Command("shutdown.exe", "/s", "/t", "0", "/d", "p:0:0").Start(); err != nil { + return nil, fmt.Errorf("start Windows shutdown: %w", err) + } + return &pb.ShutdownResponse{}, nil +}