mirror of
https://github.com/imjasonh/crush
synced 2026-07-07 00:12:50 +00:00
add per-session network allowlist and env config via crush.toml
Client reads crush.toml (cwd, then ~/.config/crush/config.toml) for network allowlist and environment variables. Server applies iptables OUTPUT rules to restrict outbound to allowed hosts/CIDRs. IPv6 is blocked separately via ip6tables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
120856e2fe
commit
e7de8d628c
15 changed files with 540 additions and 44 deletions
41
README.md
41
README.md
|
|
@ -48,6 +48,47 @@ crush --service crush-xxxxx-ue.a.run.app -- ls -la
|
|||
|
||||
Passing `-e FOO=bar` will set the environment variable `FOO` to `bar` in the remote session.
|
||||
|
||||
### Config file (`crush.toml`)
|
||||
|
||||
crush reads configuration from `./crush.toml` in the current directory,
|
||||
falling back to `~/.config/crush/config.toml`. See
|
||||
[`crush.toml`](./crush.toml) for a full example.
|
||||
|
||||
#### Network allowlist
|
||||
|
||||
Restrict outbound network access from the remote container to a list of
|
||||
hosts, IPs, or CIDRs:
|
||||
|
||||
```toml
|
||||
[network]
|
||||
allow = [
|
||||
"registry.npmjs.org",
|
||||
"github.com",
|
||||
"10.0.0.0/8",
|
||||
]
|
||||
```
|
||||
|
||||
If `allow` is absent, the container has full network access (default).
|
||||
If `allow` is present but empty (`allow = []`), all outbound is blocked.
|
||||
Hostnames are resolved to IPs at session start. DNS and the gRPC control
|
||||
connection are always permitted.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Set explicit env vars or pass through values from the local environment:
|
||||
|
||||
```toml
|
||||
[env]
|
||||
passthrough = ["LANG", "GITHUB_TOKEN"]
|
||||
|
||||
[env.set]
|
||||
EDITOR = "vim"
|
||||
```
|
||||
|
||||
`passthrough` inherits the named variable from the caller's environment
|
||||
(skipped if unset). `[env.set]` provides explicit key=value pairs.
|
||||
CLI `-e` flags take precedence over both.
|
||||
|
||||
### Volume mounts (`-v`)
|
||||
|
||||
`-v /local/path:/remote/path` uploads local files to the remote container
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ contents:
|
|||
- apk-tools
|
||||
- wolfi-base
|
||||
- claude
|
||||
- iptables
|
||||
- ip6tables
|
||||
|
||||
archs:
|
||||
- amd64
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/chainguard-dev/clog"
|
||||
pb "github.com/imjasonh/crush/gen/crush/v1"
|
||||
"github.com/imjasonh/crush/internal/client"
|
||||
"github.com/imjasonh/crush/internal/config"
|
||||
"github.com/imjasonh/crush/internal/repl"
|
||||
)
|
||||
|
||||
|
|
@ -48,18 +50,36 @@ func main() {
|
|||
flag.Var(&vols, "v", "volume mount in LOCAL:REMOTE form (repeatable)")
|
||||
flag.Parse()
|
||||
|
||||
env := make(map[string]string, len(envs))
|
||||
for _, e := range envs {
|
||||
k, v, _ := strings.Cut(e, "=")
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
var mounts []client.Mount
|
||||
for _, v := range vols {
|
||||
local, remote, _ := strings.Cut(v, ":")
|
||||
mounts = append(mounts, client.Mount{LocalPath: local, RemotePath: remote})
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("loading config: %v", err)
|
||||
}
|
||||
|
||||
// Build env: config set < config passthrough < -e flags.
|
||||
env := make(map[string]string)
|
||||
for k, v := range cfg.Env.Set {
|
||||
env[k] = v
|
||||
}
|
||||
for _, k := range cfg.Env.Passthrough {
|
||||
if v, ok := os.LookupEnv(k); ok {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
for _, e := range envs {
|
||||
k, v, _ := strings.Cut(e, "=")
|
||||
env[k] = v
|
||||
}
|
||||
var network *pb.NetworkPolicy
|
||||
if cfg.Network.Allow != nil {
|
||||
network = &pb.NetworkPolicy{Allow: cfg.Network.Allow}
|
||||
}
|
||||
|
||||
if *service == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --service or CRUSH_SERVICE is required")
|
||||
flag.Usage()
|
||||
|
|
@ -80,14 +100,14 @@ func main() {
|
|||
args := flag.Args()
|
||||
if len(args) == 0 {
|
||||
// REPL mode.
|
||||
if err := repl.Run(ctx, cli, env, mounts); err != nil {
|
||||
if err := repl.Run(ctx, cli, env, mounts, network); err != nil {
|
||||
log.Fatalf("repl: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// One-shot mode.
|
||||
exitCode, err := client.Run(ctx, cli, args, env, mounts, os.Stdin, os.Stdout, os.Stderr)
|
||||
exitCode, err := client.Run(ctx, cli, args, env, mounts, network, os.Stdin, os.Stdout, os.Stderr)
|
||||
if err != nil {
|
||||
log.Fatalf("exec: %v", err)
|
||||
}
|
||||
|
|
|
|||
13
crush.toml
Normal file
13
crush.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[network]
|
||||
allow = [
|
||||
"registry.npmjs.org",
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"10.0.0.0/8",
|
||||
]
|
||||
|
||||
[env]
|
||||
passthrough = ["LANG"]
|
||||
|
||||
[env.set]
|
||||
FOO = "bar"
|
||||
|
|
@ -379,6 +379,53 @@ func (*ExecRequest_Resize) isExecRequest_Request() {}
|
|||
|
||||
func (*ExecRequest_Upload) isExecRequest_Request() {}
|
||||
|
||||
// NetworkPolicy restricts outbound network from the server.
|
||||
// When present, only the listed destinations are reachable.
|
||||
// An empty allow list means fully isolated (no outbound).
|
||||
type NetworkPolicy struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Allow []string `protobuf:"bytes,1,rep,name=allow,proto3" json:"allow,omitempty"` // hostnames, IPs, or CIDRs
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *NetworkPolicy) Reset() {
|
||||
*x = NetworkPolicy{}
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *NetworkPolicy) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NetworkPolicy) ProtoMessage() {}
|
||||
|
||||
func (x *NetworkPolicy) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crush_v1_exec_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 NetworkPolicy.ProtoReflect.Descriptor instead.
|
||||
func (*NetworkPolicy) Descriptor() ([]byte, []int) {
|
||||
return file_crush_v1_exec_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *NetworkPolicy) GetAllow() []string {
|
||||
if x != nil {
|
||||
return x.Allow
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type StartRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Command []string `protobuf:"bytes,1,rep,name=command,proto3" json:"command,omitempty"`
|
||||
|
|
@ -386,13 +433,14 @@ type StartRequest struct {
|
|||
WorkingDir string `protobuf:"bytes,3,opt,name=working_dir,json=workingDir,proto3" json:"working_dir,omitempty"`
|
||||
Pty *WindowSize `protobuf:"bytes,4,opt,name=pty,proto3" json:"pty,omitempty"` // If set, allocate a PTY with this initial size.
|
||||
Volumes []*VolumeMount `protobuf:"bytes,5,rep,name=volumes,proto3" json:"volumes,omitempty"` // Paths to sync bidirectionally.
|
||||
Network *NetworkPolicy `protobuf:"bytes,6,opt,name=network,proto3" json:"network,omitempty"` // If set, restrict outbound network.
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *StartRequest) Reset() {
|
||||
*x = StartRequest{}
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[4]
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -404,7 +452,7 @@ func (x *StartRequest) String() string {
|
|||
func (*StartRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StartRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[4]
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -417,7 +465,7 @@ func (x *StartRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StartRequest) Descriptor() ([]byte, []int) {
|
||||
return file_crush_v1_exec_proto_rawDescGZIP(), []int{4}
|
||||
return file_crush_v1_exec_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *StartRequest) GetCommand() []string {
|
||||
|
|
@ -455,6 +503,13 @@ func (x *StartRequest) GetVolumes() []*VolumeMount {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *StartRequest) GetNetwork() *NetworkPolicy {
|
||||
if x != nil {
|
||||
return x.Network
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ExecResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Response:
|
||||
|
|
@ -470,7 +525,7 @@ type ExecResponse struct {
|
|||
|
||||
func (x *ExecResponse) Reset() {
|
||||
*x = ExecResponse{}
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[5]
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -482,7 +537,7 @@ func (x *ExecResponse) String() string {
|
|||
func (*ExecResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ExecResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[5]
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -495,7 +550,7 @@ func (x *ExecResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ExecResponse) Descriptor() ([]byte, []int) {
|
||||
return file_crush_v1_exec_proto_rawDescGZIP(), []int{5}
|
||||
return file_crush_v1_exec_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *ExecResponse) GetResponse() isExecResponse_Response {
|
||||
|
|
@ -579,7 +634,7 @@ type ExitStatus struct {
|
|||
|
||||
func (x *ExitStatus) Reset() {
|
||||
*x = ExitStatus{}
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[6]
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -591,7 +646,7 @@ func (x *ExitStatus) String() string {
|
|||
func (*ExitStatus) ProtoMessage() {}
|
||||
|
||||
func (x *ExitStatus) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[6]
|
||||
mi := &file_crush_v1_exec_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -604,7 +659,7 @@ func (x *ExitStatus) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ExitStatus.ProtoReflect.Descriptor instead.
|
||||
func (*ExitStatus) Descriptor() ([]byte, []int) {
|
||||
return file_crush_v1_exec_proto_rawDescGZIP(), []int{6}
|
||||
return file_crush_v1_exec_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *ExitStatus) GetCode() int32 {
|
||||
|
|
@ -644,14 +699,17 @@ const file_crush_v1_exec_proto_rawDesc = "" +
|
|||
"\x06signal\x18\x03 \x01(\x0e2\x10.crush.v1.SignalH\x00R\x06signal\x12.\n" +
|
||||
"\x06resize\x18\x04 \x01(\v2\x14.crush.v1.WindowSizeH\x00R\x06resize\x12-\n" +
|
||||
"\x06upload\x18\x05 \x01(\v2\x13.crush.v1.FileChunkH\x00R\x06uploadB\t\n" +
|
||||
"\arequest\"\x8d\x02\n" +
|
||||
"\arequest\"%\n" +
|
||||
"\rNetworkPolicy\x12\x14\n" +
|
||||
"\x05allow\x18\x01 \x03(\tR\x05allow\"\xc0\x02\n" +
|
||||
"\fStartRequest\x12\x18\n" +
|
||||
"\acommand\x18\x01 \x03(\tR\acommand\x121\n" +
|
||||
"\x03env\x18\x02 \x03(\v2\x1f.crush.v1.StartRequest.EnvEntryR\x03env\x12\x1f\n" +
|
||||
"\vworking_dir\x18\x03 \x01(\tR\n" +
|
||||
"workingDir\x12&\n" +
|
||||
"\x03pty\x18\x04 \x01(\v2\x14.crush.v1.WindowSizeR\x03pty\x12/\n" +
|
||||
"\avolumes\x18\x05 \x03(\v2\x15.crush.v1.VolumeMountR\avolumes\x1a6\n" +
|
||||
"\avolumes\x18\x05 \x03(\v2\x15.crush.v1.VolumeMountR\avolumes\x121\n" +
|
||||
"\anetwork\x18\x06 \x01(\v2\x17.crush.v1.NetworkPolicyR\anetwork\x1a6\n" +
|
||||
"\bEnvEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xba\x01\n" +
|
||||
|
|
@ -690,35 +748,37 @@ func file_crush_v1_exec_proto_rawDescGZIP() []byte {
|
|||
}
|
||||
|
||||
var file_crush_v1_exec_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_crush_v1_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
|
||||
var file_crush_v1_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_crush_v1_exec_proto_goTypes = []any{
|
||||
(Signal)(0), // 0: crush.v1.Signal
|
||||
(*WindowSize)(nil), // 1: crush.v1.WindowSize
|
||||
(*VolumeMount)(nil), // 2: crush.v1.VolumeMount
|
||||
(*FileChunk)(nil), // 3: crush.v1.FileChunk
|
||||
(*ExecRequest)(nil), // 4: crush.v1.ExecRequest
|
||||
(*StartRequest)(nil), // 5: crush.v1.StartRequest
|
||||
(*ExecResponse)(nil), // 6: crush.v1.ExecResponse
|
||||
(*ExitStatus)(nil), // 7: crush.v1.ExitStatus
|
||||
nil, // 8: crush.v1.StartRequest.EnvEntry
|
||||
(Signal)(0), // 0: crush.v1.Signal
|
||||
(*WindowSize)(nil), // 1: crush.v1.WindowSize
|
||||
(*VolumeMount)(nil), // 2: crush.v1.VolumeMount
|
||||
(*FileChunk)(nil), // 3: crush.v1.FileChunk
|
||||
(*ExecRequest)(nil), // 4: crush.v1.ExecRequest
|
||||
(*NetworkPolicy)(nil), // 5: crush.v1.NetworkPolicy
|
||||
(*StartRequest)(nil), // 6: crush.v1.StartRequest
|
||||
(*ExecResponse)(nil), // 7: crush.v1.ExecResponse
|
||||
(*ExitStatus)(nil), // 8: crush.v1.ExitStatus
|
||||
nil, // 9: crush.v1.StartRequest.EnvEntry
|
||||
}
|
||||
var file_crush_v1_exec_proto_depIdxs = []int32{
|
||||
5, // 0: crush.v1.ExecRequest.start:type_name -> crush.v1.StartRequest
|
||||
6, // 0: crush.v1.ExecRequest.start:type_name -> crush.v1.StartRequest
|
||||
0, // 1: crush.v1.ExecRequest.signal:type_name -> crush.v1.Signal
|
||||
1, // 2: crush.v1.ExecRequest.resize:type_name -> crush.v1.WindowSize
|
||||
3, // 3: crush.v1.ExecRequest.upload:type_name -> crush.v1.FileChunk
|
||||
8, // 4: crush.v1.StartRequest.env:type_name -> crush.v1.StartRequest.EnvEntry
|
||||
9, // 4: crush.v1.StartRequest.env:type_name -> crush.v1.StartRequest.EnvEntry
|
||||
1, // 5: crush.v1.StartRequest.pty:type_name -> crush.v1.WindowSize
|
||||
2, // 6: crush.v1.StartRequest.volumes:type_name -> crush.v1.VolumeMount
|
||||
7, // 7: crush.v1.ExecResponse.exit_status:type_name -> crush.v1.ExitStatus
|
||||
3, // 8: crush.v1.ExecResponse.download:type_name -> crush.v1.FileChunk
|
||||
4, // 9: crush.v1.ExecService.Exec:input_type -> crush.v1.ExecRequest
|
||||
6, // 10: crush.v1.ExecService.Exec:output_type -> crush.v1.ExecResponse
|
||||
10, // [10:11] is the sub-list for method output_type
|
||||
9, // [9:10] is the sub-list for method input_type
|
||||
9, // [9:9] is the sub-list for extension type_name
|
||||
9, // [9:9] is the sub-list for extension extendee
|
||||
0, // [0:9] is the sub-list for field type_name
|
||||
5, // 7: crush.v1.StartRequest.network:type_name -> crush.v1.NetworkPolicy
|
||||
8, // 8: crush.v1.ExecResponse.exit_status:type_name -> crush.v1.ExitStatus
|
||||
3, // 9: crush.v1.ExecResponse.download:type_name -> crush.v1.FileChunk
|
||||
4, // 10: crush.v1.ExecService.Exec:input_type -> crush.v1.ExecRequest
|
||||
7, // 11: crush.v1.ExecService.Exec:output_type -> crush.v1.ExecResponse
|
||||
11, // [11:12] is the sub-list for method output_type
|
||||
10, // [10:11] is the sub-list for method input_type
|
||||
10, // [10:10] is the sub-list for extension type_name
|
||||
10, // [10:10] is the sub-list for extension extendee
|
||||
0, // [0:10] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_crush_v1_exec_proto_init() }
|
||||
|
|
@ -733,7 +793,7 @@ func file_crush_v1_exec_proto_init() {
|
|||
(*ExecRequest_Resize)(nil),
|
||||
(*ExecRequest_Upload)(nil),
|
||||
}
|
||||
file_crush_v1_exec_proto_msgTypes[5].OneofWrappers = []any{
|
||||
file_crush_v1_exec_proto_msgTypes[6].OneofWrappers = []any{
|
||||
(*ExecResponse_Stdout)(nil),
|
||||
(*ExecResponse_Stderr)(nil),
|
||||
(*ExecResponse_ExitStatus)(nil),
|
||||
|
|
@ -745,7 +805,7 @@ func file_crush_v1_exec_proto_init() {
|
|||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_crush_v1_exec_proto_rawDesc), len(file_crush_v1_exec_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 8,
|
||||
NumMessages: 9,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -18,6 +18,7 @@ require (
|
|||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -4,6 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
|||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chainguard-dev/clog v1.8.0 h1:frlTMEdg3XQR+ioQ6O9i92uigY8GTUcWKpuCFkhcCHA=
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func Dial(ctx context.Context, opts Options) (pb.ExecServiceClient, *grpc.Client
|
|||
}
|
||||
|
||||
// Run executes a command on the remote server and streams I/O.
|
||||
func Run(ctx context.Context, client pb.ExecServiceClient, command []string, env map[string]string, mounts []Mount, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
|
||||
func Run(ctx context.Context, client pb.ExecServiceClient, command []string, env map[string]string, mounts []Mount, network *pb.NetworkPolicy, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
|
||||
stream, err := client.Exec(ctx)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("opening exec stream: %w", err)
|
||||
|
|
@ -67,6 +67,7 @@ func Run(ctx context.Context, client pb.ExecServiceClient, command []string, env
|
|||
Command: command,
|
||||
Env: env,
|
||||
Volumes: protoVolumes,
|
||||
Network: network,
|
||||
}},
|
||||
}); err != nil {
|
||||
return -1, fmt.Errorf("sending start request: %w", err)
|
||||
|
|
|
|||
57
internal/config/config.go
Normal file
57
internal/config/config.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Network NetworkConfig `toml:"network"`
|
||||
Env EnvConfig `toml:"env"`
|
||||
}
|
||||
|
||||
type NetworkConfig struct {
|
||||
Allow []string `toml:"allow"`
|
||||
}
|
||||
|
||||
type EnvConfig struct {
|
||||
Set map[string]string `toml:"set"`
|
||||
Passthrough []string `toml:"passthrough"`
|
||||
}
|
||||
|
||||
// Load reads config from ./crush.toml, falling back to
|
||||
// ~/.config/crush/config.toml. If neither exists, it returns
|
||||
// a zero-value Config (no restrictions).
|
||||
func Load() (*Config, error) {
|
||||
paths := []string{"crush.toml"}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
paths = append(paths, filepath.Join(home, ".config", "crush", "config.toml"))
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
cfg, err := loadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
return &Config{}, nil
|
||||
}
|
||||
|
||||
func loadFile(path string) (*Config, error) {
|
||||
var cfg Config
|
||||
_, err := toml.DecodeFile(path, &cfg)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
118
internal/config/config_test.go
Normal file
118
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "valid.toml")
|
||||
os.WriteFile(path, []byte(`
|
||||
[network]
|
||||
allow = ["github.com", "10.0.0.0/8"]
|
||||
`), 0o644)
|
||||
|
||||
cfg, err := loadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Network.Allow) != 2 {
|
||||
t.Fatalf("got %d allow entries, want 2", len(cfg.Network.Allow))
|
||||
}
|
||||
if cfg.Network.Allow[0] != "github.com" {
|
||||
t.Fatalf("got %q, want %q", cfg.Network.Allow[0], "github.com")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty allow", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "empty.toml")
|
||||
os.WriteFile(path, []byte(`
|
||||
[network]
|
||||
allow = []
|
||||
`), 0o644)
|
||||
|
||||
cfg, err := loadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Network.Allow == nil {
|
||||
t.Fatal("allow should be non-nil empty slice")
|
||||
}
|
||||
if len(cfg.Network.Allow) != 0 {
|
||||
t.Fatalf("got %d allow entries, want 0", len(cfg.Network.Allow))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no network section", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "nonet.toml")
|
||||
os.WriteFile(path, []byte(`# empty config
|
||||
`), 0o644)
|
||||
|
||||
cfg, err := loadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Network.Allow != nil {
|
||||
t.Fatalf("allow should be nil, got %v", cfg.Network.Allow)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file", func(t *testing.T) {
|
||||
_, err := loadFile(filepath.Join(dir, "nope.toml"))
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("expected os.ErrNotExist, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env set and passthrough", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "env.toml")
|
||||
os.WriteFile(path, []byte(`
|
||||
[env]
|
||||
passthrough = ["HOME", "GOPATH"]
|
||||
|
||||
[env.set]
|
||||
FOO = "bar"
|
||||
BAZ = "qux"
|
||||
`), 0o644)
|
||||
|
||||
cfg, err := loadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Env.Set["FOO"] != "bar" {
|
||||
t.Fatalf("got FOO=%q, want %q", cfg.Env.Set["FOO"], "bar")
|
||||
}
|
||||
if cfg.Env.Set["BAZ"] != "qux" {
|
||||
t.Fatalf("got BAZ=%q, want %q", cfg.Env.Set["BAZ"], "qux")
|
||||
}
|
||||
if len(cfg.Env.Passthrough) != 2 {
|
||||
t.Fatalf("got %d passthrough entries, want 2", len(cfg.Env.Passthrough))
|
||||
}
|
||||
if cfg.Env.Passthrough[0] != "HOME" {
|
||||
t.Fatalf("got passthrough[0]=%q, want %q", cfg.Env.Passthrough[0], "HOME")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env only passthrough", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "pass.toml")
|
||||
os.WriteFile(path, []byte(`
|
||||
[env]
|
||||
passthrough = ["TERM"]
|
||||
`), 0o644)
|
||||
|
||||
cfg, err := loadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Env.Set) != 0 {
|
||||
t.Fatalf("got %d set entries, want 0", len(cfg.Env.Set))
|
||||
}
|
||||
if len(cfg.Env.Passthrough) != 1 {
|
||||
t.Fatalf("got %d passthrough entries, want 1", len(cfg.Env.Passthrough))
|
||||
}
|
||||
})
|
||||
}
|
||||
111
internal/firewall/firewall.go
Normal file
111
internal/firewall/firewall.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/chainguard-dev/clog"
|
||||
pb "github.com/imjasonh/crush/gen/crush/v1"
|
||||
)
|
||||
|
||||
func isIPv4(s string) bool {
|
||||
ip := net.ParseIP(s)
|
||||
return ip != nil && ip.To4() != nil
|
||||
}
|
||||
|
||||
func isIPv4CIDR(s string) bool {
|
||||
ip, _, err := net.ParseCIDR(s)
|
||||
return err == nil && ip.To4() != nil
|
||||
}
|
||||
|
||||
// Apply configures iptables OUTPUT rules to enforce the given network policy.
|
||||
// If iptables is not available, it logs a warning and returns nil.
|
||||
func Apply(ctx context.Context, policy *pb.NetworkPolicy) error {
|
||||
log := clog.FromContext(ctx)
|
||||
|
||||
if _, err := exec.LookPath("iptables"); err != nil {
|
||||
log.Warnf("iptables not found, skipping network policy")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve hostnames and collect allowed IPv4 addresses/CIDRs.
|
||||
// IPv6 addresses are filtered out since iptables only handles IPv4;
|
||||
// we block all IPv6 OUTPUT separately via ip6tables.
|
||||
var dests []string
|
||||
for _, entry := range policy.Allow {
|
||||
if isIPv4CIDR(entry) {
|
||||
dests = append(dests, entry)
|
||||
continue
|
||||
}
|
||||
if isIPv4(entry) {
|
||||
dests = append(dests, entry)
|
||||
continue
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(entry); err == nil {
|
||||
// IPv6 CIDR — skip for iptables.
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(entry); ip != nil {
|
||||
// IPv6 address — skip for iptables.
|
||||
continue
|
||||
}
|
||||
// Treat as hostname — resolve to IPs.
|
||||
addrs, err := net.LookupHost(entry)
|
||||
if err != nil {
|
||||
log.Warnf("resolving %q: %v", entry, err)
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if isIPv4(addr) {
|
||||
dests = append(dests, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rules := [][]string{
|
||||
// Flush OUTPUT chain.
|
||||
{"-F", "OUTPUT"},
|
||||
// Allow loopback.
|
||||
{"-A", "OUTPUT", "-o", "lo", "-j", "ACCEPT"},
|
||||
// Allow established/related connections (keeps gRPC alive).
|
||||
{"-A", "OUTPUT", "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"},
|
||||
// Allow DNS.
|
||||
{"-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "ACCEPT"},
|
||||
}
|
||||
|
||||
for _, d := range dests {
|
||||
rules = append(rules, []string{"-A", "OUTPUT", "-d", d, "-j", "ACCEPT"})
|
||||
}
|
||||
|
||||
// Default drop.
|
||||
rules = append(rules, []string{"-P", "OUTPUT", "DROP"})
|
||||
|
||||
for _, args := range rules {
|
||||
cmd := exec.CommandContext(ctx, "iptables", args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("iptables %s: %s: %w", strings.Join(args, " "), out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Block all IPv6 OUTPUT if ip6tables is available.
|
||||
if _, err := exec.LookPath("ip6tables"); err == nil {
|
||||
for _, args := range [][]string{
|
||||
{"-F", "OUTPUT"},
|
||||
{"-A", "OUTPUT", "-o", "lo", "-j", "ACCEPT"},
|
||||
{"-A", "OUTPUT", "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"},
|
||||
{"-P", "OUTPUT", "DROP"},
|
||||
} {
|
||||
cmd := exec.CommandContext(ctx, "ip6tables", args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Warnf("ip6tables %s: %s: %v", strings.Join(args, " "), out, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("network policy applied: %d destinations allowed", len(dests))
|
||||
return nil
|
||||
}
|
||||
54
internal/firewall/firewall_test.go
Normal file
54
internal/firewall/firewall_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pb "github.com/imjasonh/crush/gen/crush/v1"
|
||||
)
|
||||
|
||||
func TestIsIPv4(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"1.1.1.1", true},
|
||||
{"192.168.1.1", true},
|
||||
{"::1", false},
|
||||
{"2606:4700::1111", false},
|
||||
{"not-an-ip", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isIPv4(tt.input); got != tt.want {
|
||||
t.Errorf("isIPv4(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIPv4CIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"10.0.0.0/8", true},
|
||||
{"192.168.0.0/16", true},
|
||||
{"::1/128", false},
|
||||
{"2001:db8::/32", false},
|
||||
{"not-a-cidr", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isIPv4CIDR(tt.input); got != tt.want {
|
||||
t.Errorf("isIPv4CIDR(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySkipsWithoutIptables(t *testing.T) {
|
||||
// When iptables is not in PATH, Apply should return nil (graceful skip).
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
err := Apply(t.Context(), &pb.NetworkPolicy{
|
||||
Allow: []string{"1.1.1.1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error when iptables missing, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import (
|
|||
// Run starts an interactive REPL over a single long-lived Exec stream
|
||||
// running the user's shell with a PTY. The local terminal is put into
|
||||
// raw mode so that arrow keys, tab completion, Ctrl+R, etc. work.
|
||||
func Run(ctx context.Context, cli pb.ExecServiceClient, extraEnv map[string]string, mounts []client.Mount) error {
|
||||
func Run(ctx context.Context, cli pb.ExecServiceClient, extraEnv map[string]string, mounts []client.Mount, network *pb.NetworkPolicy) error {
|
||||
// Put the local terminal into raw mode so keystrokes are forwarded
|
||||
// verbatim (arrow keys, Ctrl sequences, etc.).
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
|
@ -66,6 +66,7 @@ func Run(ctx context.Context, cli pb.ExecServiceClient, extraEnv map[string]stri
|
|||
Cols: uint32(cols),
|
||||
},
|
||||
Volumes: protoVolumes,
|
||||
Network: network,
|
||||
}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("sending start request: %w", err)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/chainguard-dev/clog"
|
||||
"github.com/creack/pty"
|
||||
pb "github.com/imjasonh/crush/gen/crush/v1"
|
||||
"github.com/imjasonh/crush/internal/firewall"
|
||||
"github.com/imjasonh/crush/internal/volume"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
|
|
@ -79,6 +80,12 @@ func (s *ExecServer) Exec(stream grpc.BidiStreamingServer[pb.ExecRequest, pb.Exe
|
|||
}
|
||||
}
|
||||
|
||||
if start.Network != nil {
|
||||
if err := firewall.Apply(ctx, start.Network); err != nil {
|
||||
log.Warnf("applying network policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...)
|
||||
if start.WorkingDir != "" {
|
||||
cmd.Dir = start.WorkingDir
|
||||
|
|
|
|||
|
|
@ -46,12 +46,20 @@ message ExecRequest {
|
|||
}
|
||||
}
|
||||
|
||||
// NetworkPolicy restricts outbound network from the server.
|
||||
// When present, only the listed destinations are reachable.
|
||||
// An empty allow list means fully isolated (no outbound).
|
||||
message NetworkPolicy {
|
||||
repeated string allow = 1; // hostnames, IPs, or CIDRs
|
||||
}
|
||||
|
||||
message StartRequest {
|
||||
repeated string command = 1;
|
||||
map<string, string> env = 2;
|
||||
string working_dir = 3;
|
||||
WindowSize pty = 4; // If set, allocate a PTY with this initial size.
|
||||
repeated VolumeMount volumes = 5; // Paths to sync bidirectionally.
|
||||
NetworkPolicy network = 6; // If set, restrict outbound network.
|
||||
}
|
||||
|
||||
enum Signal {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue