diff --git a/.cirrus.yml b/.cirrus.yml index e1fd151..249b427 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -35,6 +35,30 @@ task: prepare_script: brew install go test_script: go test -v ./... +task: + name: Check for lacking "buf generate" invocation + + container: + image: golang:latest + + install_buf_script: go install github.com/bufbuild/buf/cmd/buf@v1.50.0 + generate_script: buf generate + check_script: git diff --exit-code + +task: + only_if: $CIRRUS_BRANCH != '' + name: buf push + + container: + image: bufbuild/buf + + login_script: echo $BUF_TOKEN | buf registry login --username $BUF_LOGIN --token-stdin + push_script: buf push --git-metadata + + env: + BUF_LOGIN: fkorotkov + BUF_TOKEN: ENCRYPTED[!8ee7eb2504cc84b08d4a7c0dacbe103640b1feaa26d06f0df010784e872d39e65a0cdea3fc7c09b065a917a77113b96b!] + task: name: Release (Dry Run) only_if: $CIRRUS_TAG == '' diff --git a/README.md b/README.md index 7affa02..2b63d3e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,17 @@ Currently implemented features: * Automatic disk resizing for macOS VMs with recovery partition removed (`--resize-disk`) * needs to be invoked as a launchd [global daemon](https://launchd.info/) - * example usage: [`tart-guest-daemon.plist`](https://github.com/cirruslabs/macos-image-templates/blob/main/data/tart-guest-daemon.plist) * Clipboard sharing for macOS VMs using our in-house SPICE vdagent implementation (`--run-vdagent`) * needs to be invoked as a launchd [global agent](https://launchd.info/) +* `tart exec` support (`--run-rpc`) + * it's recommended to invoke it as a launchd [global agent](https://launchd.info/) because fewer privileges will be available to commands started via `tart exec` + * however, you can also invoke it as a launchd [global daemon](https://launchd.info/) if running commands started via `tart exec` as `root` is desired + +To run all features appropriate for a given context, use component groups: + +* `--run-daemon` + * implies `--resize-disk` + * example usage: [`tart-guest-daemon.plist`](https://github.com/cirruslabs/macos-image-templates/blob/main/data/tart-guest-daemon.plist) +* `--run-agent` + * implies `--run-vdagent --run-rpc` * example usage: [`tart-guest-agent.plist`](https://github.com/cirruslabs/macos-image-templates/blob/main/data/tart-guest-agent.plist) diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..e78fd37 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,8 @@ +version: v2 +plugins: + - remote: buf.build/protocolbuffers/go:v1.36.6 + out: internal/ + opt: paths=source_relative + - remote: buf.build/grpc/go:v1.5.1 + out: internal/ + opt: paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..86abc9e --- /dev/null +++ b/buf.yaml @@ -0,0 +1,10 @@ +version: v2 +modules: + - path: proto/ + name: buf.build/cirruslabs/tart-guest-agent +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/go.mod b/go.mod index 35491c0..dbad034 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/cirruslabs/tart-guest-agent go 1.23.7 require ( + github.com/creack/pty v1.1.24 github.com/hashicorp/go-version v1.7.0 github.com/samber/lo v1.49.1 github.com/spf13/cobra v1.9.1 @@ -10,6 +11,8 @@ require ( go.uber.org/zap v1.27.0 golang.design/x/clipboard v0.7.0 golang.org/x/sys v0.32.0 + google.golang.org/grpc v1.72.1 + google.golang.org/protobuf v1.36.6 howett.net/plist v1.0.1 ) @@ -22,6 +25,8 @@ require ( golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect golang.org/x/image v0.6.0 // indirect golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8510d93..103db62 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,19 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -23,6 +35,18 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= @@ -51,6 +75,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -71,14 +97,20 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/command/root.go b/internal/command/root.go index 3fd43f7..62487f5 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -2,11 +2,14 @@ package command import ( "errors" + "fmt" "github.com/cirruslabs/tart-guest-agent/internal/diskresizer" "github.com/cirruslabs/tart-guest-agent/internal/logginglevel" + "github.com/cirruslabs/tart-guest-agent/internal/rpc" "github.com/cirruslabs/tart-guest-agent/internal/spice/vdagent" "github.com/cirruslabs/tart-guest-agent/internal/tart" "github.com/cirruslabs/tart-guest-agent/internal/version" + "github.com/cirruslabs/tart-guest-agent/internal/vsock" "github.com/spf13/cobra" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -17,6 +20,11 @@ import ( var resizeDisk bool var runVdagent bool +var runRPC bool + +var runDaemon bool +var runAgent bool + var debug bool func NewRootCommand() *cobra.Command { @@ -36,8 +44,17 @@ func NewRootCommand() *cobra.Command { RunE: run, } + // Individual components cmd.Flags().BoolVar(&resizeDisk, "resize-disk", false, "resize disk") cmd.Flags().BoolVar(&runVdagent, "run-vdagent", false, "run vdagent") + cmd.Flags().BoolVar(&runRPC, "run-rpc", false, "run RPC service (currently required "+ + "to support \"tart exec\" functionality)") + + // Component groups + cmd.Flags().BoolVar(&runDaemon, "run-daemon", false, "identical to running the agent"+ + "with \"--resize-disk\" command-line argument") + cmd.Flags().BoolVar(&runAgent, "run-agent", false, "identical to running the agent "+ + "with \"--run-vdagent\" and \"--run-rpc\" command-line arguments") cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging") @@ -45,6 +62,16 @@ func NewRootCommand() *cobra.Command { } func run(cmd *cobra.Command, args []string) error { + // Component groups automatically enable certain individual components + if runDaemon { + resizeDisk = true + } + + if runAgent { + runVdagent = true + runRPC = true + } + // Terminate to prevent corruption on systems with disk layouts other than Tart's communicationPoint, ok := tart.LocateCommunicationPoint() if !ok { @@ -56,6 +83,8 @@ func run(cmd *cobra.Command, args []string) error { // Perform disk resizing if resizeDisk { + zap.S().Info("attempting to resize disk...") + if err := diskresizer.Resize(); err != nil { if errors.Is(err, diskresizer.ErrUnsupported) || errors.Is(err, diskresizer.ErrAlreadyResized) { zap.S().Infof("skipping disk resizing: %v", err) @@ -68,14 +97,38 @@ func run(cmd *cobra.Command, args []string) error { } if runVdagent { + zap.S().Infof("running vdagent...") + vdAgent, err := vdagent.New() if err != nil { return err } - if err := vdAgent.Run(cmd.Context()); err != nil { + go func() { + if err := vdAgent.Run(cmd.Context()); err != nil { + zap.S().Fatalf("vdagent failed: %v", err) + } + }() + } + + if runRPC { + listener, err := vsock.Listen(8080) + if err != nil { + return fmt.Errorf("failed to listen on AF_VSOCK port 8080: %v", err) + } + + zap.S().Info("running RPC server on AF_VSOCK port 8080...") + + rpcServer, err := rpc.New(listener) + if err != nil { return err } + + go func() { + if err := rpcServer.Run(); err != nil { + zap.S().Fatalf("RPC server failed: %v", err) + } + }() } // Wait indefinitely diff --git a/internal/rpc/agent.pb.go b/internal/rpc/agent.pb.go new file mode 100644 index 0000000..9fce189 --- /dev/null +++ b/internal/rpc/agent.pb.go @@ -0,0 +1,538 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: rpc/agent.proto + +package rpc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + _ "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ExecRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Type: + // + // *ExecRequest_Command_ + // *ExecRequest_StandardInput + // *ExecRequest_TerminalResize + Type isExecRequest_Type `protobuf_oneof:"type"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRequest) Reset() { + *x = ExecRequest{} + mi := &file_rpc_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRequest) ProtoMessage() {} + +func (x *ExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_rpc_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead. +func (*ExecRequest) Descriptor() ([]byte, []int) { + return file_rpc_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *ExecRequest) GetType() isExecRequest_Type { + if x != nil { + return x.Type + } + return nil +} + +func (x *ExecRequest) GetCommand() *ExecRequest_Command { + if x != nil { + if x, ok := x.Type.(*ExecRequest_Command_); ok { + return x.Command + } + } + return nil +} + +func (x *ExecRequest) GetStandardInput() *IOChunk { + if x != nil { + if x, ok := x.Type.(*ExecRequest_StandardInput); ok { + return x.StandardInput + } + } + return nil +} + +func (x *ExecRequest) GetTerminalResize() *TerminalSize { + if x != nil { + if x, ok := x.Type.(*ExecRequest_TerminalResize); ok { + return x.TerminalResize + } + } + return nil +} + +type isExecRequest_Type interface { + isExecRequest_Type() +} + +type ExecRequest_Command_ struct { + Command *ExecRequest_Command `protobuf:"bytes,1,opt,name=command,proto3,oneof"` +} + +type ExecRequest_StandardInput struct { + StandardInput *IOChunk `protobuf:"bytes,2,opt,name=standard_input,json=standardInput,proto3,oneof"` +} + +type ExecRequest_TerminalResize struct { + TerminalResize *TerminalSize `protobuf:"bytes,3,opt,name=terminal_resize,json=terminalResize,proto3,oneof"` +} + +func (*ExecRequest_Command_) isExecRequest_Type() {} + +func (*ExecRequest_StandardInput) isExecRequest_Type() {} + +func (*ExecRequest_TerminalResize) isExecRequest_Type() {} + +type ExecResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Type: + // + // *ExecResponse_Exit_ + // *ExecResponse_StandardOutput + // *ExecResponse_StandardError + Type isExecResponse_Type `protobuf_oneof:"type"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecResponse) Reset() { + *x = ExecResponse{} + mi := &file_rpc_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecResponse) ProtoMessage() {} + +func (x *ExecResponse) ProtoReflect() protoreflect.Message { + mi := &file_rpc_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead. +func (*ExecResponse) Descriptor() ([]byte, []int) { + return file_rpc_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *ExecResponse) GetType() isExecResponse_Type { + if x != nil { + return x.Type + } + return nil +} + +func (x *ExecResponse) GetExit() *ExecResponse_Exit { + if x != nil { + if x, ok := x.Type.(*ExecResponse_Exit_); ok { + return x.Exit + } + } + return nil +} + +func (x *ExecResponse) GetStandardOutput() *IOChunk { + if x != nil { + if x, ok := x.Type.(*ExecResponse_StandardOutput); ok { + return x.StandardOutput + } + } + return nil +} + +func (x *ExecResponse) GetStandardError() *IOChunk { + if x != nil { + if x, ok := x.Type.(*ExecResponse_StandardError); ok { + return x.StandardError + } + } + return nil +} + +type isExecResponse_Type interface { + isExecResponse_Type() +} + +type ExecResponse_Exit_ struct { + Exit *ExecResponse_Exit `protobuf:"bytes,1,opt,name=exit,proto3,oneof"` +} + +type ExecResponse_StandardOutput struct { + StandardOutput *IOChunk `protobuf:"bytes,2,opt,name=standard_output,json=standardOutput,proto3,oneof"` +} + +type ExecResponse_StandardError struct { + StandardError *IOChunk `protobuf:"bytes,3,opt,name=standard_error,json=standardError,proto3,oneof"` +} + +func (*ExecResponse_Exit_) isExecResponse_Type() {} + +func (*ExecResponse_StandardOutput) isExecResponse_Type() {} + +func (*ExecResponse_StandardError) isExecResponse_Type() {} + +type TerminalSize struct { + state protoimpl.MessageState `protogen:"open.v1"` + Rows uint32 `protobuf:"varint,1,opt,name=rows,proto3" json:"rows,omitempty"` + Cols uint32 `protobuf:"varint,2,opt,name=cols,proto3" json:"cols,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminalSize) Reset() { + *x = TerminalSize{} + mi := &file_rpc_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminalSize) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminalSize) ProtoMessage() {} + +func (x *TerminalSize) ProtoReflect() protoreflect.Message { + mi := &file_rpc_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminalSize.ProtoReflect.Descriptor instead. +func (*TerminalSize) Descriptor() ([]byte, []int) { + return file_rpc_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *TerminalSize) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +func (x *TerminalSize) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +type IOChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IOChunk) Reset() { + *x = IOChunk{} + mi := &file_rpc_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IOChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IOChunk) ProtoMessage() {} + +func (x *IOChunk) ProtoReflect() protoreflect.Message { + mi := &file_rpc_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IOChunk.ProtoReflect.Descriptor instead. +func (*IOChunk) Descriptor() ([]byte, []int) { + return file_rpc_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *IOChunk) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type ExecRequest_Command struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Interactive bool `protobuf:"varint,3,opt,name=interactive,proto3" json:"interactive,omitempty"` + Tty bool `protobuf:"varint,4,opt,name=tty,proto3" json:"tty,omitempty"` + TerminalSize *TerminalSize `protobuf:"bytes,5,opt,name=terminal_size,json=terminalSize,proto3" json:"terminal_size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRequest_Command) Reset() { + *x = ExecRequest_Command{} + mi := &file_rpc_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRequest_Command) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRequest_Command) ProtoMessage() {} + +func (x *ExecRequest_Command) ProtoReflect() protoreflect.Message { + mi := &file_rpc_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRequest_Command.ProtoReflect.Descriptor instead. +func (*ExecRequest_Command) Descriptor() ([]byte, []int) { + return file_rpc_agent_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *ExecRequest_Command) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ExecRequest_Command) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ExecRequest_Command) GetInteractive() bool { + if x != nil { + return x.Interactive + } + return false +} + +func (x *ExecRequest_Command) GetTty() bool { + if x != nil { + return x.Tty + } + return false +} + +func (x *ExecRequest_Command) GetTerminalSize() *TerminalSize { + if x != nil { + return x.TerminalSize + } + return nil +} + +type ExecResponse_Exit struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecResponse_Exit) Reset() { + *x = ExecResponse_Exit{} + mi := &file_rpc_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecResponse_Exit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecResponse_Exit) ProtoMessage() {} + +func (x *ExecResponse_Exit) ProtoReflect() protoreflect.Message { + mi := &file_rpc_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecResponse_Exit.ProtoReflect.Descriptor instead. +func (*ExecResponse_Exit) Descriptor() ([]byte, []int) { + return file_rpc_agent_proto_rawDescGZIP(), []int{1, 0} +} + +func (x *ExecResponse_Exit) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +var File_rpc_agent_proto protoreflect.FileDescriptor + +const file_rpc_agent_proto_rawDesc = "" + + "\n" + + "\x0frpc/agent.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xd0\x02\n" + + "\vExecRequest\x120\n" + + "\acommand\x18\x01 \x01(\v2\x14.ExecRequest.CommandH\x00R\acommand\x121\n" + + "\x0estandard_input\x18\x02 \x01(\v2\b.IOChunkH\x00R\rstandardInput\x128\n" + + "\x0fterminal_resize\x18\x03 \x01(\v2\r.TerminalSizeH\x00R\x0eterminalResize\x1a\x99\x01\n" + + "\aCommand\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04args\x18\x02 \x03(\tR\x04args\x12 \n" + + "\vinteractive\x18\x03 \x01(\bR\vinteractive\x12\x10\n" + + "\x03tty\x18\x04 \x01(\bR\x03tty\x122\n" + + "\rterminal_size\x18\x05 \x01(\v2\r.TerminalSizeR\fterminalSizeB\x06\n" + + "\x04type\"\xc4\x01\n" + + "\fExecResponse\x12(\n" + + "\x04exit\x18\x01 \x01(\v2\x12.ExecResponse.ExitH\x00R\x04exit\x123\n" + + "\x0fstandard_output\x18\x02 \x01(\v2\b.IOChunkH\x00R\x0estandardOutput\x121\n" + + "\x0estandard_error\x18\x03 \x01(\v2\b.IOChunkH\x00R\rstandardError\x1a\x1a\n" + + "\x04Exit\x12\x12\n" + + "\x04code\x18\x01 \x01(\x05R\x04codeB\x06\n" + + "\x04type\"6\n" + + "\fTerminalSize\x12\x12\n" + + "\x04rows\x18\x01 \x01(\rR\x04rows\x12\x12\n" + + "\x04cols\x18\x02 \x01(\rR\x04cols\"\x1d\n" + + "\aIOChunk\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data20\n" + + "\x05Agent\x12'\n" + + "\x04Exec\x12\f.ExecRequest\x1a\r.ExecResponse(\x010\x01B5Z3github.com/cirruslabs/tart-guest-agent/internal/rpcb\x06proto3" + +var ( + file_rpc_agent_proto_rawDescOnce sync.Once + file_rpc_agent_proto_rawDescData []byte +) + +func file_rpc_agent_proto_rawDescGZIP() []byte { + file_rpc_agent_proto_rawDescOnce.Do(func() { + file_rpc_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_rpc_agent_proto_rawDesc), len(file_rpc_agent_proto_rawDesc))) + }) + return file_rpc_agent_proto_rawDescData +} + +var file_rpc_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_rpc_agent_proto_goTypes = []any{ + (*ExecRequest)(nil), // 0: ExecRequest + (*ExecResponse)(nil), // 1: ExecResponse + (*TerminalSize)(nil), // 2: TerminalSize + (*IOChunk)(nil), // 3: IOChunk + (*ExecRequest_Command)(nil), // 4: ExecRequest.Command + (*ExecResponse_Exit)(nil), // 5: ExecResponse.Exit +} +var file_rpc_agent_proto_depIdxs = []int32{ + 4, // 0: ExecRequest.command:type_name -> ExecRequest.Command + 3, // 1: ExecRequest.standard_input:type_name -> IOChunk + 2, // 2: ExecRequest.terminal_resize:type_name -> TerminalSize + 5, // 3: ExecResponse.exit:type_name -> ExecResponse.Exit + 3, // 4: ExecResponse.standard_output:type_name -> IOChunk + 3, // 5: ExecResponse.standard_error:type_name -> IOChunk + 2, // 6: ExecRequest.Command.terminal_size:type_name -> TerminalSize + 0, // 7: Agent.Exec:input_type -> ExecRequest + 1, // 8: Agent.Exec:output_type -> ExecResponse + 8, // [8:9] is the sub-list for method output_type + 7, // [7:8] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name +} + +func init() { file_rpc_agent_proto_init() } +func file_rpc_agent_proto_init() { + if File_rpc_agent_proto != nil { + return + } + file_rpc_agent_proto_msgTypes[0].OneofWrappers = []any{ + (*ExecRequest_Command_)(nil), + (*ExecRequest_StandardInput)(nil), + (*ExecRequest_TerminalResize)(nil), + } + file_rpc_agent_proto_msgTypes[1].OneofWrappers = []any{ + (*ExecResponse_Exit_)(nil), + (*ExecResponse_StandardOutput)(nil), + (*ExecResponse_StandardError)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_agent_proto_rawDesc), len(file_rpc_agent_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_rpc_agent_proto_goTypes, + DependencyIndexes: file_rpc_agent_proto_depIdxs, + MessageInfos: file_rpc_agent_proto_msgTypes, + }.Build() + File_rpc_agent_proto = out.File + file_rpc_agent_proto_goTypes = nil + file_rpc_agent_proto_depIdxs = nil +} diff --git a/internal/rpc/agent_grpc.pb.go b/internal/rpc/agent_grpc.pb.go new file mode 100644 index 0000000..08e15d9 --- /dev/null +++ b/internal/rpc/agent_grpc.pb.go @@ -0,0 +1,115 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: rpc/agent.proto + +package rpc + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Agent_Exec_FullMethodName = "/Agent/Exec" +) + +// AgentClient is the client API for Agent service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AgentClient interface { + Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecRequest, ExecResponse], error) +} + +type agentClient struct { + cc grpc.ClientConnInterface +} + +func NewAgentClient(cc grpc.ClientConnInterface) AgentClient { + return &agentClient{cc} +} + +func (c *agentClient) Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecRequest, ExecResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Agent_ServiceDesc.Streams[0], Agent_Exec_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecRequest, ExecResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Agent_ExecClient = grpc.BidiStreamingClient[ExecRequest, ExecResponse] + +// AgentServer is the server API for Agent service. +// All implementations must embed UnimplementedAgentServer +// for forward compatibility. +type AgentServer interface { + Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error + mustEmbedUnimplementedAgentServer() +} + +// UnimplementedAgentServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAgentServer struct{} + +func (UnimplementedAgentServer) Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error { + return status.Errorf(codes.Unimplemented, "method Exec not implemented") +} +func (UnimplementedAgentServer) mustEmbedUnimplementedAgentServer() {} +func (UnimplementedAgentServer) testEmbeddedByValue() {} + +// UnsafeAgentServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AgentServer will +// result in compilation errors. +type UnsafeAgentServer interface { + mustEmbedUnimplementedAgentServer() +} + +func RegisterAgentServer(s grpc.ServiceRegistrar, srv AgentServer) { + // If the following call pancis, it indicates UnimplementedAgentServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Agent_ServiceDesc, srv) +} + +func _Agent_Exec_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(AgentServer).Exec(&grpc.GenericServerStream[ExecRequest, ExecResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Agent_ExecServer = grpc.BidiStreamingServer[ExecRequest, ExecResponse] + +// Agent_ServiceDesc is the grpc.ServiceDesc for Agent service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Agent_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "Agent", + HandlerType: (*AgentServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Exec", + Handler: _Agent_Exec_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "rpc/agent.proto", +} diff --git a/internal/rpc/exec.go b/internal/rpc/exec.go new file mode 100644 index 0000000..55f2d15 --- /dev/null +++ b/internal/rpc/exec.go @@ -0,0 +1,259 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "github.com/creack/pty" + "github.com/samber/lo" + "go.uber.org/zap" + "google.golang.org/grpc" + "io" + "os" + "os/exec" + "slices" + "strings" +) + +const ( + standardStreamsBufferSize = 4096 + + eofChar = 0x04 +) + +type standardStreamOutput struct { + Data []byte + Err error +} + +func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error { + // Read the first exec request, it should describe a command to execute + firstExecRequest, err := stream.Recv() + if err != nil { + return err + } + firstExecRequestCommand, ok := firstExecRequest.Type.(*ExecRequest_Command_) + if !ok { + return fmt.Errorf("first exec request should describe a command to execute") + } + + zap.S().Infof("executing %s", formatCommandAndArgs(firstExecRequestCommand.Command.Name, + firstExecRequestCommand.Command.Args)) + + // Execute the command + cmd := exec.CommandContext(stream.Context(), firstExecRequestCommand.Command.Name, + firstExecRequestCommand.Command.Args...) + + var stdin io.WriteCloser + var stdout, stderr io.ReadCloser + var ptmx *os.File + + if firstExecRequestCommand.Command.Tty { + ptmx, err = pty.StartWithSize(cmd, &pty.Winsize{ + Rows: uint16(firstExecRequestCommand.Command.GetTerminalSize().GetRows()), + Cols: uint16(firstExecRequestCommand.Command.GetTerminalSize().GetCols()), + }) + + if firstExecRequestCommand.Command.Interactive { + stdin = ptmx + } + stdout = ptmx + stderr = ptmx + } else { + if firstExecRequestCommand.Command.Interactive { + stdin, err = cmd.StdinPipe() + if err != nil { + return err + } + } + + stdout, err = cmd.StdoutPipe() + if err != nil { + return err + } + + stderr, err = cmd.StderrPipe() + if err != nil { + return err + } + + err = cmd.Start() + } + if err != nil { + return err + } + if ptmx != nil { + defer ptmx.Close() + } + + // Handle standard input and terminal resize events from the client + fromClientErrCh := make(chan error, 1) + + go func() { + for { + request, err := stream.Recv() + if err != nil { + if !errors.Is(err, context.Canceled) { + fromClientErrCh <- err + } + + return + } + + switch typedAction := request.Type.(type) { + case *ExecRequest_StandardInput: + if !firstExecRequestCommand.Command.Interactive { + // Ignore standard input from the client + // as non-interactive command is running + continue + } + + dataToWrite := typedAction.StandardInput.Data + + // Check if the remote client has received EOF on their standard input + if len(typedAction.StandardInput.Data) == 0 { + if firstExecRequestCommand.Command.Tty { + // When using pseudo-terminal, we can't simply close the + // standard input, as the file descriptor is shared for + // standard output and standard error too, so we send + // an EOF character instead + dataToWrite = []byte{eofChar} + } else { + // Close the standard input + if err := stdin.Close(); err != nil { + fromClientErrCh <- err + + return + } + + continue + } + } + + if _, err := stdin.Write(dataToWrite); err != nil { + fromClientErrCh <- err + + return + } + case *ExecRequest_TerminalResize: + // Ignore terminal resize requests + // when pseudo terminal is disabled + if !firstExecRequestCommand.Command.Tty { + continue + } + + if err := pty.Setsize(ptmx, &pty.Winsize{ + Rows: uint16(typedAction.TerminalResize.GetRows()), + Cols: uint16(typedAction.TerminalResize.GetCols()), + }); err != nil { + fromClientErrCh <- err + + return + } + } + } + }() + + // Handle standard output from the command + stdoutOutputCh := make(chan *standardStreamOutput, 1) + + go streamStandardStream(stdout, stdoutOutputCh) + + // Handle standard error from the command + // + // Note that it makes no sense to handle standard error when TTY is requested + // because in this case stdout and stderr will point to the same file descriptor + stderrOutputCh := make(chan *standardStreamOutput, 1) + + if !firstExecRequestCommand.Command.Tty { + go streamStandardStream(stderr, stderrOutputCh) + } + + // Wait for the command to finish + commandErrCh := make(chan error, 1) + go func() { + commandErrCh <- cmd.Wait() + }() + + for { + select { + case stdoutOutput := <-stdoutOutputCh: + if err := stdoutOutput.Err; err != nil { + return err + } + + if err := stream.Send(&ExecResponse{ + Type: &ExecResponse_StandardOutput{ + StandardOutput: &IOChunk{ + Data: stdoutOutput.Data, + }, + }, + }); err != nil { + return err + } + case stderrOutput := <-stderrOutputCh: + if err := stderrOutput.Err; err != nil { + return err + } + + if err := stream.Send(&ExecResponse{ + Type: &ExecResponse_StandardError{ + StandardError: &IOChunk{ + Data: stderrOutput.Data, + }, + }, + }); err != nil { + return err + } + case commandErr := <-commandErrCh: + exitCode := 0 + + var exitError *exec.ExitError + if errors.As(commandErr, &exitError) { + exitCode = exitError.ExitCode() + } + + return stream.Send(&ExecResponse{ + Type: &ExecResponse_Exit_{ + Exit: &ExecResponse_Exit{ + Code: int32(exitCode), + }, + }, + }) + } + } +} + +func formatCommandAndArgs(name string, args []string) string { + var all []string + + all = append(all, name) + all = append(all, args...) + + all = lo.Map(all, func(item string, _ int) string { + return fmt.Sprintf("%q", item) + }) + + return fmt.Sprintf("[%s]", strings.Join(all, ", ")) +} + +func streamStandardStream(standardStream io.Reader, outputCh chan *standardStreamOutput) { + buf := make([]byte, standardStreamsBufferSize) + + for { + n, err := standardStream.Read(buf) + if err != nil { + if !errors.Is(err, io.EOF) { + outputCh <- &standardStreamOutput{ + Err: err, + } + } + + return + } + + outputCh <- &standardStreamOutput{ + Data: slices.Clone(buf[:n]), + } + } +} diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go new file mode 100644 index 0000000..78b0dd8 --- /dev/null +++ b/internal/rpc/rpc.go @@ -0,0 +1,28 @@ +package rpc + +import ( + "google.golang.org/grpc" + "net" +) + +type RPC struct { + grpcServer *grpc.Server + listener net.Listener + + UnimplementedAgentServer +} + +func New(listener net.Listener) (*RPC, error) { + rpc := &RPC{ + grpcServer: grpc.NewServer(), + listener: listener, + } + + RegisterAgentServer(rpc.grpcServer, rpc) + + return rpc, nil +} + +func (rpc *RPC) Run() error { + return rpc.grpcServer.Serve(rpc.listener) +} diff --git a/internal/vsock/addr.go b/internal/vsock/addr.go new file mode 100644 index 0000000..658a566 --- /dev/null +++ b/internal/vsock/addr.go @@ -0,0 +1,15 @@ +package vsock + +import "fmt" + +type addr struct { + port uint32 +} + +func (addr *addr) Network() string { + return "vsock" +} + +func (addr *addr) String() string { + return fmt.Sprintf("%d", addr.port) +} diff --git a/internal/vsock/conn.go b/internal/vsock/conn.go new file mode 100644 index 0000000..fec9585 --- /dev/null +++ b/internal/vsock/conn.go @@ -0,0 +1,45 @@ +package vsock + +import ( + "net" + "os" + "time" +) + +type conn struct { + file *os.File + localPort uint32 + remotePort uint32 +} + +func (conn *conn) Read(b []byte) (n int, err error) { + return conn.file.Read(b) +} + +func (conn *conn) Write(b []byte) (n int, err error) { + return conn.file.Write(b) +} + +func (conn *conn) SetDeadline(t time.Time) error { + return conn.file.SetDeadline(t) +} + +func (conn *conn) SetReadDeadline(t time.Time) error { + return conn.file.SetReadDeadline(t) +} + +func (conn *conn) SetWriteDeadline(t time.Time) error { + return conn.file.SetWriteDeadline(t) +} + +func (conn *conn) LocalAddr() net.Addr { + return &addr{port: conn.localPort} +} + +func (conn *conn) RemoteAddr() net.Addr { + return &addr{port: conn.remotePort} +} + +func (conn *conn) Close() error { + return conn.file.Close() +} diff --git a/internal/vsock/listener.go b/internal/vsock/listener.go new file mode 100644 index 0000000..10e469c --- /dev/null +++ b/internal/vsock/listener.go @@ -0,0 +1,71 @@ +package vsock + +import ( + "fmt" + "golang.org/x/sys/unix" + "net" + "os" +) + +type listener struct { + file *os.File + port uint32 +} + +func Listen(port uint32) (net.Listener, error) { + fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM, 0) + if err != nil { + return nil, err + } + + file := os.NewFile(uintptr(fd), "vsock") + + if err := unix.Bind(int(file.Fd()), &unix.SockaddrVM{ + CID: unix.VMADDR_CID_ANY, + Port: port, + }); err != nil { + return nil, err + } + + if err := unix.Listen(int(file.Fd()), unix.SOMAXCONN); err != nil { + return nil, err + } + + return &listener{ + file: file, + port: port, + }, nil +} + +func (listener *listener) Accept() (net.Conn, error) { + fd, _, err := unix.Accept(int(listener.file.Fd())) + if err != nil { + return nil, err + } + + file := os.NewFile(uintptr(fd), "vsock") + + peerName, err := unix.Getpeername(int(file.Fd())) + if err != nil { + return nil, fmt.Errorf("failed to get a peer name for an AF_VSOCK connection %w", err) + } + + peerNameVM, ok := peerName.(*unix.SockaddrVM) + if !ok { + return nil, fmt.Errorf("accepted a non-AF_VSOCK connection on an AF_VSOCK socket") + } + + return &conn{ + file: file, + localPort: listener.port, + remotePort: peerNameVM.Port, + }, nil +} + +func (listener *listener) Addr() net.Addr { + return &addr{port: listener.port} +} + +func (listener *listener) Close() error { + return listener.file.Close() +} diff --git a/proto/rpc/agent.proto b/proto/rpc/agent.proto new file mode 100644 index 0000000..4c2dc1b --- /dev/null +++ b/proto/rpc/agent.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +option go_package = "github.com/cirruslabs/tart-guest-agent/internal/rpc"; + +service Agent { + rpc Exec(stream ExecRequest) returns (stream ExecResponse); +} + +message ExecRequest { + message Command { + string name = 1; + repeated string args = 2; + bool interactive = 3; + bool tty = 4; + TerminalSize terminal_size = 5; + } + + oneof type { + Command command = 1; + IOChunk standard_input = 2; + TerminalSize terminal_resize = 3; + } +} + +message ExecResponse { + message Exit { + int32 code = 1; + } + + oneof type { + Exit exit = 1; + IOChunk standard_output = 2; + IOChunk standard_error = 3; + } +} + +message TerminalSize { + uint32 rows = 1; + uint32 cols = 2; +} + +message IOChunk { + bytes data = 1; +}