diff --git a/cmd/firewall.go b/cmd/firewall.go new file mode 100644 index 0000000..59c155a --- /dev/null +++ b/cmd/firewall.go @@ -0,0 +1,126 @@ +// ExitBox - Multi-Agent Container Sandbox +// Copyright (C) 2026 Cloud Exit B.V. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "strconv" + + "github.com/cloud-exit/exitbox/internal/config" + "github.com/cloud-exit/exitbox/internal/network" + "github.com/cloud-exit/exitbox/internal/ui" + "github.com/spf13/cobra" +) + +func newFirewallCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "firewall", + Short: "Manage firewall exclusions", + } + ports := &cobra.Command{ + Use: "ports", + Short: "Manage host ports reachable as localhost: from agents", + } + ports.AddCommand(newFirewallPortsListCmd()) + ports.AddCommand(newFirewallPortsAddCmd()) + ports.AddCommand(newFirewallPortsRemoveCmd()) + cmd.AddCommand(ports) + return cmd +} + +func newFirewallPortsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List persistent host port exclusions", + Run: func(cmd *cobra.Command, args []string) { + al := config.LoadAllowlistOrDefault() + ports := network.NormalizeHostPorts(al.HostPorts) + if len(ports) == 0 { + ui.Info("No host ports configured.") + return + } + for _, port := range ports { + fmt.Println(port) + } + }, + } +} + +func newFirewallPortsAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add [port...]", + Short: "Allow host ports from agents via localhost:", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ports := parseFirewallPorts(args) + al := config.LoadAllowlistOrDefault() + al.HostPorts = network.NormalizeHostPorts(append(al.HostPorts, ports...)) + if err := config.SaveAllowlist(al); err != nil { + ui.Errorf("Failed to save firewall ports: %v", err) + } + ui.Successf("Allowed host ports: %s", network.HostPortsEnvValue(al.HostPorts)) + }, + } +} + +func newFirewallPortsRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove [port...]", + Short: "Remove persistent host port exclusions", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ports := parseFirewallPorts(args) + remove := make(map[int]struct{}, len(ports)) + for _, port := range ports { + remove[port] = struct{}{} + } + + al := config.LoadAllowlistOrDefault() + var kept []int + for _, port := range network.NormalizeHostPorts(al.HostPorts) { + if _, ok := remove[port]; !ok { + kept = append(kept, port) + } + } + al.HostPorts = kept + if err := config.SaveAllowlist(al); err != nil { + ui.Errorf("Failed to save firewall ports: %v", err) + } + if len(al.HostPorts) == 0 { + ui.Success("No host ports allowed.") + } else { + ui.Successf("Allowed host ports: %s", network.HostPortsEnvValue(al.HostPorts)) + } + }, + } +} + +func parseFirewallPorts(args []string) []int { + var ports []int + for _, arg := range args { + port, err := strconv.Atoi(arg) + if err != nil || port < 1 || port > 65535 { + ui.Errorf("Invalid port %q: must be 1-65535", arg) + } + ports = append(ports, port) + } + return ports +} + +func init() { + rootCmd.AddCommand(newFirewallCmd()) +} diff --git a/cmd/root.go b/cmd/root.go index 4374a39..000d86a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -80,6 +80,7 @@ func init() { rootCmd.PersistentFlags().StringSliceP("include-dir", "i", nil, "Mount host dir inside /workspace") rootCmd.PersistentFlags().StringSliceP("tools", "t", nil, "Add Alpine packages to the image") rootCmd.PersistentFlags().StringSliceP("allow-urls", "a", nil, "Allow extra domains for this session") + rootCmd.PersistentFlags().IntSlice("host-port", nil, "Allow localhost:PORT in the agent to reach the host") rootCmd.AddCommand(versionCmd) diff --git a/cmd/run.go b/cmd/run.go index 39634af..228ebf6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -95,6 +95,8 @@ Flags (passed after the agent name): -t, --tools PKG Add Alpine packages to the image -i, --include-dir DIR Mount host dir inside /workspace -a, --allow-urls DOM Allow extra domains for this session + --host-port PORT Allow localhost:PORT in the agent to reach the host + Persist ports with: exitbox firewall ports add PORT --ollama Use host Ollama for local models --memory SIZE Container memory limit (default: 8g) --cpus COUNT Container CPU limit (default: 4) @@ -164,6 +166,9 @@ func runAgent(agentName string, passthrough []string) { flags := parseRunFlags(passthrough, cfg.Settings.DefaultFlags) flags = applySessionResumeDefaults(flags) + if len(flags.HostPortErrors) > 0 { + ui.Errorf("Invalid --host-port value(s): %s", strings.Join(flags.HostPortErrors, ", ")) + } if flags.Verbose { ui.Verbose = true @@ -312,6 +317,7 @@ func runAgent(agentName string, passthrough []string) { EnvVars: flags.EnvVars, IncludeDirs: flags.IncludeDirs, AllowURLs: flags.AllowURLs, + HostPorts: flags.HostPorts, Passthrough: flags.Remaining, Verbose: flags.Verbose, StatusBar: cfg.Settings.StatusBar, @@ -418,6 +424,8 @@ type parsedFlags struct { EnvVars []string IncludeDirs []string AllowURLs []string + HostPorts []int + HostPortErrors []string Tools []string Remaining []string } @@ -498,6 +506,13 @@ func parseRunFlags(passthrough []string, defaults config.DefaultFlags) parsedFla i++ f.AllowURLs = append(f.AllowURLs, passthrough[i]) } + case "--host-port": + if i+1 < len(passthrough) { + i++ + ports, errs := parseHostPortList(passthrough[i]) + f.HostPorts = append(f.HostPorts, ports...) + f.HostPortErrors = append(f.HostPortErrors, errs...) + } case "--ollama": f.Ollama = true case "--memory": @@ -521,6 +536,30 @@ func parseRunFlags(passthrough []string, defaults config.DefaultFlags) parsedFla return f } +func parseHostPortList(raw string) ([]int, []string) { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' + }) + var ports []int + var errs []string + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + port, err := strconv.Atoi(field) + if err != nil || port < 1 || port > 65535 { + errs = append(errs, field) + continue + } + ports = append(ports, port) + } + if len(fields) == 0 && strings.TrimSpace(raw) != "" { + errs = append(errs, raw) + } + return ports, errs +} + func applySessionResumeDefaults(f parsedFlags) parsedFlags { // Named sessions (--name) imply auto-resume: if the session already // exists it will be resumed, otherwise a fresh one is created. diff --git a/cmd/run_flags_test.go b/cmd/run_flags_test.go index 4cd292a..a290b0b 100644 --- a/cmd/run_flags_test.go +++ b/cmd/run_flags_test.go @@ -68,6 +68,32 @@ func TestParseRunFlags_Tools(t *testing.T) { } } +func TestParseRunFlags_HostPort(t *testing.T) { + f := parseRunFlags([]string{"--host-port", "3000", "--host-port", "5173,8080"}, config.DefaultFlags{}) + want := []int{3000, 5173, 8080} + if len(f.HostPorts) != len(want) { + t.Fatalf("expected %d host ports, got %d: %v", len(want), len(f.HostPorts), f.HostPorts) + } + for i := range want { + if f.HostPorts[i] != want[i] { + t.Fatalf("HostPorts[%d] = %d, want %d", i, f.HostPorts[i], want[i]) + } + } + if len(f.HostPortErrors) != 0 { + t.Fatalf("unexpected host port errors: %v", f.HostPortErrors) + } +} + +func TestParseRunFlags_HostPortInvalid(t *testing.T) { + f := parseRunFlags([]string{"--host-port", "0,abc,70000"}, config.DefaultFlags{}) + if len(f.HostPorts) != 0 { + t.Fatalf("expected no valid host ports, got %v", f.HostPorts) + } + if len(f.HostPortErrors) != 3 { + t.Fatalf("expected 3 host port errors, got %v", f.HostPortErrors) + } +} + func TestParseRunFlags_DoubleDash(t *testing.T) { f := parseRunFlags([]string{"-f", "--", "-r", "extra"}, config.DefaultFlags{}) if !f.NoFirewall { diff --git a/internal/config/types.go b/internal/config/types.go index 9d9f5d9..b744f89 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -113,6 +113,7 @@ type Allowlist struct { Development []string `yaml:"development"` CloudServices []string `yaml:"cloud_services"` CommonServices []string `yaml:"common_services"` + HostPorts []int `yaml:"host_ports,omitempty"` Custom []string `yaml:"custom,omitempty"` } diff --git a/internal/network/network.go b/internal/network/network.go index 233222a..34df8f3 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -22,7 +22,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" + "time" "github.com/cloud-exit/exitbox/internal/config" "github.com/cloud-exit/exitbox/internal/container" @@ -35,6 +37,48 @@ const ( SquidContainer = "exitbox-squid" ) +const ( + squidLockTimeout = 60 * time.Second + squidLockStaleAfter = 2 * time.Minute +) + +func withSquidLock(fn func() error) error { + unlock, err := acquireSquidLock() + if err != nil { + return err + } + defer unlock() + return fn() +} + +func acquireSquidLock() (func(), error) { + lockDir := filepath.Join(config.Cache, "squid.lock") + deadline := time.Now().Add(squidLockTimeout) + + for { + if err := os.MkdirAll(filepath.Dir(lockDir), 0755); err != nil { + return nil, fmt.Errorf("creating squid lock parent: %w", err) + } + err := os.Mkdir(lockDir, 0755) + if err == nil { + _ = os.WriteFile(filepath.Join(lockDir, "owner"), []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644) + return func() { _ = os.RemoveAll(lockDir) }, nil + } + if !os.IsExist(err) { + return nil, fmt.Errorf("acquiring squid lock: %w", err) + } + + if info, statErr := os.Stat(lockDir); statErr == nil && time.Since(info.ModTime()) > squidLockStaleAfter { + _ = os.RemoveAll(lockDir) + continue + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timed out waiting for Squid proxy lock") + } + time.Sleep(100 * time.Millisecond) + } +} + // reconfigureSquid sends a HUP to the running squid process. If that fails // (stale PID, process crashed), it restarts squid inside the container. func reconfigureSquid(cmd string) { @@ -115,42 +159,77 @@ func sessionDir() string { // RegisterSessionURLs writes a session file for a container's extra URLs. func RegisterSessionURLs(containerName string, urls []string) error { + return RegisterSessionAccess(containerName, urls, nil) +} + +// RegisterSessionAccess writes a session file for a container's firewall extras. +func RegisterSessionAccess(containerName string, urls []string, hostPorts []int) error { dir := sessionDir() if err := os.MkdirAll(dir, 0755); err != nil { return err } - content := strings.Join(urls, "\n") + "\n" - return os.WriteFile(filepath.Join(dir, containerName+".urls"), []byte(content), 0644) + var b strings.Builder + for _, url := range urls { + if strings.TrimSpace(url) == "" { + continue + } + b.WriteString(url) + b.WriteByte('\n') + } + for _, port := range NormalizeHostPorts(hostPorts) { + fmt.Fprintf(&b, "host-port:%d\n", port) + } + return os.WriteFile(filepath.Join(dir, containerName+".urls"), []byte(b.String()), 0644) +} + +func sessionFileCount() int { + entries, err := os.ReadDir(sessionDir()) + if err != nil { + return 0 + } + count := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".urls") { + count++ + } + } + return count } // RemoveSessionURLs removes a container's session file and regenerates squid config. func RemoveSessionURLs(rt container.Runtime, containerName string) { - dir := sessionDir() - _ = os.Remove(filepath.Join(dir, containerName+".urls")) - - // Collect remaining URLs from all sessions and regenerate config - remaining := collectAllSessionURLs() - changed, writeErr := writeSquidConfig(rt, remaining) - if writeErr != nil { - ui.Warnf("Failed to regenerate squid config: %v", writeErr) - return - } + if err := withSquidLock(func() error { + dir := sessionDir() + _ = os.Remove(filepath.Join(dir, containerName+".urls")) + + // Collect remaining URLs from all sessions and regenerate config + remaining := collectAllSessionURLs() + remainingHostPorts := collectAllSessionHostPorts() + changed, writeErr := writeSquidConfig(rt, remaining, remainingHostPorts) + if writeErr != nil { + ui.Warnf("Failed to regenerate squid config: %v", writeErr) + return nil + } - // Reconfigure squid if running and config changed - if !changed { - return - } - cmd := container.Cmd(rt) - names, err := rt.PS("", "{{.Names}}") - if err != nil { - ui.Warnf("Failed to list containers: %v", err) - return - } - for _, n := range names { - if n == SquidContainer { - reconfigureSquid(cmd) - break + // Reconfigure squid if running and config changed + if !changed { + return nil + } + cmd := container.Cmd(rt) + names, err := rt.PS("", "{{.Names}}") + if err != nil { + ui.Warnf("Failed to list containers: %v", err) + return nil } + for _, n := range names { + if n == SquidContainer { + reconfigureSquid(cmd) + break + } + } + return nil + }); err != nil { + ui.Warnf("Failed to update Squid session state: %v", err) } } @@ -173,7 +252,10 @@ func collectAllSessionURLs() []string { } for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { line = strings.TrimSpace(line) - if line != "" && !seen[line] { + if line == "" || strings.HasPrefix(line, "host-port:") { + continue + } + if !seen[line] { seen[line] = true urls = append(urls, line) } @@ -182,19 +264,58 @@ func collectAllSessionURLs() []string { return urls } +// collectAllSessionHostPorts reads all session files and returns deduplicated host ports. +func collectAllSessionHostPorts() []int { + dir := sessionDir() + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var ports []int + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".urls") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "host-port:") { + continue + } + p, err := strconv.Atoi(strings.TrimPrefix(line, "host-port:")) + if err == nil { + ports = append(ports, p) + } + } + } + return NormalizeHostPorts(ports) +} + // StartSquidProxy starts the Squid proxy container. -func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []string) error { +func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []string, hostPorts []int) error { + err := withSquidLock(func() error { + return startSquidProxyLocked(rt, containerName, extraURLs, hostPorts) + }) + if err != nil { + _ = os.Remove(filepath.Join(sessionDir(), containerName+".urls")) + } + return err +} + +func startSquidProxyLocked(rt container.Runtime, containerName string, extraURLs []string, hostPorts []int) error { cmd := container.Cmd(rt) // Register session URLs - if len(extraURLs) > 0 { - if err := RegisterSessionURLs(containerName, extraURLs); err != nil { - ui.Warnf("Failed to register session URLs: %v", err) - } + if err := RegisterSessionAccess(containerName, extraURLs, hostPorts); err != nil { + ui.Warnf("Failed to register session firewall access: %v", err) } // Collect all session URLs for config generation allExtraURLs := collectAllSessionURLs() + allHostPorts := collectAllSessionHostPorts() // Check if already running names, err := rt.PS("", "{{.Names}}") @@ -204,7 +325,7 @@ func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []str for _, n := range names { if n == SquidContainer { // Regenerate config and reload only if it changed. - changed, writeErr := writeSquidConfig(rt, allExtraURLs) + changed, writeErr := writeSquidConfig(rt, allExtraURLs, allHostPorts) if writeErr != nil { return writeErr } @@ -222,7 +343,7 @@ func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []str EnsureNetworks(rt) // Generate config - if _, err := writeSquidConfig(rt, allExtraURLs); err != nil { + if _, err := writeSquidConfig(rt, allExtraURLs, allHostPorts); err != nil { return err } @@ -285,40 +406,51 @@ func GetProxyEnvVars(rt container.Runtime) []string { // CleanupSquidIfUnused stops squid if no agent containers are running. func CleanupSquidIfUnused(rt container.Runtime) { - cmd := container.Cmd(rt) - names, err := rt.PS("", "{{.Names}}") - if err != nil { - ui.Warnf("Failed to list containers: %v", err) - return - } - running := 0 - squidRunning := false - for _, n := range names { - if n == SquidContainer { - squidRunning = true - continue + if err := withSquidLock(func() error { + cmd := container.Cmd(rt) + names, err := rt.PS("", "{{.Names}}") + if err != nil { + ui.Warnf("Failed to list containers: %v", err) + return nil } - if strings.HasPrefix(n, "exitbox-") { - running++ + running := 0 + squidRunning := false + for _, n := range names { + if n == SquidContainer { + squidRunning = true + continue + } + if strings.HasPrefix(n, "exitbox-") { + running++ + } } - } - if running == 0 && squidRunning { - ui.Info("Stopping Squid proxy (no running agents)...") - // Stop first (handles restart policy), then remove. - _ = exec.Command(cmd, "stop", SquidContainer).Run() - if rmErr := exec.Command(cmd, "rm", "-f", SquidContainer).Run(); rmErr != nil { - ui.Warnf("Failed to remove Squid proxy: %v", rmErr) + if running == 0 && sessionFileCount() == 0 && squidRunning { + ui.Info("Stopping Squid proxy (no running agents)...") + // Stop first (handles restart policy), then remove. + _ = exec.Command(cmd, "stop", SquidContainer).Run() + if rmErr := exec.Command(cmd, "rm", "-f", SquidContainer).Run(); rmErr != nil { + ui.Warnf("Failed to remove Squid proxy: %v", rmErr) + } } - } - if running == 0 { - // Clean stale session files - _ = os.RemoveAll(sessionDir()) + if running == 0 && sessionFileCount() == 0 { + // Clean stale session files + _ = os.RemoveAll(sessionDir()) + } + return nil + }); err != nil { + ui.Warnf("Failed to clean up Squid proxy: %v", err) } } // AddSessionURLAndReload adds a domain to a container's session URLs and // hot-reloads Squid so the change takes effect immediately. func AddSessionURLAndReload(rt container.Runtime, containerName string, domain string) error { + return withSquidLock(func() error { + return addSessionURLAndReloadLocked(rt, containerName, domain) + }) +} + +func addSessionURLAndReloadLocked(rt container.Runtime, containerName string, domain string) error { cmd := container.Cmd(rt) dir := sessionDir() if err := os.MkdirAll(dir, 0755); err != nil { @@ -328,12 +460,20 @@ func AddSessionURLAndReload(rt container.Runtime, containerName string, domain s // Read existing session URLs for this container. urlFile := filepath.Join(dir, containerName+".urls") var urls []string + var hostPorts []int if data, err := os.ReadFile(urlFile); err == nil { for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { line = strings.TrimSpace(line) - if line != "" { - urls = append(urls, line) + if line == "" { + continue + } + if strings.HasPrefix(line, "host-port:") { + if p, err := strconv.Atoi(strings.TrimPrefix(line, "host-port:")); err == nil { + hostPorts = append(hostPorts, p) + } + continue } + urls = append(urls, line) } } @@ -346,12 +486,13 @@ func AddSessionURLAndReload(rt container.Runtime, containerName string, domain s urls = append(urls, domain) // Write back and regenerate config. - if err := RegisterSessionURLs(containerName, urls); err != nil { + if err := RegisterSessionAccess(containerName, urls, hostPorts); err != nil { return err } allURLs := collectAllSessionURLs() - changed, writeErr := writeSquidConfig(rt, allURLs) + allHostPorts := collectAllSessionHostPorts() + changed, writeErr := writeSquidConfig(rt, allURLs, allHostPorts) if writeErr != nil { return writeErr } @@ -377,7 +518,7 @@ func AddSessionURLAndReload(rt container.Runtime, containerName string, domain s // writeSquidConfig generates and writes the squid config. Returns (changed, error) // where changed indicates whether the config file content actually changed. -func writeSquidConfig(rt container.Runtime, extraURLs []string) (bool, error) { +func writeSquidConfig(rt container.Runtime, extraURLs []string, sessionHostPorts []int) (bool, error) { subnet, err := GetNetworkSubnet(rt, InternalNetwork) if err != nil { return false, fmt.Errorf("could not detect internal network subnet: %w", err) @@ -385,8 +526,9 @@ func writeSquidConfig(rt container.Runtime, extraURLs []string) (bool, error) { al := config.LoadAllowlistOrDefault() domains := al.AllDomains() + hostPorts := NormalizeHostPorts(append(append([]int{}, al.HostPorts...), sessionHostPorts...)) - content := GenerateSquidConfig(subnet, domains, extraURLs) + content := GenerateSquidConfigWithHostPorts(subnet, domains, extraURLs, hostPorts) configFile := filepath.Join(config.Cache, "squid.conf") if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil { return false, fmt.Errorf("failed to create config directory: %w", err) diff --git a/internal/network/session_test.go b/internal/network/session_test.go index 0055129..bb63dc3 100644 --- a/internal/network/session_test.go +++ b/internal/network/session_test.go @@ -57,3 +57,50 @@ func TestSessionURLLifecycle(t *testing.T) { t.Errorf("expected 0 URLs after cleanup, got %d", len(empty)) } } + +func TestRegisterSessionURLsCreatesMarkerWithoutExtraURLs(t *testing.T) { + tmpDir := t.TempDir() + origCache := config.Cache + config.Cache = tmpDir + defer func() { config.Cache = origCache }() + + if err := RegisterSessionURLs("container-a", nil); err != nil { + t.Fatalf("RegisterSessionURLs: %v", err) + } + if got := sessionFileCount(); got != 1 { + t.Fatalf("sessionFileCount() = %d, want 1", got) + } + if urls := collectAllSessionURLs(); len(urls) != 0 { + t.Fatalf("collectAllSessionURLs() = %v, want empty", urls) + } +} + +func TestSessionHostPortsLifecycle(t *testing.T) { + tmpDir := t.TempDir() + origCache := config.Cache + config.Cache = tmpDir + defer func() { config.Cache = origCache }() + + if err := RegisterSessionAccess("container-a", []string{"example.com"}, []int{3000, 5173, 3000, 0}); err != nil { + t.Fatalf("RegisterSessionAccess(a): %v", err) + } + if err := RegisterSessionAccess("container-b", nil, []int{8080, 5173}); err != nil { + t.Fatalf("RegisterSessionAccess(b): %v", err) + } + + ports := collectAllSessionHostPorts() + want := []int{3000, 5173, 8080} + if len(ports) != len(want) { + t.Fatalf("host ports = %v, want %v", ports, want) + } + for i := range want { + if ports[i] != want[i] { + t.Fatalf("host ports = %v, want %v", ports, want) + } + } + + urls := collectAllSessionURLs() + if len(urls) != 1 || urls[0] != "example.com" { + t.Fatalf("session URLs = %v, want [example.com]", urls) + } +} diff --git a/internal/network/squidconf.go b/internal/network/squidconf.go index da3cbce..e6996b3 100644 --- a/internal/network/squidconf.go +++ b/internal/network/squidconf.go @@ -19,6 +19,7 @@ package network import ( "fmt" "net" + "sort" "strings" "github.com/cloud-exit/exitbox/internal/ui" @@ -26,7 +27,14 @@ import ( // GenerateSquidConfig generates the squid.conf content. func GenerateSquidConfig(subnet string, domains []string, extraURLs []string) string { + return GenerateSquidConfigWithHostPorts(subnet, domains, extraURLs, nil) +} + +// GenerateSquidConfigWithHostPorts generates the squid.conf content, including +// host loopback ports that agent containers may access via localhost relays. +func GenerateSquidConfigWithHostPorts(subnet string, domains []string, extraURLs []string, hostPorts []int) string { var b strings.Builder + hostPorts = NormalizeHostPorts(hostPorts) b.WriteString(`# Squid Configuration for Agentbox http_port 3128 @@ -34,6 +42,11 @@ shutdown_lifetime 1 seconds # Access Control Lists acl SSL_ports port 443 +`) + if len(hostPorts) > 0 { + fmt.Fprintf(&b, "acl SSL_ports port %s\t# configured host loopback ports\n", joinPorts(hostPorts)) + } + b.WriteString(` acl Safe_ports port 80 # http acl Safe_ports port 21 # ftp acl Safe_ports port 443 # https @@ -44,6 +57,11 @@ acl Safe_ports port 280 # http-mgmt acl Safe_ports port 488 # gss-http acl Safe_ports port 591 # filemaker acl Safe_ports port 777 # multiling http +`) + if len(hostPorts) > 0 { + fmt.Fprintf(&b, "acl Safe_ports port %s\t# configured host loopback ports\n", joinPorts(hostPorts)) + } + b.WriteString(` acl CONNECT method CONNECT # Deny requests to certain unsafe ports @@ -115,6 +133,10 @@ acl localhost src 127.0.0.1/32 for _, e := range ipEntries { fmt.Fprintf(&b, "acl allowed_ips dst %s\n", e) } + if len(hostPorts) > 0 { + b.WriteString("acl host_loopback dstdomain host.docker.internal\n") + fmt.Fprintf(&b, "acl host_loopback_ports port %s\n", joinPorts(hostPorts)) + } hasDomains := len(domainEntries) > 0 if !hasDomains && len(ipEntries) == 0 { @@ -130,6 +152,9 @@ acl localhost src 127.0.0.1/32 if len(ipEntries) > 0 { b.WriteString("http_access allow agent_sources allowed_ips\n") } + if len(hostPorts) > 0 { + b.WriteString("http_access allow agent_sources host_loopback host_loopback_ports\n") + } b.WriteString(` # Deny everything else @@ -148,6 +173,37 @@ negative_dns_ttl 30 seconds return b.String() } +// NormalizeHostPorts returns valid TCP ports sorted and deduplicated. +func NormalizeHostPorts(ports []int) []int { + seen := make(map[int]struct{}, len(ports)) + var out []int + for _, p := range ports { + if p < 1 || p > 65535 { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + sort.Ints(out) + return out +} + +func joinPorts(ports []int) string { + parts := make([]string, 0, len(ports)) + for _, p := range ports { + parts = append(parts, fmt.Sprintf("%d", p)) + } + return strings.Join(parts, " ") +} + +// HostPortsEnvValue serializes valid host ports for the container entrypoint. +func HostPortsEnvValue(ports []int) string { + return strings.ReplaceAll(joinPorts(NormalizeHostPorts(ports)), " ", ",") +} + // removeRedundantSubdomains filters out entries that are subdomains of another // entry. E.g. if ".icy-veins.com" is present, ".www.icy-veins.com" is removed // since the parent already covers all subdomains in squid's dstdomain matching. diff --git a/internal/network/squidconf_test.go b/internal/network/squidconf_test.go index d0e827f..3dd8e66 100644 --- a/internal/network/squidconf_test.go +++ b/internal/network/squidconf_test.go @@ -158,6 +158,22 @@ func TestGenerateSquidConfig_AllowAccess(t *testing.T) { } } +func TestGenerateSquidConfig_HostPorts(t *testing.T) { + conf := GenerateSquidConfigWithHostPorts("10.89.0.0/24", []string{"example.com"}, nil, []int{5173, 3000, 3000, 0}) + + for _, required := range []string{ + "acl SSL_ports port 3000 5173", + "acl Safe_ports port 3000 5173", + "acl host_loopback dstdomain host.docker.internal", + "acl host_loopback_ports port 3000 5173", + "http_access allow agent_sources host_loopback host_loopback_ports", + } { + if !strings.Contains(conf, required) { + t.Errorf("config missing host-port directive: %q", required) + } + } +} + func TestGetSquidDNSServers_Default(t *testing.T) { os.Unsetenv("EXITBOX_SQUID_DNS") servers := getSquidDNSServers() diff --git a/internal/profile/dockerfile.go b/internal/profile/dockerfile.go index 1c69e11..1417e5f 100644 --- a/internal/profile/dockerfile.go +++ b/internal/profile/dockerfile.go @@ -65,22 +65,7 @@ RUN python3 -m venv /home/user/.venv && \ ENV PATH="/home/user/.venv/bin:$PATH" ` case "go": - return `RUN set -e && \ - case "$(uname -m)" in \ - x86_64|amd64) GO_ARCH="amd64" ;; \ - aarch64|arm64) GO_ARCH="arm64" ;; \ - *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; \ - esac && \ - GO_VERSION="$(wget -qO- https://go.dev/VERSION?m=text | head -n1)" && \ - GO_TARBALL="${GO_VERSION}.linux-${GO_ARCH}.tar.gz" && \ - GO_SHA256="$(wget -qO- https://go.dev/dl/?mode=json | jq -r --arg f "$GO_TARBALL" '.[0].files[] | select(.filename == $f) | .sha256')" && \ - test -n "$GO_SHA256" && \ - wget -q -O /tmp/go.tar.gz "https://go.dev/dl/${GO_TARBALL}" && \ - echo "${GO_SHA256} /tmp/go.tar.gz" | sha256sum -c - && \ - tar -C /usr/local -xzf /tmp/go.tar.gz && \ - rm -f /tmp/go.tar.gz && \ - ln -sf /usr/local/go/bin/go /usr/local/bin/go && \ - ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + return `# Go profile - compiler and gofmt are installed via apk package "go". RUN set -e && \ case "$(uname -m)" in \ x86_64|amd64) LINT_ARCH="amd64" ;; \ diff --git a/internal/profile/dockerfile_test.go b/internal/profile/dockerfile_test.go new file mode 100644 index 0000000..8dababe --- /dev/null +++ b/internal/profile/dockerfile_test.go @@ -0,0 +1,46 @@ +// ExitBox - Multi-Agent Container Sandbox +// Copyright (C) 2026 Cloud Exit B.V. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package profile + +import ( + "strings" + "testing" +) + +func TestGoProfileUsesAPKGoPackage(t *testing.T) { + pkgs := CollectPackages([]string{"go"}) + if len(pkgs) != 1 || pkgs[0] != "go" { + t.Fatalf("CollectPackages([go]) = %v, want [go]", pkgs) + } +} + +func TestGoCustomSnippetDoesNotDownloadGoDevToolchain(t *testing.T) { + snippet := CustomSnippet("go") + for _, forbidden := range []string{ + "go.dev/VERSION", + "go.dev/dl", + "GO_TARBALL", + "/usr/local/go", + } { + if strings.Contains(snippet, forbidden) { + t.Fatalf("Go CustomSnippet contains %q; compiler should be installed via apk", forbidden) + } + } + if !strings.Contains(snippet, "golangci-lint") { + t.Fatal("Go CustomSnippet should still install golangci-lint") + } +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 69d337b..1540cb4 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -36,7 +36,7 @@ func AllRoles() []Role { {"javascript", "Compatibility alias for node profile", "nodejs npm"}, {"python", "Python 3 with pip and venv", ""}, {"rust", "Rust toolchain (rust + cargo via apk)", "rust cargo"}, - {"go", "Go runtime (latest stable for host arch, checksum verified)", ""}, + {"go", "Go toolchain (go + gofmt via apk)", "go"}, {"java", "OpenJDK with Maven and Gradle", "openjdk17-jdk maven gradle"}, {"dotnet", ".NET 8 SDK (dotnet CLI)", "dotnet8-sdk"}, {"ruby", "Ruby runtime with bundler", "ruby ruby-dev readline-dev yaml-dev sqlite-dev sqlite libxml2-dev libxslt-dev curl-dev"}, diff --git a/internal/run/run.go b/internal/run/run.go index 72970ef..9474e46 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -64,6 +64,7 @@ type Options struct { EnvVars []string IncludeDirs []string AllowURLs []string + HostPorts []int Passthrough []string Verbose bool StatusBar bool @@ -147,6 +148,7 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) { // Ollama mode: route traffic through the firewall to host Ollama. if opts.Ollama { opts.AllowURLs = append(opts.AllowURLs, "host.docker.internal") + opts.HostPorts = append(opts.HostPorts, 11434) ui.Infof("Ollama mode: routing traffic through firewall to host") } @@ -156,13 +158,22 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) { // all container ports directly (e.g. OAuth callbacks). args = append(args, "--network", "host") } else { + allowlist := config.LoadAllowlistOrDefault() + hostPorts := network.NormalizeHostPorts(append(append([]int{}, allowlist.HostPorts...), opts.HostPorts...)) network.EnsureNetworks(rt) args = append(args, "--network", network.InternalNetwork) - if err := network.StartSquidProxy(rt, containerName, opts.AllowURLs); err != nil { + if err := network.StartSquidProxy(rt, containerName, opts.AllowURLs, opts.HostPorts); err != nil { return 1, fmt.Errorf("failed to start firewall (Squid proxy): %w", err) } + defer func() { + network.RemoveSessionURLs(rt, containerName) + network.CleanupSquidIfUnused(rt) + }() proxyArgs := network.GetProxyEnvVars(rt) args = append(args, proxyArgs...) + if envValue := network.HostPortsEnvValue(hostPorts); envValue != "" { + args = append(args, "-e", "EXITBOX_HOST_PORTS="+envValue) + } // Publish OAuth callback ports so browser redirects to localhost // reach the agent inside the container. A socat relay in @@ -238,14 +249,6 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) { } } - // Ensure squid cleanup runs on ALL return paths (including early errors). - defer func() { - if len(opts.AllowURLs) > 0 { - network.RemoveSessionURLs(rt, containerName) - } - network.CleanupSquidIfUnused(rt) - }() - // Resource limits memory := "8g" if opts.Memory != "" { @@ -534,6 +537,7 @@ func isReservedEnvVar(key string) bool { "EXITBOX_RESUME_TOKEN": true, "EXITBOX_SESSION_NAME": true, "EXITBOX_KEYBINDINGS": true, + "EXITBOX_HOST_PORTS": true, "EXITBOX_VAULT_ENABLED": true, "EXITBOX_VAULT_READONLY": true, "EXITBOX_RTK": true, diff --git a/internal/run/run_test.go b/internal/run/run_test.go index 2791da3..0e3829f 100644 --- a/internal/run/run_test.go +++ b/internal/run/run_test.go @@ -88,6 +88,7 @@ func TestIsReservedEnvVar(t *testing.T) { "EXITBOX_AUTO_RESUME", "EXITBOX_SESSION_NAME", "EXITBOX_KEYBINDINGS", + "EXITBOX_HOST_PORTS", "EXITBOX_VAULT_ENABLED", "EXITBOX_VAULT_READONLY", "EXITBOX_IDE_PORT", diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index 1ae518f..4f6f71f 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -149,11 +149,11 @@ func applyResult(state State, existingCfg *config.Config) error { cfg.Settings.StatusBar = state.StatusBar cfg.Settings.RTK = state.RTK cfg.Settings.DefaultFlags = config.DefaultFlags{ - NoFirewall: !state.EnableFirewall, - AutoResume: state.AutoResume, - NoEnv: !state.PassEnv, - ReadOnly: state.ReadOnly, - FullGitSupport: state.FullGitSupport, + NoFirewall: !state.EnableFirewall, + AutoResume: state.AutoResume, + NoEnv: !state.PassEnv, + ReadOnly: state.ReadOnly, + FullGitSupport: state.FullGitSupport, } // Persist keybindings — only store non-default values (omitempty in YAML). @@ -210,7 +210,9 @@ func applyResult(state State, existingCfg *config.Config) error { var al *config.Allowlist if len(state.DomainCategories) > 0 { + existingAllowlist := config.LoadAllowlistOrDefault() al = categoriesToAllowlist(state.DomainCategories) + al.HostPorts = existingAllowlist.HostPorts } else { al = config.LoadAllowlistOrDefault() } @@ -275,7 +277,6 @@ func applyLanguageDelta(original []string, selectedLanguages []string) []string return result } - func upsertWorkspace(list []config.Workspace, item config.Workspace) []config.Workspace { for i := range list { if list[i].Name == item.Name { diff --git a/static/build/docker-entrypoint b/static/build/docker-entrypoint index 1d0d283..c41c202 100755 --- a/static/build/docker-entrypoint +++ b/static/build/docker-entrypoint @@ -159,6 +159,7 @@ start_opencode_callback_relay() { } IDE_RELAY_PID="" +HOST_PORT_RELAY_PIDS="" start_ide_relay() { [[ "$AGENT" != "claude" ]] && return @@ -179,6 +180,39 @@ cleanup_ide_relay() { [[ -n "$IDE_RELAY_PID" ]] && kill "$IDE_RELAY_PID" >/dev/null 2>&1 || true } +start_host_port_relays() { + [[ -n "${EXITBOX_HOST_PORTS:-}" ]] || return 0 + [[ -n "${http_proxy:-}" ]] || return 0 + command -v socat >/dev/null 2>&1 || return 0 + + # Parse proxy host and port from http_proxy (e.g. http://172.18.0.2:3128). + local proxy_url="${http_proxy#http://}" + proxy_url="${proxy_url#https://}" + proxy_url="${proxy_url%%/*}" + local proxy_host="${proxy_url%%:*}" + local proxy_port="${proxy_url##*:}" + [[ -z "$proxy_host" ]] && return 0 + [[ "$proxy_port" == "$proxy_host" ]] && proxy_port="3128" + + local ports="${EXITBOX_HOST_PORTS//,/ }" + local port + for port in $ports; do + [[ "$port" =~ ^[0-9]+$ ]] || continue + (( port >= 1 && port <= 65535 )) || continue + socat "TCP-LISTEN:${port},bind=127.0.0.1,reuseaddr,fork" \ + "PROXY:${proxy_host}:host.docker.internal:${port},proxyport=${proxy_port}" \ + >"/tmp/exitbox-host-port-${port}.log" 2>&1 & + HOST_PORT_RELAY_PIDS="${HOST_PORT_RELAY_PIDS} $!" + done +} + +cleanup_host_port_relays() { + local pid + for pid in ${HOST_PORT_RELAY_PIDS:-}; do + kill "$pid" >/dev/null 2>&1 || true + done +} + ensure_workspace_files() { mkdir -p "$(dirname "$GLOBAL_CONFIG_FILE")" mkdir -p "$GLOBAL_WORKSPACE_ROOT" @@ -1799,8 +1833,9 @@ fi start_ide_relay setup_git_credential_helper setup_ssh_proxy_tunnel +start_host_port_relays setup_rtk -trap 'cleanup_relay; cleanup_ide_relay' EXIT INT TERM +trap 'cleanup_relay; cleanup_ide_relay; cleanup_host_port_relays' EXIT INT TERM if [[ "${1:-}" == "__agent-loop" ]]; then shift diff --git a/static/build/docker-entrypoint_test.sh b/static/build/docker-entrypoint_test.sh index cb570c6..f11b661 100644 --- a/static/build/docker-entrypoint_test.sh +++ b/static/build/docker-entrypoint_test.sh @@ -57,7 +57,7 @@ trap 'rm -rf "$TEST_TMPDIR"' EXIT unset EXITBOX_PROJECT_KEY EXITBOX_WORKSPACE_NAME EXITBOX_WORKSPACE_SCOPE unset EXITBOX_AGENT EXITBOX_AUTO_RESUME EXITBOX_IPC_SOCKET EXITBOX_KEYBINDINGS unset EXITBOX_SESSION_NAME EXITBOX_RESUME_TOKEN EXITBOX_VAULT_ENABLED -unset EXITBOX_VAULT_READONLY EXITBOX_RTK +unset EXITBOX_VAULT_READONLY EXITBOX_RTK EXITBOX_HOST_PORTS # Extract functions from the entrypoint using awk (handles nested braces) extract_func() { @@ -1474,6 +1474,73 @@ test_ssh_proxy_tunnel_correct_permissions test_ssh_proxy_tunnel_writes_known_hosts test_ssh_proxy_tunnel_strict_host_key_checking +# ============================================================================ +# Host port relay tests +# ============================================================================ +echo "" +echo "Testing host port relays..." + +START_HOST_PORT_RELAYS_FUNC="$(extract_func start_host_port_relays)" +CLEANUP_HOST_PORT_RELAYS_FUNC="$(extract_func cleanup_host_port_relays)" + +test_host_port_relays_skip_without_ports() { + local tmpdir + tmpdir="$(mktemp -d)" + local result + result="$( + export HOME="$tmpdir" + unset EXITBOX_HOST_PORTS + export http_proxy="http://172.18.0.2:3128" + eval "$START_HOST_PORT_RELAYS_FUNC" + start_host_port_relays + echo "ok" + )" 2>/dev/null + assert_eq "host port relay skips without ports" "ok" "$result" + rm -rf "$tmpdir" +} + +test_host_port_relays_start_configured_ports() { + local tmpdir bindir + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + cat > "$bindir/socat" <<'FAKESOCAT' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$SOCAT_LOG" +sleep 30 +FAKESOCAT + chmod +x "$bindir/socat" + + ( + export PATH="$bindir:$PATH" + export SOCAT_LOG="$tmpdir/socat.log" + export EXITBOX_HOST_PORTS="3000,5173 bad 0" + export http_proxy="http://172.18.0.2:3128" + eval "$START_HOST_PORT_RELAYS_FUNC" + eval "$CLEANUP_HOST_PORT_RELAYS_FUNC" + start_host_port_relays + sleep 0.2 + cleanup_host_port_relays + ) 2>/dev/null + + local content + content="$(cat "$tmpdir/socat.log" 2>/dev/null)" + assert_contains "host port relay listens on 3000" "$content" "TCP-LISTEN:3000,bind=127.0.0.1" + assert_contains "host port relay proxies 3000 to host" "$content" "PROXY:172.18.0.2:host.docker.internal:3000,proxyport=3128" + assert_contains "host port relay listens on 5173" "$content" "TCP-LISTEN:5173,bind=127.0.0.1" + assert_contains "host port relay proxies 5173 to host" "$content" "PROXY:172.18.0.2:host.docker.internal:5173,proxyport=3128" + if [[ "$content" == *"bad"* || "$content" == *"TCP-LISTEN:0"* ]]; then + ((FAIL++)) + ERRORS+=("FAIL: host port relay should ignore invalid ports") + else + ((PASS++)) + fi + rm -rf "$tmpdir" +} + +test_host_port_relays_skip_without_ports +test_host_port_relays_start_configured_ports + # ============================================================================ # RTK setup tests # ============================================================================ diff --git a/static/config/allowlist.yaml b/static/config/allowlist.yaml index 5b2db5f..c3dfab7 100644 --- a/static/config/allowlist.yaml +++ b/static/config/allowlist.yaml @@ -51,4 +51,6 @@ common_services: - sentry.io - statsig.com +host_ports: [] + custom: []