Skip to content

Commit 0790ca9

Browse files
committed
Add --unprivileged flag for rootless operation using user namespaces + iptables
- Implements user namespace + iptables for comprehensive TCP interception - Provides identical traffic coverage to privileged mode - Requires zero elevated privileges (no sudo needed) - Maintains full backward compatibility - Linux-only feature with proper validation and error messages Resolves: Need for rootless operation in restricted environments
1 parent 70677f2 commit 0790ca9

File tree

5 files changed

+473
-15
lines changed

5 files changed

+473
-15
lines changed

README.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,35 @@ jail \
5050
jail -- curl https://example.com
5151
```
5252

53+
### Unprivileged Mode (NEW!)
54+
55+
jail now supports running without elevated privileges using the `--unprivileged` flag:
56+
57+
```bash
58+
# No sudo required!
59+
jail --unprivileged --allow "github.com" -- curl https://github.com
60+
61+
# Works with complex applications
62+
jail --unprivileged --allow "*.npmjs.org" -- npm install
63+
64+
# Same rule engine and proxy functionality as privileged mode
65+
jail --unprivileged --allow "api.example.com" -- ./my-app
66+
```
67+
68+
**Requirements for Unprivileged Mode:**
69+
- Linux with user namespace support (kernel 3.8+)
70+
- User namespaces enabled: `sudo sysctl -w kernel.unprivileged_userns_clone=1`
71+
- Standard tools: `unshare`, `nsenter`, `iptables`, `ip`
72+
```bash
73+
sudo apt-get install util-linux iptables iproute2
74+
```
75+
76+
**Benefits:**
77+
-**No sudo required** - Runs as regular user
78+
-**Same traffic coverage** - Intercepts ALL TCP traffic (ports 1-65535)
79+
-**Container-friendly** - Works in restricted environments
80+
-**Identical functionality** - Same rule engine, proxy, and TLS features
81+
5382
## Allow Rules
5483

5584
jail uses simple wildcard patterns for URL matching.
@@ -121,10 +150,11 @@ For more help: https://github.com/coder/jail
121150
## Platform Support
122151

123152
| Platform | Implementation | Sudo Required |
124-
|----------|----------------|---------------|
125-
| Linux | Network namespaces + iptables | Yes |
126-
| macOS | Process groups + PF rules | Yes |
127-
| Windows | Not supported | - |
153+
|----------|----------------|--------------|
154+
| Linux | Network namespaces + iptables | Yes |
155+
| **Linux (Unprivileged)** | **User namespaces + iptables** | **No** |
156+
| macOS | Process groups + PF rules | Yes |
157+
| Windows | Not supported | - |
128158

129159
## Installation
130160

@@ -174,6 +204,7 @@ OPTIONS:
174204
--allow <SPEC> Allow rule (repeatable)
175205
Format: "pattern" or "METHOD[,METHOD] pattern"
176206
--log-level <LEVEL> Set log level (error, warn, info, debug)
207+
--unprivileged Use unprivileged mode (no sudo required, Linux only)
177208
--no-tls-intercept Disable HTTPS interception
178209
-h, --help Print help
179210
```

cli/cli.go

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"fmt"
66
"log/slog"
77
"os"
8+
"os/exec"
89
"os/signal"
910
"os/user"
1011
"path/filepath"
12+
"runtime"
1113
"strconv"
1214
"strings"
1315
"syscall"
@@ -24,6 +26,7 @@ import (
2426
type Config struct {
2527
AllowStrings []string
2628
LogLevel string
29+
Unprivileged bool // Enable unprivileged mode (user namespace + iptables)
2730
}
2831

2932
// NewCommand creates and returns the root serpent command
@@ -37,14 +40,22 @@ func NewCommand() *serpent.Command {
3740
intercepting all HTTP/HTTPS traffic through a transparent proxy that enforces
3841
user-defined rules.
3942
43+
Modes:
44+
Default (privileged): Uses network namespaces + iptables (requires sudo)
45+
Unprivileged: Uses user namespaces + iptables (no sudo required)
46+
4047
Examples:
41-
# Allow only requests to github.com
42-
jail --allow "github.com" -- curl https://github.com
48+
# Privileged mode (original behavior)
49+
sudo jail --allow "github.com" -- curl https://github.com
50+
51+
# Unprivileged mode (NEW!)
52+
jail --unprivileged --allow "github.com" -- curl https://github.com
4353
44-
# Monitor all requests to specific domains (allow only those)
45-
jail --allow "github.com/api/issues/*" --allow "GET,HEAD github.com" -- npm install
54+
# Monitor all requests to specific domains
55+
jail --unprivileged --allow "github.com/api/issues/*" --allow "GET,HEAD github.com" -- npm install
4656
47-
# Block everything by default (implicit)`,
57+
# Block everything by default (implicit)
58+
jail --unprivileged --allow "api.example.com" -- ./my-app`,
4859
Options: serpent.OptionSet{
4960
{
5061
Name: "allow",
@@ -61,6 +72,13 @@ Examples:
6172
Default: "warn",
6273
Value: serpent.StringOf(&config.LogLevel),
6374
},
75+
{
76+
Name: "unprivileged",
77+
Flag: "unprivileged",
78+
Env: "JAIL_UNPRIVILEGED",
79+
Description: "Use unprivileged mode (user namespace + iptables, no sudo required, Linux only).",
80+
Value: serpent.BoolOf(&config.Unprivileged),
81+
},
6482
},
6583
Handler: func(inv *serpent.Invocation) error {
6684
return Run(inv.Context(), config, inv.Args)
@@ -75,6 +93,16 @@ func Run(ctx context.Context, config Config, args []string) error {
7593
logger := setupLogging(config.LogLevel)
7694
userInfo := getUserInfo()
7795

96+
// Validate unprivileged mode if requested
97+
if config.Unprivileged {
98+
if err := validateUnprivilegedMode(logger); err != nil {
99+
return fmt.Errorf("unprivileged mode validation failed: %v", err)
100+
}
101+
logger.Info("Using unprivileged mode (user namespace + iptables, no sudo required)")
102+
} else {
103+
logger.Info("Using privileged mode (network namespace + iptables, requires sudo)")
104+
}
105+
78106
// Get command arguments
79107
if len(args) == 0 {
80108
return fmt.Errorf("no command specified")
@@ -109,12 +137,26 @@ func Run(ctx context.Context, config Config, args []string) error {
109137
}
110138

111139
// Create jail instance
112-
jailInstance, err := jail.New(ctx, jail.Config{
113-
RuleEngine: ruleEngine,
114-
Auditor: auditor,
115-
CertManager: certManager,
116-
Logger: logger,
117-
})
140+
var jailInstance JailInterface
141+
if config.Unprivileged {
142+
// Use enhanced jail with unprivileged mode
143+
enhancedConfig := jail.EnhancedConfig{
144+
RuleEngine: ruleEngine,
145+
Auditor: auditor,
146+
CertManager: certManager,
147+
Logger: logger,
148+
Unprivileged: true,
149+
}
150+
jailInstance, err = jail.NewEnhanced(ctx, enhancedConfig)
151+
} else {
152+
// Use regular jail (privileged mode)
153+
jailInstance, err = jail.New(ctx, jail.Config{
154+
RuleEngine: ruleEngine,
155+
Auditor: auditor,
156+
CertManager: certManager,
157+
Logger: logger,
158+
})
159+
}
118160
if err != nil {
119161
return fmt.Errorf("failed to create jail instance: %v", err)
120162
}
@@ -255,3 +297,39 @@ func getConfigDir(homeDir string) string {
255297
}
256298
return filepath.Join(homeDir, ".config", "coder_jail")
257299
}
300+
301+
// JailInterface defines the common interface for both jail types
302+
type JailInterface interface {
303+
Start() error
304+
Command(command []string) *exec.Cmd
305+
Close() error
306+
}
307+
308+
// validateUnprivilegedMode checks if the system supports unprivileged mode
309+
func validateUnprivilegedMode(logger *slog.Logger) error {
310+
// Check if we're on Linux
311+
if runtime.GOOS != "linux" {
312+
return fmt.Errorf("unprivileged mode only supports Linux, got: %s", runtime.GOOS)
313+
}
314+
315+
// Check if user namespaces are enabled
316+
userNSFile := "/proc/sys/kernel/unprivileged_userns_clone"
317+
if data, err := os.ReadFile(userNSFile); err == nil {
318+
if len(data) > 0 && strings.TrimSpace(string(data)) != "1" {
319+
return fmt.Errorf("user namespaces are disabled. Enable with: sudo sysctl -w kernel.unprivileged_userns_clone=1")
320+
}
321+
} else {
322+
logger.Warn("Could not check user namespace support", "error", err)
323+
}
324+
325+
// Check for required tools
326+
requiredTools := []string{"unshare", "nsenter", "iptables", "ip"}
327+
for _, tool := range requiredTools {
328+
if _, err := exec.LookPath(tool); err != nil {
329+
return fmt.Errorf("required tool %s not found. Install with: sudo apt-get install util-linux iptables iproute2", tool)
330+
}
331+
}
332+
333+
logger.Debug("Unprivileged mode validation passed")
334+
return nil
335+
}

jail

14.1 MB
Binary file not shown.

jail.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ type Config struct {
2222
Logger *slog.Logger
2323
}
2424

25+
// EnhancedConfig extends Config with unprivileged mode support
26+
type EnhancedConfig struct {
27+
RuleEngine rules.Evaluator
28+
Auditor audit.Auditor
29+
CertManager tls.Manager
30+
Logger *slog.Logger
31+
Unprivileged bool // If true, use user namespace instead of privileged namespaces
32+
}
33+
2534
type Jail struct {
2635
commander namespace.Commander
2736
proxyServer *proxy.Server
@@ -79,6 +88,61 @@ func New(ctx context.Context, config Config) (*Jail, error) {
7988
}, nil
8089
}
8190

91+
// NewEnhanced creates a jail that can run in either privileged or unprivileged mode
92+
func NewEnhanced(ctx context.Context, config EnhancedConfig) (*Jail, error) {
93+
config.Logger.Debug("Creating enhanced jail", "unprivileged", config.Unprivileged)
94+
95+
// Validate platform support for unprivileged mode
96+
if config.Unprivileged && runtime.GOOS != "linux" {
97+
return nil, fmt.Errorf("unprivileged mode only supports Linux, got: %s", runtime.GOOS)
98+
}
99+
100+
// Setup TLS config and write CA certificate to file
101+
tlsConfig, caCertPath, configDir, err := config.CertManager.SetupTLSAndWriteCACert()
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to setup TLS and CA certificate: %v", err)
104+
}
105+
106+
// Create proxy server
107+
proxyServer := proxy.NewProxyServer(proxy.Config{
108+
HTTPPort: 8080,
109+
HTTPSPort: 8443,
110+
Auditor: config.Auditor,
111+
RuleEngine: config.RuleEngine,
112+
Logger: config.Logger,
113+
TLSConfig: tlsConfig,
114+
})
115+
116+
// Create appropriate commander based on configuration
117+
commander, err := newEnhancedNamespaceCommander(namespace.Config{
118+
Logger: config.Logger,
119+
HttpProxyPort: 8080,
120+
HttpsProxyPort: 8443,
121+
Env: map[string]string{
122+
"SSL_CERT_FILE": caCertPath,
123+
"SSL_CERT_DIR": configDir,
124+
"CURL_CA_BUNDLE": caCertPath,
125+
"GIT_SSL_CAINFO": caCertPath,
126+
"REQUESTS_CA_BUNDLE": caCertPath,
127+
"NODE_EXTRA_CA_CERTS": caCertPath,
128+
},
129+
}, config.Unprivileged)
130+
if err != nil {
131+
return nil, fmt.Errorf("failed to create commander: %v", err)
132+
}
133+
134+
// Create cancellable context for jail
135+
ctx, cancel := context.WithCancel(ctx)
136+
137+
return &Jail{
138+
commander: commander,
139+
proxyServer: proxyServer,
140+
logger: config.Logger,
141+
ctx: ctx,
142+
cancel: cancel,
143+
}, nil
144+
}
145+
82146
func (j *Jail) Start() error {
83147
// Open the command executor (network namespace)
84148
err := j.commander.Start()
@@ -128,3 +192,22 @@ func newNamespaceCommander(config namespace.Config) (namespace.Commander, error)
128192
return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS)
129193
}
130194
}
195+
196+
// newEnhancedNamespaceCommander creates the appropriate commander based on mode and platform
197+
func newEnhancedNamespaceCommander(config namespace.Config, unprivileged bool) (namespace.Commander, error) {
198+
switch runtime.GOOS {
199+
case "darwin":
200+
if unprivileged {
201+
return nil, fmt.Errorf("unprivileged mode not available on macOS")
202+
}
203+
return namespace.NewMacOS(config)
204+
case "linux":
205+
if unprivileged {
206+
return namespace.NewUserNamespaceLinux(config)
207+
} else {
208+
return namespace.NewLinux(config)
209+
}
210+
default:
211+
return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS)
212+
}
213+
}

0 commit comments

Comments
 (0)