"tart exec" support (--run-rpc) and component groups (e.g. --run-daemon) (#10)

* "tart exec" support (--run-rpc) and component groups (e.g. --run-daemon)

* CI: check for lacking "buf generate" invocation

* CI: a task to run "buf push"

* defer ptmx.Close()

* Use getpeername(2) to get AF_VSOCK peer's identity

* Only call ptmx.Close() when pty.StartWithSize() succeeded
This commit is contained in:
Nikolay Edigaryev 2025-05-19 16:53:16 +02:00 committed by GitHub
parent 9f799bbfda
commit 9fac15bc9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1264 additions and 5 deletions

View File

@ -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 == ''

View File

@ -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)

8
buf.gen.yaml Normal file
View File

@ -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

10
buf.yaml Normal file
View File

@ -0,0 +1,10 @@
version: v2
modules:
- path: proto/
name: buf.build/cirruslabs/tart-guest-agent
lint:
use:
- STANDARD
breaking:
use:
- FILE

7
go.mod
View File

@ -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
)

36
go.sum
View File

@ -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=

View File

@ -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

538
internal/rpc/agent.pb.go Normal file
View File

@ -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
}

View File

@ -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",
}

259
internal/rpc/exec.go Normal file
View File

@ -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]),
}
}
}

28
internal/rpc/rpc.go Normal file
View File

@ -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)
}

15
internal/vsock/addr.go Normal file
View File

@ -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)
}

45
internal/vsock/conn.go Normal file
View File

@ -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()
}

View File

@ -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()
}

46
proto/rpc/agent.proto Normal file
View File

@ -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;
}