1
0
Fork 0
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:
Jason Hall 2026-02-23 16:04:51 -05:00
parent 120856e2fe
commit e7de8d628c
15 changed files with 540 additions and 44 deletions

View file

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

View file

@ -7,6 +7,8 @@ contents:
- apk-tools
- wolfi-base
- claude
- iptables
- ip6tables
archs:
- amd64

View file

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

View file

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

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

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

View file

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

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

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

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

View file

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

View file

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

View file

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