diff --git a/cmd/sanssh/main.go b/cmd/sanssh/main.go index bfebe362..7bb1c379 100644 --- a/cmd/sanssh/main.go +++ b/cmd/sanssh/main.go @@ -45,6 +45,7 @@ import ( _ "github.com/Snowflake-Labs/sansshell/services/dns/client" _ "github.com/Snowflake-Labs/sansshell/services/exec/client" _ "github.com/Snowflake-Labs/sansshell/services/fdb/client" + _ "github.com/Snowflake-Labs/sansshell/services/fdbexec/client" _ "github.com/Snowflake-Labs/sansshell/services/healthcheck/client" _ "github.com/Snowflake-Labs/sansshell/services/httpoverrpc/client" _ "github.com/Snowflake-Labs/sansshell/services/localfile/client" diff --git a/cmd/sansshell-server/default-policy.rego b/cmd/sansshell-server/default-policy.rego index d9956f1f..559e049f 100644 --- a/cmd/sansshell-server/default-policy.rego +++ b/cmd/sansshell-server/default-policy.rego @@ -33,6 +33,9 @@ allow { input.message.filename = "/etc/hosts" } +allow { + input.type = "FdbExec.FdbExecRequest" +} allow { input.type = "Exec.ExecRequest" input.message.command = "/bin/echo" diff --git a/cmd/sansshell-server/main.go b/cmd/sansshell-server/main.go index df8b5285..f3528609 100644 --- a/cmd/sansshell-server/main.go +++ b/cmd/sansshell-server/main.go @@ -59,6 +59,7 @@ import ( ansible "github.com/Snowflake-Labs/sansshell/services/ansible/server" _ "github.com/Snowflake-Labs/sansshell/services/dns/server" _ "github.com/Snowflake-Labs/sansshell/services/exec/server" + _ "github.com/Snowflake-Labs/sansshell/services/fdbexec/server" _ "github.com/Snowflake-Labs/sansshell/services/httpoverrpc/server" _ "github.com/Snowflake-Labs/sansshell/services/tlsinfo/server" diff --git a/services/fdbexec/README.md b/services/fdbexec/README.md new file mode 100644 index 00000000..25f850bc --- /dev/null +++ b/services/fdbexec/README.md @@ -0,0 +1,17 @@ +# FdbExec + +The FdbExec service provides a way to execute commands on remote hosts. + +## Usage + +### sanssh fdbexec run +``` +sanssh fdbexec run [--stream] [--user user] [...] +``` + +## Notes + +The FdbExec service is identical to the Exec service in functionality, but is specifically intended for use by the FDB team. The rego policy can be configured to allow only FDB team members to use this command. + +The `--stream` flag streams command output as it is generated. +The `--user user` flag executes the command as the specified user (requires privileges). \ No newline at end of file diff --git a/services/fdbexec/client/client.go b/services/fdbexec/client/client.go new file mode 100644 index 00000000..5d3b4360 --- /dev/null +++ b/services/fdbexec/client/client.go @@ -0,0 +1,169 @@ +/* Copyright (c) 2019 Snowflake Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +// Package client provides the client interface for 'fdbexec' +package client + +import ( + "context" + "flag" + "fmt" + "io" + "os" + + "github.com/google/subcommands" + + "github.com/Snowflake-Labs/sansshell/client" + pb "github.com/Snowflake-Labs/sansshell/services/fdbexec" + "github.com/Snowflake-Labs/sansshell/services/util" +) + +// Default value for using streaming exec. +// This is exposed so that clients can set a default that works for +// their environment. StreamingExec was added after Exec, so policies +// or sansshell nodes may not have been updated to support streaming. +var DefaultStreaming = false + +const subPackage = "fdbexec" + +func init() { + subcommands.Register(&fdbExecCmd{}, subPackage) +} + +func (*fdbExecCmd) GetSubpackage(f *flag.FlagSet) *subcommands.Commander { + c := client.SetupSubpackage(subPackage, f) + c.Register(&runCmd{}, "") + return c +} + +type fdbExecCmd struct{} + +func (*fdbExecCmd) Name() string { return subPackage } +func (p *fdbExecCmd) Synopsis() string { + return client.GenerateSynopsis(p.GetSubpackage(flag.NewFlagSet("", flag.ContinueOnError)), 2) +} +func (p *fdbExecCmd) Usage() string { + return client.GenerateUsage(subPackage, p.Synopsis()) +} +func (*fdbExecCmd) SetFlags(f *flag.FlagSet) {} + +func (p *fdbExecCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { + c := p.GetSubpackage(f) + return c.Execute(ctx, args...) +} + +type runCmd struct { + streaming bool + user string + + // returnCode internally keeps track of the final status to return + returnCode subcommands.ExitStatus +} + +func (*runCmd) Name() string { return "run" } +func (*runCmd) Synopsis() string { return "Run provided command and return a response." } +func (*runCmd) Usage() string { + return `run [--stream] [--user=user] [...]: + Run a command remotely and return the response + + Note: This is not optimized for large output or long running commands. If + the output doesn't fit in memory in a single proto message or if it doesn't + complete within the timeout, you'll have a bad time. + + The --stream flag can be used to stream back command output as the command + runs. It doesn't affect the timeout. + + --user flag allows to specify a user for running command, equivalent of + sudo -u ... +` +} + +func (p *runCmd) SetFlags(f *flag.FlagSet) { + f.BoolVar(&p.streaming, "stream", DefaultStreaming, "If true, stream back stdout and stdin during the command instead of sending it all at the end.") + f.StringVar(&p.user, "user", "", "If specified, allows to run a command as a specified user. Equivalent of sudo -u ... .") +} + +func (p *runCmd) printCommandOutput(state *util.ExecuteState, idx int, resp *pb.FdbExecResponse, err error) { + if err == io.EOF { + // Streaming commands may return EOF + return + } + if err != nil { + fmt.Fprintf(state.Err[idx], "Command execution failure - %v\n", err) + // If any target had errors it needs to be reported for that target but we still + // need to process responses off the channel. Final return code though should + // indicate something failed. + p.returnCode = subcommands.ExitFailure + return + } + if len(resp.Stderr) > 0 { + fmt.Fprintf(state.Err[idx], "%s", resp.Stderr) + } + fmt.Fprintf(state.Out[idx], "%s", resp.Stdout) + if resp.RetCode != 0 { + p.returnCode = subcommands.ExitFailure + } +} + +func (p *runCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { + state := args[0].(*util.ExecuteState) + if f.NArg() == 0 { + fmt.Fprintf(os.Stderr, "Please specify a command to execute.\n") + return subcommands.ExitUsageError + } + + c := pb.NewFdbExecClientProxy(state.Conn) + req := &pb.FdbExecRequest{Command: f.Args()[0], Args: f.Args()[1:], User: p.user} + + if p.streaming { + resp, err := c.StreamingRunOneMany(ctx, req) + if err != nil { + // Emit this to every error file as it's not specific to a given target. + for _, e := range state.Err { + fmt.Fprintf(e, "All targets - could not execute: %v\n", err) + } + return subcommands.ExitFailure + } + + for { + rs, err := resp.Recv() + if err != nil { + if err == io.EOF { + return p.returnCode + } + fmt.Fprintf(os.Stderr, "Stream failure: %v\n", err) + return subcommands.ExitFailure + } + for _, r := range rs { + p.printCommandOutput(state, r.Index, r.Resp, r.Error) + } + } + } else { + resp, err := c.RunOneMany(ctx, req) + if err != nil { + // Emit this to every error file as it's not specific to a given target. + for _, e := range state.Err { + fmt.Fprintf(e, "All targets - could not execute: %v\n", err) + } + return subcommands.ExitFailure + } + + for r := range resp { + p.printCommandOutput(state, r.Index, r.Resp, r.Error) + } + } + return p.returnCode +} diff --git a/services/fdbexec/client/utils.go b/services/fdbexec/client/utils.go new file mode 100644 index 00000000..49f59571 --- /dev/null +++ b/services/fdbexec/client/utils.go @@ -0,0 +1,108 @@ +/* Copyright (c) 2019 Snowflake Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +package client + +import ( + "context" + "errors" + "fmt" + + "github.com/Snowflake-Labs/sansshell/proxy/proxy" + pb "github.com/Snowflake-Labs/sansshell/services/fdbexec" +) + +// FdbExecRemoteCommand is an options to exec a command on a remote host +type fdbExecRemoteOptions struct { + // execAsUser, Optional. User is the user to run the command as, if nil the command will be run as the default user + execAsUser *string +} + +type FdbExecRemoteOption func(*fdbExecRemoteOptions) + +func FdbExecAsUser(user string) FdbExecRemoteOption { + return func(o *fdbExecRemoteOptions) { + o.execAsUser = &user + } +} + +// FdbExecRemoteCommand is a helper function for execing a command on a remote host +// using a proxy.Conn. If the conn is defined for >1 targets this will return an error. +// +// Deprecated: Use FdbExecRemoteCommandWithOpts instead. +func FdbExecRemoteCommand(ctx context.Context, conn *proxy.Conn, binary string, args ...string) (*pb.FdbExecResponse, error) { + return FdbExecRemoteCommandWithOpts(ctx, conn, binary, args) +} + +// FdbExecRemoteCommandWithOpts is a helper function for execing a command on a remote host with provided opts +// using a proxy.Conn. If the conn is defined for >1 targets this will return an error. +func FdbExecRemoteCommandWithOpts(ctx context.Context, conn *proxy.Conn, binary string, args []string, opts ...FdbExecRemoteOption) (*pb.FdbExecResponse, error) { + if len(conn.Targets) != 1 { + return nil, errors.New("FdbExecRemoteCommand only supports single targets") + } + + result, err := FdbExecRemoteCommandManyWithOpts(ctx, conn, binary, args) + if err != nil { + return nil, err + } + if len(result) < 1 { + return nil, fmt.Errorf("FdbExecRemoteCommand error: received empty response") + } + if result[0].Error != nil { + return nil, fmt.Errorf("FdbExecRemoteCommand error: %v", result[0].Error) + } + return result[0].Resp, nil +} + +// FdbExecRemoteCommandMany is a helper function for execing a command on one or remote hosts +// using a proxy.Conn. +// `binary` refers to the absolute path of the binary file on the remote host. +// +// // Deprecated: Use FdbExecRemoteCommandManyWithOpts instead. +func FdbExecRemoteCommandMany(ctx context.Context, conn *proxy.Conn, binary string, args ...string) ([]*pb.RunManyResponse, error) { + return FdbExecRemoteCommandManyWithOpts(ctx, conn, binary, args) +} + +// FdbExecRemoteCommandManyWithOpts is a helper function for execing a command on one or remote hosts with provided opts +// using a proxy.Conn. +// `binary` refers to the absolute path of the binary file on the remote host. +func FdbExecRemoteCommandManyWithOpts(ctx context.Context, conn *proxy.Conn, binary string, args []string, opts ...FdbExecRemoteOption) ([]*pb.RunManyResponse, error) { + execOptions := &fdbExecRemoteOptions{} + for _, opt := range opts { + opt(execOptions) + } + + c := pb.NewFdbExecClientProxy(conn) + req := &pb.FdbExecRequest{ + Command: binary, + Args: args, + } + + if execOptions.execAsUser != nil { + req.User = *execOptions.execAsUser + } + + respChan, err := c.RunOneMany(ctx, req) + if err != nil { + return nil, err + } + result := make([]*pb.RunManyResponse, len(conn.Targets)) + for r := range respChan { + result[r.Index] = r + } + + return result, nil +} diff --git a/services/fdbexec/fdbexec.go b/services/fdbexec/fdbexec.go new file mode 100644 index 00000000..13436032 --- /dev/null +++ b/services/fdbexec/fdbexec.go @@ -0,0 +1,22 @@ +/* Copyright (c) 2019 Snowflake Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +// Package fdbexec defines the RPC interface for the sansshell FdbExec actions. +package fdbexec + +// To regenerate the proto headers if the .proto changes, just run go generate +// This comment encodes the necessary magic: +//go:generate protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=require_unimplemented_servers=false:. --go-grpc_opt=paths=source_relative --go-grpcproxy_out=. --go-grpcproxy_opt=paths=source_relative fdbexec.proto diff --git a/services/fdbexec/fdbexec.pb.go b/services/fdbexec/fdbexec.pb.go new file mode 100644 index 00000000..d76850cc --- /dev/null +++ b/services/fdbexec/fdbexec.pb.go @@ -0,0 +1,277 @@ +// Copyright (c) 2019 Snowflake Inc. All rights reserved. +// +//Licensed under the Apache License, Version 2.0 (the +//"License"); you may not use this file except in compliance +//with the License. You may obtain a copy of the License at +// +//http://www.apache.org/licenses/LICENSE-2.0 +// +//Unless required by applicable law or agreed to in writing, +//software distributed under the License is distributed on an +//"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +//KIND, either express or implied. See the License for the +//specific language governing permissions and limitations +//under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v5.29.3 +// source: services/fdbexec/fdbexec.proto + +package fdbexec + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +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) +) + +// FdbExecRequest describes what to execute +type FdbExecRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + // User to execute command as, equivalent of `sudo -u `. + User string `protobuf:"bytes,3,opt,name=user,proto3" json:"user,omitempty"` +} + +func (x *FdbExecRequest) Reset() { + *x = FdbExecRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_services_fdbexec_fdbexec_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FdbExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FdbExecRequest) ProtoMessage() {} + +func (x *FdbExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_services_fdbexec_fdbexec_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FdbExecRequest.ProtoReflect.Descriptor instead. +func (*FdbExecRequest) Descriptor() ([]byte, []int) { + return file_services_fdbexec_fdbexec_proto_rawDescGZIP(), []int{0} +} + +func (x *FdbExecRequest) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *FdbExecRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *FdbExecRequest) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +// FdbExecResponse describes output of execution +type FdbExecResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3" json:"stdout,omitempty"` + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3" json:"stderr,omitempty"` + RetCode int32 `protobuf:"varint,3,opt,name=retCode,proto3" json:"retCode,omitempty"` +} + +func (x *FdbExecResponse) Reset() { + *x = FdbExecResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_services_fdbexec_fdbexec_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FdbExecResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FdbExecResponse) ProtoMessage() {} + +func (x *FdbExecResponse) ProtoReflect() protoreflect.Message { + mi := &file_services_fdbexec_fdbexec_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FdbExecResponse.ProtoReflect.Descriptor instead. +func (*FdbExecResponse) Descriptor() ([]byte, []int) { + return file_services_fdbexec_fdbexec_proto_rawDescGZIP(), []int{1} +} + +func (x *FdbExecResponse) GetStdout() []byte { + if x != nil { + return x.Stdout + } + return nil +} + +func (x *FdbExecResponse) GetStderr() []byte { + if x != nil { + return x.Stderr + } + return nil +} + +func (x *FdbExecResponse) GetRetCode() int32 { + if x != nil { + return x.RetCode + } + return 0 +} + +var File_services_fdbexec_fdbexec_proto protoreflect.FileDescriptor + +var file_services_fdbexec_fdbexec_proto_rawDesc = []byte{ + 0x0a, 0x1e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x66, 0x64, 0x62, 0x65, 0x78, + 0x65, 0x63, 0x2f, 0x66, 0x64, 0x62, 0x65, 0x78, 0x65, 0x63, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x07, 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x22, 0x52, 0x0a, 0x0e, 0x46, 0x64, 0x62, + 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, + 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, + 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x5b, 0x0a, + 0x0f, 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x65, + 0x72, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, 0x72, + 0x12, 0x18, 0x0a, 0x07, 0x72, 0x65, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x07, 0x72, 0x65, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x32, 0x8c, 0x01, 0x0a, 0x07, 0x46, + 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x12, 0x3a, 0x0a, 0x03, 0x52, 0x75, 0x6e, 0x12, 0x17, 0x2e, + 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x2e, 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, + 0x2e, 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x45, 0x0a, 0x0c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x52, + 0x75, 0x6e, 0x12, 0x17, 0x2e, 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x2e, 0x46, 0x64, 0x62, + 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x46, 0x64, + 0x62, 0x45, 0x78, 0x65, 0x63, 0x2e, 0x46, 0x64, 0x62, 0x45, 0x78, 0x65, 0x63, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x53, 0x6e, 0x6f, 0x77, 0x66, 0x6c, 0x61, 0x6b, + 0x65, 0x2d, 0x4c, 0x61, 0x62, 0x73, 0x2f, 0x73, 0x61, 0x6e, 0x73, 0x73, 0x68, 0x65, 0x6c, 0x6c, + 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x66, 0x64, 0x62, 0x65, 0x78, 0x65, + 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_services_fdbexec_fdbexec_proto_rawDescOnce sync.Once + file_services_fdbexec_fdbexec_proto_rawDescData = file_services_fdbexec_fdbexec_proto_rawDesc +) + +func file_services_fdbexec_fdbexec_proto_rawDescGZIP() []byte { + file_services_fdbexec_fdbexec_proto_rawDescOnce.Do(func() { + file_services_fdbexec_fdbexec_proto_rawDescData = protoimpl.X.CompressGZIP(file_services_fdbexec_fdbexec_proto_rawDescData) + }) + return file_services_fdbexec_fdbexec_proto_rawDescData +} + +var file_services_fdbexec_fdbexec_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_services_fdbexec_fdbexec_proto_goTypes = []any{ + (*FdbExecRequest)(nil), // 0: FdbExec.FdbExecRequest + (*FdbExecResponse)(nil), // 1: FdbExec.FdbExecResponse +} +var file_services_fdbexec_fdbexec_proto_depIdxs = []int32{ + 0, // 0: FdbExec.FdbExec.Run:input_type -> FdbExec.FdbExecRequest + 0, // 1: FdbExec.FdbExec.StreamingRun:input_type -> FdbExec.FdbExecRequest + 1, // 2: FdbExec.FdbExec.Run:output_type -> FdbExec.FdbExecResponse + 1, // 3: FdbExec.FdbExec.StreamingRun:output_type -> FdbExec.FdbExecResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_services_fdbexec_fdbexec_proto_init() } +func file_services_fdbexec_fdbexec_proto_init() { + if File_services_fdbexec_fdbexec_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_services_fdbexec_fdbexec_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*FdbExecRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_services_fdbexec_fdbexec_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*FdbExecResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_services_fdbexec_fdbexec_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_services_fdbexec_fdbexec_proto_goTypes, + DependencyIndexes: file_services_fdbexec_fdbexec_proto_depIdxs, + MessageInfos: file_services_fdbexec_fdbexec_proto_msgTypes, + }.Build() + File_services_fdbexec_fdbexec_proto = out.File + file_services_fdbexec_fdbexec_proto_rawDesc = nil + file_services_fdbexec_fdbexec_proto_goTypes = nil + file_services_fdbexec_fdbexec_proto_depIdxs = nil +} diff --git a/services/fdbexec/fdbexec.proto b/services/fdbexec/fdbexec.proto new file mode 100644 index 00000000..09b16482 --- /dev/null +++ b/services/fdbexec/fdbexec.proto @@ -0,0 +1,47 @@ +/* Copyright (c) 2019 Snowflake Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +syntax = "proto3"; + +option go_package = "github.com/Snowflake-Labs/sansshell/services/fdbexec"; + +package FdbExec; + +// The FdbExec service definition. +service FdbExec { + // Run takes input, executes it and returns result of input execution + rpc Run (FdbExecRequest) returns (FdbExecResponse) {} + // StreamingRun takes input, executes it and streams back execution information + // + // A nonzero return code, if any, will be in the final response. Intermediate + // responses may contain stdout and/or stderr. + rpc StreamingRun (FdbExecRequest) returns (stream FdbExecResponse) {} +} + +// FdbExecRequest describes what to execute +message FdbExecRequest { + string command = 1; + repeated string args = 2; + // User to execute command as, equivalent of `sudo -u `. + string user = 3; +} + +// FdbExecResponse describes output of execution +message FdbExecResponse { + bytes stdout = 1; + bytes stderr = 2; + int32 retCode = 3; +} diff --git a/services/fdbexec/fdbexec_grpc.pb.go b/services/fdbexec/fdbexec_grpc.pb.go new file mode 100644 index 00000000..cf85683c --- /dev/null +++ b/services/fdbexec/fdbexec_grpc.pb.go @@ -0,0 +1,190 @@ +// Copyright (c) 2019 Snowflake Inc. All rights reserved. +// +//Licensed under the Apache License, Version 2.0 (the +//"License"); you may not use this file except in compliance +//with the License. You may obtain a copy of the License at +// +//http://www.apache.org/licenses/LICENSE-2.0 +// +//Unless required by applicable law or agreed to in writing, +//software distributed under the License is distributed on an +//"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +//KIND, either express or implied. See the License for the +//specific language governing permissions and limitations +//under the License. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.3 +// source: services/fdbexec/fdbexec.proto + +package fdbexec + +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 ( + FdbExec_Run_FullMethodName = "/FdbExec.FdbExec/Run" + FdbExec_StreamingRun_FullMethodName = "/FdbExec.FdbExec/StreamingRun" +) + +// FdbExecClient is the client API for FdbExec 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. +// +// The FdbExec service definition. +type FdbExecClient interface { + // Run takes input, executes it and returns result of input execution + Run(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (*FdbExecResponse, error) + // StreamingRun takes input, executes it and streams back execution information + // + // A nonzero return code, if any, will be in the final response. Intermediate + // responses may contain stdout and/or stderr. + StreamingRun(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FdbExecResponse], error) +} + +type fdbExecClient struct { + cc grpc.ClientConnInterface +} + +func NewFdbExecClient(cc grpc.ClientConnInterface) FdbExecClient { + return &fdbExecClient{cc} +} + +func (c *fdbExecClient) Run(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (*FdbExecResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FdbExecResponse) + err := c.cc.Invoke(ctx, FdbExec_Run_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *fdbExecClient) StreamingRun(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FdbExecResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &FdbExec_ServiceDesc.Streams[0], FdbExec_StreamingRun_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[FdbExecRequest, FdbExecResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + 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 FdbExec_StreamingRunClient = grpc.ServerStreamingClient[FdbExecResponse] + +// FdbExecServer is the server API for FdbExec service. +// All implementations should embed UnimplementedFdbExecServer +// for forward compatibility. +// +// The FdbExec service definition. +type FdbExecServer interface { + // Run takes input, executes it and returns result of input execution + Run(context.Context, *FdbExecRequest) (*FdbExecResponse, error) + // StreamingRun takes input, executes it and streams back execution information + // + // A nonzero return code, if any, will be in the final response. Intermediate + // responses may contain stdout and/or stderr. + StreamingRun(*FdbExecRequest, grpc.ServerStreamingServer[FdbExecResponse]) error +} + +// UnimplementedFdbExecServer should 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 UnimplementedFdbExecServer struct{} + +func (UnimplementedFdbExecServer) Run(context.Context, *FdbExecRequest) (*FdbExecResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Run not implemented") +} +func (UnimplementedFdbExecServer) StreamingRun(*FdbExecRequest, grpc.ServerStreamingServer[FdbExecResponse]) error { + return status.Errorf(codes.Unimplemented, "method StreamingRun not implemented") +} +func (UnimplementedFdbExecServer) testEmbeddedByValue() {} + +// UnsafeFdbExecServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to FdbExecServer will +// result in compilation errors. +type UnsafeFdbExecServer interface { + mustEmbedUnimplementedFdbExecServer() +} + +func RegisterFdbExecServer(s grpc.ServiceRegistrar, srv FdbExecServer) { + // If the following call pancis, it indicates UnimplementedFdbExecServer 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(&FdbExec_ServiceDesc, srv) +} + +func _FdbExec_Run_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FdbExecRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(FdbExecServer).Run(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: FdbExec_Run_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(FdbExecServer).Run(ctx, req.(*FdbExecRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _FdbExec_StreamingRun_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(FdbExecRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(FdbExecServer).StreamingRun(m, &grpc.GenericServerStream[FdbExecRequest, FdbExecResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FdbExec_StreamingRunServer = grpc.ServerStreamingServer[FdbExecResponse] + +// FdbExec_ServiceDesc is the grpc.ServiceDesc for FdbExec service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var FdbExec_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "FdbExec.FdbExec", + HandlerType: (*FdbExecServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Run", + Handler: _FdbExec_Run_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamingRun", + Handler: _FdbExec_StreamingRun_Handler, + ServerStreams: true, + }, + }, + Metadata: "services/fdbexec/fdbexec.proto", +} diff --git a/services/fdbexec/fdbexec_grpcproxy.pb.go b/services/fdbexec/fdbexec_grpcproxy.pb.go new file mode 100644 index 00000000..9edbdb96 --- /dev/null +++ b/services/fdbexec/fdbexec_grpcproxy.pb.go @@ -0,0 +1,190 @@ +// Auto generated code by protoc-gen-go-grpcproxy +// DO NOT EDIT + +// Adds OneMany versions of RPC methods for use by proxy clients + +package fdbexec + +import ( + context "context" + proxy "github.com/Snowflake-Labs/sansshell/proxy/proxy" + grpc "google.golang.org/grpc" +) + +import ( + "fmt" + "io" +) + +// FdbExecClientProxy is the superset of FdbExecClient which additionally includes the OneMany proxy methods +type FdbExecClientProxy interface { + FdbExecClient + RunOneMany(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (<-chan *RunManyResponse, error) + StreamingRunOneMany(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (FdbExec_StreamingRunClientProxy, error) +} + +// Embed the original client inside of this so we get the other generated methods automatically. +type fdbExecClientProxy struct { + *fdbExecClient +} + +// NewFdbExecClientProxy creates a FdbExecClientProxy for use in proxied connections. +// NOTE: This takes a proxy.Conn instead of a generic ClientConnInterface as the methods here are only valid in proxy.Conn contexts. +func NewFdbExecClientProxy(cc *proxy.Conn) FdbExecClientProxy { + return &fdbExecClientProxy{NewFdbExecClient(cc).(*fdbExecClient)} +} + +// RunManyResponse encapsulates a proxy data packet. +// It includes the target, index, response and possible error returned. +type RunManyResponse struct { + Target string + // As targets can be duplicated this is the index into the slice passed to proxy.Conn. + Index int + Resp *FdbExecResponse + Error error +} + +// RunOneMany provides the same API as Run but sends the same request to N destinations at once. +// N can be a single destination. +// +// NOTE: The returned channel must be read until it closes in order to avoid leaking goroutines. +func (c *fdbExecClientProxy) RunOneMany(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (<-chan *RunManyResponse, error) { + conn := c.cc.(*proxy.Conn) + ret := make(chan *RunManyResponse) + // If this is a single case we can just use Invoke and marshal it onto the channel once and be done. + if len(conn.Targets) == 1 { + go func() { + out := &RunManyResponse{ + Target: conn.Targets[0], + Index: 0, + Resp: &FdbExecResponse{}, + } + err := conn.Invoke(ctx, "/FdbExec.FdbExec/Run", in, out.Resp, opts...) + if err != nil { + out.Error = err + } + // Send and close. + ret <- out + close(ret) + }() + return ret, nil + } + manyRet, err := conn.InvokeOneMany(ctx, "/FdbExec.FdbExec/Run", in, opts...) + if err != nil { + return nil, err + } + // A goroutine to retrive untyped responses and convert them to typed ones. + go func() { + for { + typedResp := &RunManyResponse{ + Resp: &FdbExecResponse{}, + } + + resp, ok := <-manyRet + if !ok { + // All done so we can shut down. + close(ret) + return + } + typedResp.Target = resp.Target + typedResp.Index = resp.Index + typedResp.Error = resp.Error + if resp.Error == nil { + if err := resp.Resp.UnmarshalTo(typedResp.Resp); err != nil { + typedResp.Error = fmt.Errorf("can't decode any response - %v. Original Error - %v", err, resp.Error) + } + } + ret <- typedResp + } + }() + + return ret, nil +} + +// StreamingRunManyResponse encapsulates a proxy data packet. +// It includes the target, index, response and possible error returned. +type StreamingRunManyResponse struct { + Target string + // As targets can be duplicated this is the index into the slice passed to proxy.Conn. + Index int + Resp *FdbExecResponse + Error error +} + +type FdbExec_StreamingRunClientProxy interface { + Recv() ([]*StreamingRunManyResponse, error) + grpc.ClientStream +} + +type fdbExecClientStreamingRunClientProxy struct { + cc *proxy.Conn + directDone bool + grpc.ClientStream +} + +func (x *fdbExecClientStreamingRunClientProxy) Recv() ([]*StreamingRunManyResponse, error) { + var ret []*StreamingRunManyResponse + // If this is a direct connection the RecvMsg call is to a standard grpc.ClientStream + // and not our proxy based one. This means we need to receive a typed response and + // convert it into a single slice entry return. This ensures the OneMany style calls + // can be used by proxy with 1:N targets and non proxy with 1 target without client changes. + if x.cc.Direct() { + // Check if we're done. Just return EOF now. Any real error was already sent inside + // of a ManyResponse. + if x.directDone { + return nil, io.EOF + } + m := &FdbExecResponse{} + err := x.ClientStream.RecvMsg(m) + ret = append(ret, &StreamingRunManyResponse{ + Resp: m, + Error: err, + Target: x.cc.Targets[0], + Index: 0, + }) + // An error means we're done so set things so a later call now gets an EOF. + if err != nil { + x.directDone = true + } + return ret, nil + } + + m := []*proxy.Ret{} + if err := x.ClientStream.RecvMsg(&m); err != nil { + return nil, err + } + for _, r := range m { + typedResp := &StreamingRunManyResponse{ + Resp: &FdbExecResponse{}, + } + typedResp.Target = r.Target + typedResp.Index = r.Index + typedResp.Error = r.Error + if r.Error == nil { + if err := r.Resp.UnmarshalTo(typedResp.Resp); err != nil { + typedResp.Error = fmt.Errorf("can't decode any response - %v. Original Error - %v", err, r.Error) + } + } + ret = append(ret, typedResp) + } + return ret, nil +} + +// StreamingRunOneMany provides the same API as StreamingRun but sends the same request to N destinations at once. +// N can be a single destination. +// +// NOTE: The returned channel must be read until it closes in order to avoid leaking goroutines. +func (c *fdbExecClientProxy) StreamingRunOneMany(ctx context.Context, in *FdbExecRequest, opts ...grpc.CallOption) (FdbExec_StreamingRunClientProxy, error) { + stream, err := c.cc.NewStream(ctx, &FdbExec_ServiceDesc.Streams[0], "/FdbExec.FdbExec/StreamingRun", opts...) + if err != nil { + return nil, err + } + x := &fdbExecClientStreamingRunClientProxy{c.cc.(*proxy.Conn), false, stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} diff --git a/services/fdbexec/server/exec.go b/services/fdbexec/server/exec.go new file mode 100644 index 00000000..76825ce6 --- /dev/null +++ b/services/fdbexec/server/exec.go @@ -0,0 +1,267 @@ +/* Copyright (c) 2019 Snowflake Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +// Package server implements the sansshell 'FdbExec' service. +package server + +import ( + "bufio" + "context" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/Snowflake-Labs/sansshell/services" + pb "github.com/Snowflake-Labs/sansshell/services/fdbexec" + "github.com/Snowflake-Labs/sansshell/services/util" + "github.com/Snowflake-Labs/sansshell/telemetry/metrics" +) + +// Metrics +var ( + fdbexecRunFailureCounter = metrics.MetricDefinition{Name: "actions_fdbexec_run_failure", + Description: "number of failures when performing fdbexec.Run"} +) + +// server is used to implement the gRPC server +type server struct{} + +// loadFdbEnvFile loads environment variables from /etc/fdb.env if it exists +func loadFdbEnvFile() ([]string, error) { + // Check if /etc/fdb.env exists + if _, err := os.Stat("/etc/fdb.env"); os.IsNotExist(err) { + return nil, nil + } + + // Open the environment file + file, err := os.Open("/etc/fdb.env") + if err != nil { + return nil, err + } + defer file.Close() + + var envVars []string + + // Read each line + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Parse KEY=VALUE format + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes if present + if len(value) > 1 && (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || + (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { + value = value[1 : len(value)-1] + } + + // Add the environment variable + envVars = append(envVars, key+"="+value) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return envVars, nil +} + +// Run executes command and returns result +func (s *server) Run(ctx context.Context, req *pb.FdbExecRequest) (res *pb.FdbExecResponse, err error) { + recorder := metrics.RecorderFromContextOrNoop(ctx) + + var opts []util.Option + if req.User != "" { + uid, gid, err := resolveUser(req.User) + if err != nil { + return nil, err + } + opts = append(opts, util.CommandUser(uint32(uid))) + opts = append(opts, util.CommandGroup(uint32(gid))) + } + + // Load environment variables from /etc/fdb.env + envVars, err := loadFdbEnvFile() + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to load environment variables: %v", err) + } + + // Add environment variables as options + if envVars != nil { + for _, env := range envVars { + opts = append(opts, util.EnvVar(env)) + } + } + + run, err := util.RunCommand(ctx, req.Command, req.Args, opts...) + if err != nil { + recorder.CounterOrLog(ctx, fdbexecRunFailureCounter, 1) + return nil, err + } + + if run.Error != nil { + recorder.CounterOrLog(ctx, fdbexecRunFailureCounter, 1) + return nil, run.Error + } + return &pb.FdbExecResponse{Stderr: run.Stderr.Bytes(), Stdout: run.Stdout.Bytes(), RetCode: int32(run.ExitCode)}, nil +} + +// StreamingRun executes command and returns a stream of results +func (s *server) StreamingRun(req *pb.FdbExecRequest, stream pb.FdbExec_StreamingRunServer) error { + ctx := stream.Context() + recorder := metrics.RecorderFromContextOrNoop(ctx) + + // We can't use util.RunCommand because it runs the command synchronously, so we + // need to do input validation normally performed by it. + if !filepath.IsAbs(req.Command) { + return status.Errorf(codes.InvalidArgument, "%s is not an absolute path", req.Command) + } + if req.Command != filepath.Clean(req.Command) { + return status.Errorf(codes.InvalidArgument, "%s is not a clean path", req.Command) + } + + cmd := exec.CommandContext(ctx, req.Command, req.Args...) + if req.User != "" { + uid, gid, err := resolveUser(req.User) + if err != nil { + return err + } + + // Set uid/gid if needed for the sub-process to run under. + // Only do this if it's different than our current ones since + // attempting to setuid/gid() to even your current values is EPERM. + if uid != uint32(os.Geteuid()) || gid != uint32(os.Getgid()) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uid, + Gid: gid, + }, + } + } + } + + // Set default empty environment + cmd.Env = []string{} + + // Load environment variables from /etc/fdb.env + envVars, err := loadFdbEnvFile() + if err != nil { + return status.Errorf(codes.Internal, "failed to load environment variables: %v", err) + } + + // Add environment variables to cmd.Env + if envVars != nil { + cmd.Env = append(cmd.Env, envVars...) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + + if err := cmd.Start(); err != nil { + return err + } + + // Send stderr asynchronously + go func() { + for { + buf := make([]byte, 1024) + n, err := stderr.Read(buf) + if err != nil { + return + } + if err := stream.Send(&pb.FdbExecResponse{Stderr: buf[:n]}); err != nil { + recorder.CounterOrLog(ctx, fdbexecRunFailureCounter, 1) + return + } + } + }() + + // Send stdout synchronously + for { + buf := make([]byte, 1024) + n, err := stdout.Read(buf) + if err == io.EOF { + break + } else if err != nil { + recorder.CounterOrLog(ctx, fdbexecRunFailureCounter, 1) + return err + } + if err := stream.Send(&pb.FdbExecResponse{Stdout: buf[:n]}); err != nil { + return err + } + } + + // If we've gotten here, stdout has been closed and the command is over + err = cmd.Wait() + if exitErr, ok := err.(*exec.ExitError); ok { + return stream.Send(&pb.FdbExecResponse{RetCode: int32(exitErr.ExitCode())}) + } + return err +} + +// resolveUser retruns uid and gid of provided username. +func resolveUser(username string) (uint32, uint32, error) { + u, err := user.Lookup(username) + if err != nil { + return 0, 0, status.Errorf(codes.InvalidArgument, "user '%s' not found:\n%v", username, err) + } + // This will work only on POSIX (Windows has non-decimal uids) yet these are our targets. + uid, err := strconv.Atoi(u.Uid) + if err != nil { + return 0, 0, status.Errorf(codes.Internal, "'%s' user's uid %s failed to convert to numeric value:\n%v", username, u.Uid, err) + } + gid, err := strconv.Atoi(u.Gid) + if err != nil { + return 0, 0, status.Errorf(codes.Internal, "'%s' user's gid %s failed to convert to numeric value:\n%v", username, u.Gid, err) + } + return uint32(uid), uint32(gid), nil +} + +// Register is called to expose this handler to the gRPC server +func (s *server) Register(gs *grpc.Server) { + pb.RegisterFdbExecServer(gs, s) +} + +func init() { + services.RegisterSansShellService(&server{}) +} diff --git a/services/fdbexec/server/exec_test.go b/services/fdbexec/server/exec_test.go new file mode 100644 index 00000000..ce978955 --- /dev/null +++ b/services/fdbexec/server/exec_test.go @@ -0,0 +1,169 @@ +/* Copyright (c) 2019 Snowflake Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +package server + +import ( + "context" + "io" + "log" + "net" + "os" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + pb "github.com/Snowflake-Labs/sansshell/services/fdbexec" + "github.com/Snowflake-Labs/sansshell/testing/testutil" +) + +var ( + bufSize = 1024 * 1024 + lis *bufconn.Listener + conn *grpc.ClientConn +) + +func bufDialer(context.Context, string) (net.Conn, error) { + return lis.Dial() +} + +func TestMain(m *testing.M) { + lis = bufconn.Listen(bufSize) + s := grpc.NewServer() + lfs := &server{} + lfs.Register(s) + go func() { + if err := s.Serve(lis); err != nil { + log.Fatalf("Server exited with error: %v", err) + } + }() + defer s.GracefulStop() + + os.Exit(m.Run()) +} + +func collect(c pb.FdbExec_StreamingRunClient) (*pb.FdbExecResponse, error) { + collected := &pb.FdbExecResponse{} + for { + resp, err := c.Recv() + if err == io.EOF { + return collected, nil + } + if err != nil { + return nil, err + } + collected.Stdout = append(collected.Stdout, resp.Stdout...) + collected.Stderr = append(collected.Stderr, resp.Stderr...) + collected.RetCode = resp.RetCode + } +} + +func TestFdbExec(t *testing.T) { + var err error + ctx := context.Background() + conn, err = grpc.DialContext(ctx, "bufnet", grpc.WithContextDialer(bufDialer), grpc.WithTransportCredentials(insecure.NewCredentials())) + testutil.FatalOnErr("Failed to dial bufnet", err, t) + t.Cleanup(func() { conn.Close() }) + + client := pb.NewFdbExecClient(conn) + + for _, tc := range []struct { + name string + bin string + args []string + user string + wantErr bool + returnCodeNonZero bool + stdout string + }{ + { + name: "Basic functionality", + bin: testutil.ResolvePath(t, "echo"), + args: []string{"hello world"}, + stdout: "hello world\n", + }, + { + name: "Command fails", + bin: testutil.ResolvePath(t, "false"), + returnCodeNonZero: true, + }, + { + name: "Non-existant program", + bin: "/something/non-existant", + wantErr: true, + }, + { + name: "non-absolute path", + bin: "foo", + wantErr: true, + }, + { + name: "user specified -- fails as it can't setuid", + bin: testutil.ResolvePath(t, "echo"), + args: []string{"hello world"}, + user: "nobody", + wantErr: true, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // Test a normal exec. + resp, err := client.Run(ctx, &pb.FdbExecRequest{ + Command: tc.bin, + Args: tc.args, + User: tc.user, + }) + t.Logf("%s: resp: %+v", tc.name, resp) + t.Logf("%s: err: %v", tc.name, err) + if tc.wantErr { + testutil.WantErr(tc.name, err, tc.wantErr, t) + return + } + if got, want := resp.Stdout, tc.stdout; string(got) != want { + t.Fatalf("%s: stdout doesn't match. Want %q Got %q", tc.name, want, got) + } + if got, want := resp.RetCode != 0, tc.returnCodeNonZero; got != want { + t.Fatalf("%s: Invalid return codes. Non-zero state doesn't match. Want %t Got %t ReturnCode %d", tc.name, want, got, resp.RetCode) + } + + // Test a streaming exec, which should behave identically to normal exec if + // all responses are concatenated together. + stream, err := client.StreamingRun(ctx, &pb.FdbExecRequest{ + Command: tc.bin, + Args: tc.args, + }) + if err != nil { + t.Fatal(err) + } + streamResp, err := collect(stream) + t.Logf("%s: resp: %+v", tc.name, streamResp) + t.Logf("%s: err: %v", tc.name, err) + if tc.wantErr { + testutil.WantErr(tc.name, err, tc.wantErr, t) + return + } + if got, want := streamResp.Stdout, tc.stdout; string(got) != want { + t.Fatalf("%s: stdout doesn't match. Want %q Got %q", tc.name, want, got) + } + if got, want := streamResp.RetCode != 0, tc.returnCodeNonZero; got != want { + t.Fatalf("%s: Invalid return codes. Non-zero state doesn't match. Want %t Got %t ReturnCode %d", tc.name, want, got, streamResp.RetCode) + } + + }) + } +}