diff --git a/app/configuration/configure-host-gate.sh b/app/configuration/configure-host-gate.sh new file mode 100644 index 0000000..50f136d --- /dev/null +++ b/app/configuration/configure-host-gate.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +POLICY_FILE="$HOME/.config/host-gate/policy.json" + +if [ ! -f "$POLICY_FILE" ]; then + if [ -f "$MANJIKAZE_DIR/host-gate/configs/default-policy.json" ]; then + mkdir -p "$HOME/.config/host-gate" + cp "$MANJIKAZE_DIR/host-gate/configs/default-policy.json" "$POLICY_FILE" + status "Created default policy config at $POLICY_FILE" + else + status "Host gate is not installed. Install it first via Setup > Choose optional apps." + return 1 + fi +fi + +status "Opening host gate policy config for editing..." +status "After saving, restart the daemon: systemctl --user restart host-gate" + +cursor "$POLICY_FILE" --wait + +if systemctl --user is-active host-gate.service &>/dev/null; then + if gum confirm "Restart host-gate daemon to apply changes?"; then + systemctl --user restart host-gate.service + status "Host gate daemon restarted." + fi +fi diff --git a/app/installations/optional/host-gate.sh b/app/installations/optional/host-gate.sh new file mode 100644 index 0000000..363b7de --- /dev/null +++ b/app/installations/optional/host-gate.sh @@ -0,0 +1,51 @@ +install() { + install_package "go" repo + install_package "zenity" repo + + if ! command -v go &> /dev/null; then + status "Go is required to build host-gate but is not available. Aborting." + return 1 + fi + + status "Building host-gate binaries..." + local HOST_GATE_DIR="$MANJIKAZE_DIR/host-gate" + + (cd "$HOST_GATE_DIR" && make build) + + sudo install -m 755 "$HOST_GATE_DIR/bin/host-gate-daemon" /usr/local/bin/ + sudo install -m 755 "$HOST_GATE_DIR/bin/host-gate-client-linux-amd64" /usr/local/bin/host-gate-client + + local RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/host-gate" + mkdir -p "$RUNTIME_DIR" + cp "$HOST_GATE_DIR"/bin/host-gate-client-linux-* "$RUNTIME_DIR/" + chmod 755 "$RUNTIME_DIR"/host-gate-client-linux-* + + status "Creating default host policy config..." + mkdir -p "$HOME/.config/host-gate" + if [ ! -f "$HOME/.config/host-gate/policy.json" ]; then + cp "$HOST_GATE_DIR/configs/default-policy.json" "$HOME/.config/host-gate/policy.json" + status "Default policy config installed at ~/.config/host-gate/policy.json" + else + status "Host policy config already exists, skipping." + fi + + status "Setting up systemd user service..." + mkdir -p "$HOME/.config/systemd/user" + cp "$HOST_GATE_DIR/systemd/host-gate.service" "$HOME/.config/systemd/user/" + + systemctl --user daemon-reload + systemctl --user enable --now host-gate.service + + status "Host gate installed. Edit ~/.config/host-gate/policy.json to configure allowed commands." +} + +uninstall() { + systemctl --user disable --now host-gate.service 2>/dev/null || true + rm -f "$HOME/.config/systemd/user/host-gate.service" + systemctl --user daemon-reload + + sudo rm -f /usr/local/bin/host-gate-daemon + sudo rm -f /usr/local/bin/host-gate-client + + uninstall_package "zenity" repo +} diff --git a/host-gate/Makefile b/host-gate/Makefile new file mode 100644 index 0000000..4f0b5c6 --- /dev/null +++ b/host-gate/Makefile @@ -0,0 +1,22 @@ +.PHONY: build build-daemon build-client test lint clean + +DAEMON_BIN=host-gate-daemon +CLIENT_BIN=host-gate-client + +build: build-daemon build-client + +build-daemon: + go build -o bin/$(DAEMON_BIN) ./cmd/host-gate-daemon + +build-client: + GOOS=linux GOARCH=amd64 go build -o bin/$(CLIENT_BIN)-linux-amd64 ./cmd/host-gate-client + GOOS=linux GOARCH=arm64 go build -o bin/$(CLIENT_BIN)-linux-arm64 ./cmd/host-gate-client + +test: + go test ./... -v -race -cover + +lint: + golangci-lint run ./... + +clean: + rm -rf bin/ diff --git a/host-gate/README.md b/host-gate/README.md new file mode 100644 index 0000000..5075b6a --- /dev/null +++ b/host-gate/README.md @@ -0,0 +1,195 @@ +# Host Gate + +Secure container-to-host command approval system. Intercepts configured commands inside Docker containers and requires host-side human approval (desktop popup) or physical presence (YubiKey touch) before execution. + +## How It Works + +1. Commands inside the container are intercepted by wrapper scripts +2. The client sends an HMAC-signed request to the host daemon over a Unix socket +3. The daemon checks the host security policy, shows a desktop popup, and optionally requires YubiKey touch +4. If approved, the command either executes on the host (proxy mode) or the container proceeds locally (local mode) + +## Installation + +Host Gate is installed as a manjikaze optional app: + +```bash +manjikaze # then select "Install optional apps" -> "host-gate" +``` + +This builds from source, installs binaries, sets up the systemd user service, and creates a default host policy config. + +### Manual Installation + +```bash +cd host-gate +make build +sudo install -m 755 bin/host-gate-daemon /usr/local/bin/ +sudo install -m 755 bin/host-gate-client-linux-amd64 /usr/local/bin/host-gate-client + +mkdir -p ~/.config/host-gate +cp configs/default-policy.json ~/.config/host-gate/policy.json + +mkdir -p ~/.config/systemd/user +cp systemd/host-gate.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now host-gate.service +``` + +## Configuration + +### Host Policy (`~/.config/host-gate/policy.json`) + +The host policy is the security boundary. It controls which commands are allowed and enforces minimum approval levels. The container cannot weaken these settings. + +```json +{ + "defaultPolicy": "deny", + "rules": [ + { + "match": ["git", "push"], + "allow": true, + "minApproval": "popup", + "minExecution": "proxy" + }, + { + "match": ["git", "push", "--force"], + "allow": true, + "minApproval": "yubikey" + }, + { + "match": ["kubectl", "delete"], + "allow": false + } + ] +} +``` + +- `defaultPolicy`: `"deny"` (safe default) or `"allow"` +- `rules[].match`: Command prefix to match (longest prefix wins) +- `rules[].allow`: Whether this command is permitted at all +- `rules[].minApproval`: Minimum approval mode (`"popup"` or `"yubikey"`) +- `rules[].minExecution`: Minimum execution mode (`"local"` or `"proxy"`) + +When both the container and host specify modes, the **most restrictive** option wins per dimension. + +### Container Config (`.devcontainer/host-gate.json`) + +Per-project config defining which commands to gate: + +```json +{ + "rules": [ + { + "match": ["git", "push"], + "execution": "proxy", + "approval": "popup" + }, + { + "match": ["npm", "publish"], + "execution": "proxy", + "approval": "yubikey" + } + ] +} +``` + +### Daemon Flags + +Set in the systemd unit file or via CLI: + +- `--socket-dir`: Socket directory (default: `$XDG_RUNTIME_DIR/host-gate`) +- `--host-config`: Host policy config path (default: `~/.config/host-gate/policy.json`) +- `--approval-timeout`: Popup timeout (default: `60s`) +- `--yubikey-slot`: YubiKey OTP slot (default: `2`) +- `--yubikey-timeout`: YubiKey touch timeout (default: `30s`) +- `--workspace-map`: Container-to-host path mapping (e.g., `/workspace:/home/user/project`) +- `--log-level`: Log level (default: `info`) + +Edit `~/.config/systemd/user/host-gate.service` to add workspace maps: + +```ini +ExecStart=/usr/local/bin/host-gate-daemon \ + --workspace-map=/workspace:/home/user/my-project +``` + +Then reload: `systemctl --user daemon-reload && systemctl --user restart host-gate` + +## Dev Container Feature + +Local devcontainer features must live inside `.devcontainer/`. Use the init script to set up a project: + +```bash +~/.manjikaze/host-gate/scripts/init-project.sh /path/to/your/project +``` + +This copies the feature files and creates a default container config. Then add to your `devcontainer.json`: + +```json +{ + "features": { + "./host-gate": {} + } +} +``` + +To customize the container config path: + +```json +{ + "features": { + "./host-gate": { + "configPath": "/workspace/.devcontainer/host-gate.json" + } + } +} +``` + +For docker-compose setups, you may also need to add the volume mount explicitly: + +```yaml +volumes: + - ${XDG_RUNTIME_DIR}/host-gate:/var/run/host-gate:ro +``` + +## YubiKey Setup + +Host Gate uses YubiKey HMAC-SHA1 Challenge-Response for physical presence verification. The `yubikey-manager` package is already installed as an essential manjikaze package. + +One-time slot configuration: + +```bash +./scripts/configure-yubikey-slot.sh +# or manually: +ykman otp chalresp --touch --generate 2 +``` + +This configures OTP slot 2 with a random key and requires physical touch. + +## Verification + +```bash +# Check daemon status +systemctl --user status host-gate + +# Health check +curl --unix-socket $XDG_RUNTIME_DIR/host-gate/gate.sock http://host-gate/health +``` + +## Execution Modes + +| Execution | Approval | Use Case | +|-----------|----------|----------| +| `proxy` | `popup` | `git push` -- needs host SSH key | +| `proxy` | `yubikey` | `npm publish` -- needs host token + physical presence | +| `local` | `popup` | Destructive commands -- runs in container, needs approval | +| `local` | `yubikey` | `kubectl delete` -- needs physical presence | + +## Development + +```bash +cd host-gate +make test # Run all tests +make build # Build binaries +make lint # Run linter (requires golangci-lint) +``` diff --git a/host-gate/bin/host-gate-client-linux-amd64 b/host-gate/bin/host-gate-client-linux-amd64 new file mode 100755 index 0000000..76fbbcd Binary files /dev/null and b/host-gate/bin/host-gate-client-linux-amd64 differ diff --git a/host-gate/bin/host-gate-client-linux-arm64 b/host-gate/bin/host-gate-client-linux-arm64 new file mode 100755 index 0000000..5c3ac4e Binary files /dev/null and b/host-gate/bin/host-gate-client-linux-arm64 differ diff --git a/host-gate/bin/host-gate-daemon b/host-gate/bin/host-gate-daemon new file mode 100755 index 0000000..0a9438a Binary files /dev/null and b/host-gate/bin/host-gate-daemon differ diff --git a/host-gate/cmd/host-gate-client/main.go b/host-gate/cmd/host-gate-client/main.go new file mode 100644 index 0000000..02717cf --- /dev/null +++ b/host-gate/cmd/host-gate-client/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + + "github.com/ewout/host-gate/internal/client" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: host-gate-client [args...]") + os.Exit(1) + } + + switch os.Args[1] { + case "exec": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "Usage: host-gate-client exec [args...]") + os.Exit(1) + } + if err := client.ExecCommand(os.Args[2], os.Args[3:]); err != nil { + fmt.Fprintf(os.Stderr, "\033[31m[host-gate] %s\033[0m\n", err) + os.Exit(1) + } + case "setup": + configPath := "/workspace/.devcontainer/host-gate.json" + for i, arg := range os.Args[2:] { + if arg == "--config" && i+1 < len(os.Args[2:]) { + configPath = os.Args[2:][i+1] + } + } + if err := client.Setup(configPath); err != nil { + fmt.Fprintf(os.Stderr, "host-gate setup failed: %s\n", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "Unknown subcommand: %s\nUsage: host-gate-client [args...]\n", os.Args[1]) + os.Exit(1) + } +} diff --git a/host-gate/cmd/host-gate-daemon/main.go b/host-gate/cmd/host-gate-daemon/main.go new file mode 100644 index 0000000..ace5a99 --- /dev/null +++ b/host-gate/cmd/host-gate-daemon/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/ewout/host-gate/internal/daemon" +) + +func main() { + socketDir := flag.String("socket-dir", "", "Socket directory (default: $XDG_RUNTIME_DIR/host-gate)") + hostConfig := flag.String("host-config", "", "Host policy config (default: ~/.config/host-gate/policy.json)") + approvalTimeout := flag.Duration("approval-timeout", 60*time.Second, "Timeout for user approval") + yubikeySlot := flag.Int("yubikey-slot", 2, "YubiKey OTP slot for challenge-response") + yubikeyTimeout := flag.Duration("yubikey-timeout", 30*time.Second, "Timeout for YubiKey touch") + logLevel := flag.String("log-level", "info", "Log level: debug, info, warn, error") + + var workspaceMaps workspaceMapFlags + flag.Var(&workspaceMaps, "workspace-map", "Container-to-host path mapping (e.g., /workspace:/home/user/project). Can be specified multiple times.") + + flag.Parse() + + configureLogging(*logLevel) + + if *socketDir == "" { + xdgRuntime := os.Getenv("XDG_RUNTIME_DIR") + if xdgRuntime == "" { + slog.Error("XDG_RUNTIME_DIR not set and --socket-dir not provided") + os.Exit(1) + } + *socketDir = filepath.Join(xdgRuntime, "host-gate") + } + + if *hostConfig == "" { + home, _ := os.UserHomeDir() + *hostConfig = filepath.Join(home, ".config", "host-gate", "policy.json") + } + + cfg := daemon.Config{ + SocketDir: *socketDir, + HostConfigPath: *hostConfig, + ApprovalTimeout: *approvalTimeout, + YubiKeySlot: *yubikeySlot, + YubiKeyTimeout: *yubikeyTimeout, + WorkspaceMaps: workspaceMaps, + } + + srv, err := daemon.NewServer(cfg) + if err != nil { + slog.Error("Failed to create server", "error", err) + os.Exit(1) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + slog.Info("Shutting down...") + srv.Shutdown(context.Background()) + }() + + if err := srv.ListenAndServe(); err != nil { + slog.Error("Server error", "error", err) + os.Exit(1) + } +} + +type workspaceMapFlags []string + +func (w *workspaceMapFlags) String() string { return "" } +func (w *workspaceMapFlags) Set(val string) error { + *w = append(*w, val) + return nil +} + +func configureLogging(level string) { + var lvl slog.Level + switch level { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))) +} diff --git a/host-gate/configs/default-policy.json b/host-gate/configs/default-policy.json new file mode 100644 index 0000000..1247e4d --- /dev/null +++ b/host-gate/configs/default-policy.json @@ -0,0 +1,53 @@ +// Host Gate Policy Configuration +// +// This file controls which commands containers are allowed to execute +// through host-gate, and the minimum security level required for each. +// +// defaultPolicy: "deny" or "allow" +// What happens when no rule matches. "deny" is recommended (safe by default). +// +// rules[].match: ["command", "arg1", "arg2", ...] +// Prefix to match against the full command. Longest match wins. +// Example: ["git", "push", "--force"] is more specific than ["git", "push"]. +// +// rules[].allow: true/false +// Whether this command is permitted at all. false = always denied (HTTP 403). +// +// rules[].minApproval: "none", "popup", or "yubikey" +// Minimum approval mode. The most restrictive value between the container +// config and this setting wins. none < popup < yubikey. +// "none" = no approval needed, command runs immediately. +// +// rules[].minExecution: "local" or "proxy" +// Minimum execution mode. local = container runs the command after approval, +// proxy = host runs the command and streams output. local < proxy. +// +{ + "defaultPolicy": "deny", + "rules": [ + { + "match": ["git", "push"], + "allow": true, + "minApproval": "popup", + "minExecution": "proxy" + }, + { + "match": ["git", "push", "--force"], + "allow": true, + "minApproval": "yubikey", + "minExecution": "proxy" + }, + { + "match": ["npm", "publish"], + "allow": true, + "minApproval": "yubikey", + "minExecution": "proxy" + }, + { + "match": ["gh"], + "allow": true, + "minApproval": "popup", + "minExecution": "proxy" + } + ] +} diff --git a/host-gate/devcontainer-feature/src/host-gate/devcontainer-feature.json b/host-gate/devcontainer-feature/src/host-gate/devcontainer-feature.json new file mode 100644 index 0000000..89dc595 --- /dev/null +++ b/host-gate/devcontainer-feature/src/host-gate/devcontainer-feature.json @@ -0,0 +1,35 @@ +{ + "id": "host-gate", + "version": "1.0.0", + "name": "Host Gate", + "description": "Secure container-to-host command approval with popup dialogs and YubiKey support", + "options": { + "configPath": { + "type": "string", + "default": "/workspace/.devcontainer/host-gate.json", + "description": "Path to host-gate configuration file inside the container" + }, + "hostWorkspaceFolder": { + "type": "string", + "default": "", + "description": "Absolute path to the workspace on the host (e.g. /home/user/projects/my-app)" + }, + "containerWorkspaceFolder": { + "type": "string", + "default": "/workspace", + "description": "Workspace folder path inside the container" + } + }, + "mounts": [ + { + "source": "${localEnv:XDG_RUNTIME_DIR}/host-gate", + "target": "/var/run/host-gate", + "type": "bind" + } + ], + "containerEnv": { + "HOST_GATE_SOCKET": "/var/run/host-gate/gate.sock", + "HOST_GATE_HMAC_KEY": "/var/run/host-gate/hmac.key" + }, + "postStartCommand": "host-gate-setup" +} diff --git a/host-gate/devcontainer-feature/src/host-gate/install.sh b/host-gate/devcontainer-feature/src/host-gate/install.sh new file mode 100755 index 0000000..86f8a18 --- /dev/null +++ b/host-gate/devcontainer-feature/src/host-gate/install.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +CONFIG_PATH="${CONFIGPATH:-/workspace/.devcontainer/host-gate.json}" +HOST_WS="${HOSTWORKSPACEFOLDER:-}" +CONTAINER_WS="${CONTAINERWORKSPACEFOLDER:-/workspace}" + +cat > /usr/local/bin/host-gate-setup <&2; exit 1 ;; +esac + +BINARY_NAME="host-gate-client-linux-\${ARCH}" + +if [ ! -f "/usr/local/bin/host-gate-client" ]; then + if [ -f "/var/run/host-gate/\${BINARY_NAME}" ]; then + cp "/var/run/host-gate/\${BINARY_NAME}" /usr/local/bin/host-gate-client + chmod 755 /usr/local/bin/host-gate-client + else + echo "host-gate: client binary not found at /var/run/host-gate/\${BINARY_NAME}" >&2 + echo "host-gate: ensure the host-gate daemon is running on the host" >&2 + exit 0 + fi +fi + +exec host-gate-client setup --config "$CONFIG_PATH" +SETUP +chmod 755 /usr/local/bin/host-gate-setup + +ENVFILE="/etc/profile.d/host-gate-env.sh" +echo "export HOST_GATE_HOST_WORKSPACE=\"$HOST_WS\"" > "$ENVFILE" +echo "export HOST_GATE_CONTAINER_WORKSPACE=\"$CONTAINER_WS\"" >> "$ENVFILE" + +if [ -f /etc/bash.bashrc ]; then + cat "$ENVFILE" >> /etc/bash.bashrc +fi +if [ -d /etc/zsh ]; then + cat "$ENVFILE" >> /etc/zsh/zshenv 2>/dev/null || true +fi diff --git a/host-gate/devcontainer-feature/test/host-gate/scenarios.json b/host-gate/devcontainer-feature/test/host-gate/scenarios.json new file mode 100644 index 0000000..b3dbc6b --- /dev/null +++ b/host-gate/devcontainer-feature/test/host-gate/scenarios.json @@ -0,0 +1,8 @@ +{ + "test": { + "image": "ubuntu:latest", + "features": { + "host-gate": {} + } + } +} diff --git a/host-gate/devcontainer-feature/test/host-gate/test.sh b/host-gate/devcontainer-feature/test/host-gate/test.sh new file mode 100755 index 0000000..2fb65d5 --- /dev/null +++ b/host-gate/devcontainer-feature/test/host-gate/test.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# Verify client binary is installed +if ! command -v host-gate-client &> /dev/null; then + echo "FAIL: host-gate-client not found in PATH" + exit 1 +fi + +# Verify environment variables are set +if [ -z "$HOST_GATE_SOCKET" ]; then + echo "FAIL: HOST_GATE_SOCKET not set" + exit 1 +fi + +if [ -z "$HOST_GATE_HMAC_KEY" ]; then + echo "FAIL: HOST_GATE_HMAC_KEY not set" + exit 1 +fi + +echo "PASS: host-gate feature installed correctly" diff --git a/host-gate/go.mod b/host-gate/go.mod new file mode 100644 index 0000000..158f731 --- /dev/null +++ b/host-gate/go.mod @@ -0,0 +1,5 @@ +module github.com/ewout/host-gate + +go 1.22 + +require github.com/google/uuid v1.6.0 diff --git a/host-gate/go.sum b/host-gate/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/host-gate/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/host-gate/internal/client/client.go b/host-gate/internal/client/client.go new file mode 100644 index 0000000..6a996e8 --- /dev/null +++ b/host-gate/internal/client/client.go @@ -0,0 +1,58 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + + "github.com/ewout/host-gate/internal/protocol" +) + +type SocketClient struct { + socketPath string + httpClient *http.Client +} + +func NewSocketClient(socketPath string) *SocketClient { + return &SocketClient{ + socketPath: socketPath, + httpClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + }, + } +} + +func (c *SocketClient) Execute(req protocol.ExecuteRequest) (io.ReadCloser, int, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, 0, fmt.Errorf("marshal request: %w", err) + } + + resp, err := c.httpClient.Post("http://host-gate/execute", "application/json", bytes.NewReader(body)) + if err != nil { + return nil, 0, fmt.Errorf("send request: %w", err) + } + + return resp.Body, resp.StatusCode, nil +} + +func (c *SocketClient) Health() error { + resp, err := c.httpClient.Get("http://host-gate/health") + if err != nil { + return fmt.Errorf("health check: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check returned status %d", resp.StatusCode) + } + return nil +} diff --git a/host-gate/internal/client/config.go b/host-gate/internal/client/config.go new file mode 100644 index 0000000..c5ba5ff --- /dev/null +++ b/host-gate/internal/client/config.go @@ -0,0 +1,67 @@ +package client + +import ( + "encoding/json" + "os" +) + +type Config struct { + Rules []Rule `json:"rules"` +} + +type Rule struct { + Match []string `json:"match"` + Execution string `json:"execution"` + Approval string `json:"approval"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func (c *Config) MatchRule(command string, args []string) *Rule { + fullCmd := make([]string, 0, 1+len(args)) + fullCmd = append(fullCmd, command) + fullCmd = append(fullCmd, args...) + + var bestMatch *Rule + bestLen := 0 + for i, rule := range c.Rules { + if len(rule.Match) > len(fullCmd) { + continue + } + match := true + for j, part := range rule.Match { + if part != fullCmd[j] { + match = false + break + } + } + if match && len(rule.Match) > bestLen { + bestMatch = &c.Rules[i] + bestLen = len(rule.Match) + } + } + return bestMatch +} + +// UniqueBaseCommands returns the set of unique first elements from all rule matches. +func (c *Config) UniqueBaseCommands() []string { + seen := make(map[string]bool) + var cmds []string + for _, rule := range c.Rules { + if len(rule.Match) > 0 && !seen[rule.Match[0]] { + seen[rule.Match[0]] = true + cmds = append(cmds, rule.Match[0]) + } + } + return cmds +} diff --git a/host-gate/internal/client/config_test.go b/host-gate/internal/client/config_test.go new file mode 100644 index 0000000..053cb2e --- /dev/null +++ b/host-gate/internal/client/config_test.go @@ -0,0 +1,95 @@ +package client + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "host-gate.json") + os.WriteFile(path, []byte(`{ + "rules": [ + {"match": ["git", "push"], "execution": "proxy", "approval": "popup"} + ] + }`), 0644) + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(cfg.Rules)) + } +} + +func TestMatchRuleLongestPrefix(t *testing.T) { + cfg := Config{ + Rules: []Rule{ + {Match: []string{"git", "push"}, Execution: "proxy", Approval: "popup"}, + {Match: []string{"git", "push", "--force"}, Execution: "proxy", Approval: "yubikey"}, + }, + } + + rule := cfg.MatchRule("git", []string{"push", "--force", "origin"}) + if rule == nil { + t.Fatal("expected a match") + } + if rule.Approval != "yubikey" { + t.Fatalf("expected yubikey, got %s", rule.Approval) + } +} + +func TestMatchRuleFallsBackToShorterMatch(t *testing.T) { + cfg := Config{ + Rules: []Rule{ + {Match: []string{"git", "push"}, Execution: "proxy", Approval: "popup"}, + {Match: []string{"git", "push", "--force"}, Execution: "proxy", Approval: "yubikey"}, + }, + } + + rule := cfg.MatchRule("git", []string{"push", "--force-with-lease"}) + if rule == nil { + t.Fatal("expected a match") + } + if rule.Approval != "popup" { + t.Fatalf("expected popup (git push match, not git push --force), got %s", rule.Approval) + } +} + +func TestMatchRuleNoMatch(t *testing.T) { + cfg := Config{ + Rules: []Rule{ + {Match: []string{"git", "push"}, Execution: "proxy", Approval: "popup"}, + }, + } + + rule := cfg.MatchRule("npm", []string{"install"}) + if rule != nil { + t.Fatal("expected no match") + } +} + +func TestUniqueBaseCommands(t *testing.T) { + cfg := Config{ + Rules: []Rule{ + {Match: []string{"git", "push"}, Execution: "proxy", Approval: "popup"}, + {Match: []string{"git", "push", "--force"}, Execution: "proxy", Approval: "yubikey"}, + {Match: []string{"npm", "publish"}, Execution: "proxy", Approval: "yubikey"}, + }, + } + + cmds := cfg.UniqueBaseCommands() + if len(cmds) != 2 { + t.Fatalf("expected 2 unique commands, got %d: %v", len(cmds), cmds) + } + + found := make(map[string]bool) + for _, c := range cmds { + found[c] = true + } + if !found["git"] || !found["npm"] { + t.Fatalf("expected git and npm, got %v", cmds) + } +} diff --git a/host-gate/internal/client/exec.go b/host-gate/internal/client/exec.go new file mode 100644 index 0000000..252ce3c --- /dev/null +++ b/host-gate/internal/client/exec.go @@ -0,0 +1,162 @@ +package client + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/ewout/host-gate/internal/protocol" + "github.com/google/uuid" +) + +func ExecCommand(command string, args []string) error { + cfg, err := LoadConfig(configPath()) + if err != nil { + return passthrough(command, args) + } + + rule := cfg.MatchRule(command, args) + if rule == nil { + return passthrough(command, args) + } + + nonce := uuid.New().String() + timestamp := time.Now().Unix() + cwd, _ := os.Getwd() + + req := protocol.ExecuteRequest{ + Version: 1, + Nonce: nonce, + Timestamp: timestamp, + Command: command, + Args: args, + Cwd: cwd, + HostCwd: mapToHostCwd(cwd), + Execution: rule.Execution, + Approval: rule.Approval, + } + + key, err := os.ReadFile(hmacKeyPath()) + if err != nil { + return fmt.Errorf("read HMAC key: %w (is host-gate daemon running?)", err) + } + req.HMAC = protocol.Sign(key, req.CanonicalString()) + + client := NewSocketClient(socketPath()) + body, statusCode, err := client.Execute(req) + if err != nil { + return fmt.Errorf("host-gate request failed: %w", err) + } + defer body.Close() + + if statusCode != http.StatusOK { + errBody, _ := io.ReadAll(body) + return fmt.Errorf("host-gate daemon error (HTTP %d): %s", statusCode, strings.TrimSpace(string(errBody))) + } + + return processStream(body, command, args, rule.Execution) +} + +func processStream(resp io.Reader, command string, args []string, execution string) error { + scanner := bufio.NewScanner(resp) + for scanner.Scan() { + var msg protocol.StreamMsg + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + continue + } + + switch msg.Type { + case "status": + fmt.Fprintf(os.Stderr, "\033[2m[host-gate] %s\033[0m\n", msg.Message) + case "approved": + if execution == "local" { + realPath, err := findRealBinary(command) + if err != nil { + return err + } + return syscall.Exec(realPath, append([]string{command}, args...), os.Environ()) + } + case "denied": + fmt.Fprintf(os.Stderr, "\033[31m[host-gate] Denied: %s\033[0m\n", msg.Message) + os.Exit(1) + case "output": + if msg.Stream == "stdout" { + fmt.Fprint(os.Stdout, msg.Data) + } else { + fmt.Fprint(os.Stderr, msg.Data) + } + case "exit": + os.Exit(msg.Code) + case "error": + fmt.Fprintf(os.Stderr, "\033[31m[host-gate] Error: %s\033[0m\n", msg.Message) + os.Exit(1) + } + } + return nil +} + +func passthrough(command string, args []string) error { + realPath, err := findRealBinary(command) + if err != nil { + return err + } + return syscall.Exec(realPath, append([]string{command}, args...), os.Environ()) +} + +func findRealBinary(command string) (string, error) { + wrapperDir := "/usr/local/lib/host-gate/bin" + paths := strings.Split(os.Getenv("PATH"), ":") + for _, dir := range paths { + if dir == wrapperDir { + continue + } + candidate := filepath.Join(dir, command) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + if info.Mode()&0111 != 0 { + return candidate, nil + } + } + } + return "", fmt.Errorf("real binary not found: %s", command) +} + +func mapToHostCwd(containerCwd string) string { + hostWs := os.Getenv("HOST_GATE_HOST_WORKSPACE") + containerWs := os.Getenv("HOST_GATE_CONTAINER_WORKSPACE") + if hostWs == "" || containerWs == "" { + return "" + } + if strings.HasPrefix(containerCwd, containerWs) { + suffix := strings.TrimPrefix(containerCwd, containerWs) + return filepath.Join(hostWs, suffix) + } + return "" +} + +func configPath() string { + if p := os.Getenv("HOST_GATE_CONFIG"); p != "" { + return p + } + return "/workspace/.devcontainer/host-gate.json" +} + +func hmacKeyPath() string { + if p := os.Getenv("HOST_GATE_HMAC_KEY"); p != "" { + return p + } + return "/var/run/host-gate/hmac.key" +} + +func socketPath() string { + if p := os.Getenv("HOST_GATE_SOCKET"); p != "" { + return p + } + return "/var/run/host-gate/gate.sock" +} diff --git a/host-gate/internal/client/setup.go b/host-gate/internal/client/setup.go new file mode 100644 index 0000000..3e10c81 --- /dev/null +++ b/host-gate/internal/client/setup.go @@ -0,0 +1,80 @@ +package client + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +func Setup(configPath string) error { + cfg, err := LoadConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + wrapperDir := "/usr/local/lib/host-gate/bin" + if err := os.MkdirAll(wrapperDir, 0755); err != nil { + return fmt.Errorf("create wrapper dir: %w", err) + } + + commands := cfg.UniqueBaseCommands() + + for _, cmd := range commands { + wrapperPath := filepath.Join(wrapperDir, cmd) + wrapperContent := fmt.Sprintf("#!/bin/sh\nexec host-gate-client exec %s \"$@\"\n", cmd) + if err := os.WriteFile(wrapperPath, []byte(wrapperContent), 0755); err != nil { + return fmt.Errorf("write wrapper for %s: %w", cmd, err) + } + slog.Info("Created wrapper", "command", cmd, "path", wrapperPath) + } + + profileScript := fmt.Sprintf("export PATH=%s:$PATH\n", wrapperDir) + if err := os.WriteFile("/etc/profile.d/host-gate.sh", []byte(profileScript), 0644); err != nil { + slog.Warn("Could not write /etc/profile.d/host-gate.sh", "error", err) + } + + appendToFile("/etc/bash.bashrc", profileScript) + + if err := os.MkdirAll("/etc/zsh", 0755); err == nil { + appendToFile("/etc/zsh/zshenv", profileScript) + } + + slog.Info("Setup complete", "commands", commands) + return nil +} + +func appendToFile(path, content string) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + + existing, _ := os.ReadFile(path) + if len(existing) > 0 { + // Avoid duplicate entries + for _, line := range splitLines(string(existing)) { + if line == content || line+"\n" == content { + return + } + } + } + + f.WriteString(content) +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/host-gate/internal/daemon/approval.go b/host-gate/internal/daemon/approval.go new file mode 100644 index 0000000..29dbf9d --- /dev/null +++ b/host-gate/internal/daemon/approval.go @@ -0,0 +1,12 @@ +package daemon + +import ( + "context" + + "github.com/ewout/host-gate/internal/protocol" +) + +// Approver abstracts the desktop approval mechanism for testability. +type Approver interface { + RequestApproval(ctx context.Context, req protocol.ExecuteRequest, timeout interface{}) (bool, error) +} diff --git a/host-gate/internal/daemon/approval_linux.go b/host-gate/internal/daemon/approval_linux.go new file mode 100644 index 0000000..b156023 --- /dev/null +++ b/host-gate/internal/daemon/approval_linux.go @@ -0,0 +1,48 @@ +package daemon + +import ( + "context" + "fmt" + "html" + "os" + "os/exec" + "strings" + + "github.com/ewout/host-gate/internal/protocol" +) + +func (s *Server) requestApproval(ctx context.Context, req protocol.ExecuteRequest) (bool, error) { + fullCommand := req.Command + " " + strings.Join(req.Args, " ") + title := "Host Gate: Command Approval" + text := fmt.Sprintf( + "A container requests to run:\n\n%s\n\nWorking directory: %s\nExecution: %s\nApproval: %s", + html.EscapeString(fullCommand), + html.EscapeString(req.Cwd), + req.Execution, + req.Approval, + ) + + ctx, cancel := context.WithTimeout(ctx, s.cfg.ApprovalTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "zenity", + "--question", + "--title", title, + "--text", text, + "--ok-label", "Allow", + "--cancel-label", "Deny", + "--icon-name", "dialog-warning", + "--width", "450", + ) + + // Inherit graphical session environment + cmd.Env = os.Environ() + + if err := cmd.Run(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/host-gate/internal/daemon/config.go b/host-gate/internal/daemon/config.go new file mode 100644 index 0000000..5c7416a --- /dev/null +++ b/host-gate/internal/daemon/config.go @@ -0,0 +1,12 @@ +package daemon + +import "time" + +type Config struct { + SocketDir string + HostConfigPath string + ApprovalTimeout time.Duration + YubiKeySlot int + YubiKeyTimeout time.Duration + WorkspaceMaps []string +} diff --git a/host-gate/internal/daemon/executor.go b/host-gate/internal/daemon/executor.go new file mode 100644 index 0000000..758bdfc --- /dev/null +++ b/host-gate/internal/daemon/executor.go @@ -0,0 +1,79 @@ +package daemon + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "os/exec" + "sync" + + "github.com/ewout/host-gate/internal/protocol" +) + +func (s *Server) proxyExecute( + ctx context.Context, + w http.ResponseWriter, + flusher http.Flusher, + enc *json.Encoder, + req protocol.ExecuteRequest, +) { + hostCwd := req.HostCwd + if hostCwd == "" { + hostCwd = s.mapCwd(req.Cwd) + } + + args := make([]string, len(req.Args)) + copy(args, req.Args) + cmd := exec.CommandContext(ctx, req.Command, args...) + cmd.Dir = hostCwd + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + enc.Encode(protocol.StreamMsg{Type: "error", Message: err.Error()}) + flusher.Flush() + return + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + enc.Encode(protocol.StreamMsg{Type: "error", Message: err.Error()}) + flusher.Flush() + return + } + + if err := cmd.Start(); err != nil { + enc.Encode(protocol.StreamMsg{Type: "error", Message: err.Error()}) + flusher.Flush() + return + } + + var wg sync.WaitGroup + streamPipe := func(pipe io.Reader, stream string) { + defer wg.Done() + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + enc.Encode(protocol.StreamMsg{ + Type: "output", + Stream: stream, + Data: scanner.Text() + "\n", + }) + flusher.Flush() + } + } + + wg.Add(2) + go streamPipe(stdoutPipe, "stdout") + go streamPipe(stderrPipe, "stderr") + wg.Wait() + + exitCode := 0 + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + + enc.Encode(protocol.StreamMsg{Type: "exit", Code: exitCode}) + flusher.Flush() +} diff --git a/host-gate/internal/daemon/handler.go b/host-gate/internal/daemon/handler.go new file mode 100644 index 0000000..38e9ad8 --- /dev/null +++ b/host-gate/internal/daemon/handler.go @@ -0,0 +1,125 @@ +package daemon + +import ( + "encoding/json" + "log/slog" + "net/http" + "sync" + + "github.com/ewout/host-gate/internal/protocol" +) + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "version": "1.0.0", + }) +} + +func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) { + var req protocol.ExecuteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if err := req.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + canonical := req.CanonicalString() + if !protocol.Verify(s.keyManager.Key(), canonical, req.HMAC) { + http.Error(w, "hmac verification failed", http.StatusUnauthorized) + return + } + + if !s.replay.Check(req.Nonce, req.Timestamp) { + http.Error(w, "replay detected", http.StatusForbidden) + return + } + + hostCfg := s.hostConfig + if reloaded, err := LoadHostConfig(s.cfg.HostConfigPath); err != nil { + slog.Warn("Failed to reload host config, using last good config", "error", err) + } else { + s.hostConfig = reloaded + hostCfg = reloaded + } + + effectiveExecution, effectiveApproval, err := hostCfg.ApplyPolicy( + req.Command, req.Args, req.Execution, req.Approval, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + enc := json.NewEncoder(w) + + if effectiveApproval == "none" { + slog.Info("Auto-approved (approval=none)", "command", req.Command) + } else if effectiveApproval == "yubikey" { + enc.Encode(protocol.StreamMsg{Type: "status", Message: "Approve in popup AND touch your YubiKey..."}) + flusher.Flush() + + var wg sync.WaitGroup + var popupApproved bool + var popupErr error + var ykErr error + + wg.Add(2) + go func() { + defer wg.Done() + popupApproved, popupErr = s.requestApproval(r.Context(), req) + }() + go func() { + defer wg.Done() + ykErr = s.requireYubiKeyTouch(r.Context()) + }() + wg.Wait() + + if popupErr != nil || !popupApproved { + msg := "User denied the request" + if popupErr != nil { + msg = popupErr.Error() + } + enc.Encode(protocol.StreamMsg{Type: "denied", Message: msg}) + flusher.Flush() + return + } + if ykErr != nil { + enc.Encode(protocol.StreamMsg{Type: "denied", Message: "YubiKey touch failed: " + ykErr.Error()}) + flusher.Flush() + return + } + } else { + enc.Encode(protocol.StreamMsg{Type: "status", Message: "Waiting for user approval..."}) + flusher.Flush() + + approved, err := s.requestApproval(r.Context(), req) + if err != nil || !approved { + msg := "User denied the request" + if err != nil { + msg = err.Error() + } + enc.Encode(protocol.StreamMsg{Type: "denied", Message: msg}) + flusher.Flush() + return + } + } + + enc.Encode(protocol.StreamMsg{Type: "approved"}) + flusher.Flush() + + if effectiveExecution == "proxy" { + s.proxyExecute(r.Context(), w, flusher, enc, req) + } +} diff --git a/host-gate/internal/daemon/handler_test.go b/host-gate/internal/daemon/handler_test.go new file mode 100644 index 0000000..eb7cc1b --- /dev/null +++ b/host-gate/internal/daemon/handler_test.go @@ -0,0 +1,208 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/ewout/host-gate/internal/protocol" +) + +func setupTestServer(t *testing.T, hostCfg *HostConfig) (*Server, []byte) { + t.Helper() + dir := t.TempDir() + + km, err := NewKeyManager(filepath.Join(dir, "hmac.key")) + if err != nil { + t.Fatalf("key manager: %v", err) + } + + if hostCfg == nil { + hostCfg = &HostConfig{DefaultPolicy: "allow"} + } + + mux := http.NewServeMux() + s := &Server{ + cfg: Config{ + SocketDir: dir, + ApprovalTimeout: 5 * time.Second, + YubiKeySlot: 2, + YubiKeyTimeout: 5 * time.Second, + }, + keyManager: km, + hostConfig: hostCfg, + replay: NewReplayGuard(30 * time.Second), + mux: mux, + workspaceMaps: map[string]string{}, + } + + mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("POST /execute", s.handleExecute) + + return s, km.Key() +} + +func makeSignedRequest(key []byte) protocol.ExecuteRequest { + req := protocol.ExecuteRequest{ + Version: 1, + Nonce: "test-nonce-" + time.Now().String(), + Timestamp: time.Now().Unix(), + Command: "echo", + Args: []string{"hello"}, + Cwd: "/workspace", + Execution: "proxy", + Approval: "popup", + } + req.HMAC = protocol.Sign(key, req.CanonicalString()) + return req +} + +func TestHealthEndpoint(t *testing.T) { + s, _ := setupTestServer(t, nil) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + s.mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %s", resp["status"]) + } +} + +func TestExecuteRejectsInvalidJSON(t *testing.T) { + s, _ := setupTestServer(t, nil) + + req := httptest.NewRequest("POST", "/execute", bytes.NewReader([]byte("not json"))) + w := httptest.NewRecorder() + s.mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestExecuteRejectsInvalidRequest(t *testing.T) { + s, _ := setupTestServer(t, nil) + + body, _ := json.Marshal(protocol.ExecuteRequest{Version: 2}) + req := httptest.NewRequest("POST", "/execute", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestExecuteRejectsWrongHMAC(t *testing.T) { + s, _ := setupTestServer(t, nil) + + execReq := protocol.ExecuteRequest{ + Version: 1, + Nonce: "nonce", + Timestamp: time.Now().Unix(), + Command: "echo", + Args: []string{}, + Cwd: "/workspace", + Execution: "proxy", + Approval: "popup", + HMAC: "wrong-hmac", + } + + body, _ := json.Marshal(execReq) + req := httptest.NewRequest("POST", "/execute", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestExecuteRejectsReplay(t *testing.T) { + s, key := setupTestServer(t, nil) + + execReq := protocol.ExecuteRequest{ + Version: 1, + Nonce: "replay-nonce", + Timestamp: time.Now().Unix(), + Command: "echo", + Args: []string{}, + Cwd: "/workspace", + Execution: "proxy", + Approval: "popup", + } + execReq.HMAC = protocol.Sign(key, execReq.CanonicalString()) + + // First request should pass replay check (will fail at approval since no zenity) + body, _ := json.Marshal(execReq) + req1 := httptest.NewRequest("POST", "/execute", bytes.NewReader(body)) + w1 := httptest.NewRecorder() + s.mux.ServeHTTP(w1, req1) + + // Second request with same nonce should be rejected + body2, _ := json.Marshal(execReq) + req2 := httptest.NewRequest("POST", "/execute", bytes.NewReader(body2)) + w2 := httptest.NewRecorder() + s.mux.ServeHTTP(w2, req2) + + if w2.Code != http.StatusForbidden { + t.Fatalf("expected 403 for replay, got %d", w2.Code) + } +} + +func TestExecuteRejectsHostPolicyDeny(t *testing.T) { + hostCfg := &HostConfig{ + DefaultPolicy: "deny", + Rules: []HostRule{ + {Match: []string{"kubectl", "delete"}, Allow: false}, + }, + } + s, key := setupTestServer(t, hostCfg) + + execReq := protocol.ExecuteRequest{ + Version: 1, + Nonce: "policy-nonce", + Timestamp: time.Now().Unix(), + Command: "kubectl", + Args: []string{"delete", "ns", "prod"}, + Cwd: "/workspace", + Execution: "local", + Approval: "popup", + } + execReq.HMAC = protocol.Sign(key, execReq.CanonicalString()) + + body, _ := json.Marshal(execReq) + req := httptest.NewRequest("POST", "/execute", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for policy denial, got %d", w.Code) + } +} + +func TestExecuteRejectsUnknownCommandDefaultDeny(t *testing.T) { + hostCfg := &HostConfig{DefaultPolicy: "deny"} + s, key := setupTestServer(t, hostCfg) + + execReq := makeSignedRequest(key) + body, _ := json.Marshal(execReq) + req := httptest.NewRequest("POST", "/execute", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for unknown command with default deny, got %d", w.Code) + } +} diff --git a/host-gate/internal/daemon/hostconfig.go b/host-gate/internal/daemon/hostconfig.go new file mode 100644 index 0000000..dba9598 --- /dev/null +++ b/host-gate/internal/daemon/hostconfig.go @@ -0,0 +1,156 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +type HostConfig struct { + DefaultPolicy string `json:"defaultPolicy"` + Rules []HostRule `json:"rules"` +} + +type HostRule struct { + Match []string `json:"match"` + Allow bool `json:"allow"` + MinApproval string `json:"minApproval,omitempty"` + MinExecution string `json:"minExecution,omitempty"` +} + +func LoadHostConfig(path string) (*HostConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cleaned := stripJSONComments(string(data)) + var cfg HostConfig + if err := json.Unmarshal([]byte(cleaned), &cfg); err != nil { + return nil, fmt.Errorf("parse host config: %w", err) + } + return &cfg, nil +} + +// stripJSONComments removes // line comments and trailing commas to allow +// JSONC-style configs that are easy to hand-edit. +func stripJSONComments(s string) string { + var b strings.Builder + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + continue + } + b.WriteString(line) + b.WriteByte('\n') + } + return stripTrailingCommas(b.String()) +} + +func stripTrailingCommas(s string) string { + var b strings.Builder + b.Grow(len(s)) + inString := false + escaped := false + for i := 0; i < len(s); i++ { + ch := s[i] + if inString { + b.WriteByte(ch) + if escaped { + escaped = false + } else if ch == '\\' { + escaped = true + } else if ch == '"' { + inString = false + } + continue + } + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + if ch == ',' { + j := i + 1 + for j < len(s) && (s[j] == ' ' || s[j] == '\t' || s[j] == '\n' || s[j] == '\r') { + j++ + } + if j < len(s) && (s[j] == ']' || s[j] == '}') { + continue + } + } + b.WriteByte(ch) + } + return b.String() +} + +func (hc *HostConfig) MatchRule(command string, args []string) *HostRule { + fullCmd := make([]string, 0, 1+len(args)) + fullCmd = append(fullCmd, command) + fullCmd = append(fullCmd, args...) + + var bestMatch *HostRule + bestLen := 0 + for i, rule := range hc.Rules { + if len(rule.Match) > len(fullCmd) { + continue + } + match := true + for j, part := range rule.Match { + if part != fullCmd[j] { + match = false + break + } + } + if match && len(rule.Match) > bestLen { + bestMatch = &hc.Rules[i] + bestLen = len(rule.Match) + } + } + return bestMatch +} + +// ApplyPolicy checks the host policy and returns the effective execution/approval modes. +// Returns an error if the command is denied. +func (hc *HostConfig) ApplyPolicy(command string, args []string, reqExecution, reqApproval string) (string, string, error) { + rule := hc.MatchRule(command, args) + + if rule == nil { + if hc.DefaultPolicy == "allow" { + return reqExecution, reqApproval, nil + } + return "", "", fmt.Errorf("command not allowed by host policy (no matching rule, default: deny)") + } + + if !rule.Allow { + return "", "", fmt.Errorf("command explicitly denied by host policy") + } + + execution := reqExecution + if modeRank(rule.MinExecution) > modeRank(execution) { + execution = rule.MinExecution + } + + approval := reqApproval + if modeRank(rule.MinApproval) > modeRank(approval) { + approval = rule.MinApproval + } + + return execution, approval, nil +} + +// modeRank returns the restrictiveness rank of a mode. +// Approval: none(0) < popup(1) < yubikey(2) +// Execution: local(0) < proxy(1) +func modeRank(mode string) int { + switch mode { + case "none", "": + return 0 + case "local", "popup": + return 1 + case "proxy", "yubikey": + return 2 + default: + return 0 + } +} diff --git a/host-gate/internal/daemon/hostconfig_test.go b/host-gate/internal/daemon/hostconfig_test.go new file mode 100644 index 0000000..1447edf --- /dev/null +++ b/host-gate/internal/daemon/hostconfig_test.go @@ -0,0 +1,263 @@ +package daemon + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestLoadHostConfigWithComments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + + jsonc := `// This is a comment +// Another comment +{ + "defaultPolicy": "allow", + "rules": [ + { + // Rule for git push + "match": ["git", "push"], + "allow": true, + "minApproval": "yubikey" + } + ] +} +` + os.WriteFile(path, []byte(jsonc), 0644) + + loaded, err := LoadHostConfig(path) + if err != nil { + t.Fatalf("unexpected error parsing JSONC: %v", err) + } + if loaded.DefaultPolicy != "allow" { + t.Fatalf("expected allow, got %s", loaded.DefaultPolicy) + } + if len(loaded.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(loaded.Rules)) + } + if loaded.Rules[0].MinApproval != "yubikey" { + t.Fatalf("expected yubikey, got %s", loaded.Rules[0].MinApproval) + } +} + +func TestLoadHostConfigWithTrailingCommas(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + + jsonc := `{ + "defaultPolicy": "deny", + "rules": [ + { + "match": ["aws"], + "allow": true, + "minApproval": "popup", + "minExecution": "proxy", + }, + ], +} +` + os.WriteFile(path, []byte(jsonc), 0644) + + loaded, err := LoadHostConfig(path) + if err != nil { + t.Fatalf("unexpected error parsing JSONC with trailing commas: %v", err) + } + if loaded.DefaultPolicy != "deny" { + t.Fatalf("expected deny, got %s", loaded.DefaultPolicy) + } + if len(loaded.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(loaded.Rules)) + } + if loaded.Rules[0].Match[0] != "aws" { + t.Fatalf("expected aws, got %s", loaded.Rules[0].Match[0]) + } +} + +func TestLoadHostConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + + cfg := HostConfig{ + DefaultPolicy: "deny", + Rules: []HostRule{ + {Match: []string{"git", "push"}, Allow: true, MinApproval: "popup"}, + }, + } + data, _ := json.Marshal(cfg) + os.WriteFile(path, data, 0644) + + loaded, err := LoadHostConfig(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.DefaultPolicy != "deny" { + t.Fatalf("expected deny, got %s", loaded.DefaultPolicy) + } + if len(loaded.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(loaded.Rules)) + } +} + +func TestMatchRuleLongestPrefix(t *testing.T) { + cfg := HostConfig{ + Rules: []HostRule{ + {Match: []string{"git", "push"}, Allow: true, MinApproval: "popup"}, + {Match: []string{"git", "push", "--force"}, Allow: true, MinApproval: "yubikey"}, + {Match: []string{"git"}, Allow: true, MinApproval: "popup"}, + }, + } + + rule := cfg.MatchRule("git", []string{"push", "--force", "origin"}) + if rule == nil { + t.Fatal("expected a match") + } + if rule.MinApproval != "yubikey" { + t.Fatalf("expected yubikey (longest prefix match), got %s", rule.MinApproval) + } + + rule = cfg.MatchRule("git", []string{"push", "origin"}) + if rule == nil { + t.Fatal("expected a match") + } + if rule.MinApproval != "popup" { + t.Fatalf("expected popup (git push match), got %s", rule.MinApproval) + } + + rule = cfg.MatchRule("git", []string{"status"}) + if rule == nil { + t.Fatal("expected a match for bare git") + } + if rule.MinApproval != "popup" { + t.Fatalf("expected popup (bare git match), got %s", rule.MinApproval) + } +} + +func TestMatchRuleNoMatch(t *testing.T) { + cfg := HostConfig{ + Rules: []HostRule{ + {Match: []string{"git", "push"}, Allow: true}, + }, + } + + rule := cfg.MatchRule("npm", []string{"publish"}) + if rule != nil { + t.Fatal("expected no match for npm") + } +} + +func TestApplyPolicyDefaultDeny(t *testing.T) { + cfg := HostConfig{DefaultPolicy: "deny"} + + _, _, err := cfg.ApplyPolicy("git", []string{"push"}, "proxy", "popup") + if err == nil { + t.Fatal("expected denial with no matching rule and default deny") + } +} + +func TestApplyPolicyDefaultAllow(t *testing.T) { + cfg := HostConfig{DefaultPolicy: "allow"} + + exec, approval, err := cfg.ApplyPolicy("git", []string{"push"}, "proxy", "popup") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exec != "proxy" || approval != "popup" { + t.Fatalf("expected proxy/popup passthrough, got %s/%s", exec, approval) + } +} + +func TestApplyPolicyExplicitDeny(t *testing.T) { + cfg := HostConfig{ + DefaultPolicy: "allow", + Rules: []HostRule{ + {Match: []string{"kubectl", "delete"}, Allow: false}, + }, + } + + _, _, err := cfg.ApplyPolicy("kubectl", []string{"delete", "ns", "prod"}, "local", "popup") + if err == nil { + t.Fatal("expected explicit denial") + } +} + +func TestApplyPolicyEscalation(t *testing.T) { + cfg := HostConfig{ + DefaultPolicy: "deny", + Rules: []HostRule{ + {Match: []string{"git", "push"}, Allow: true, MinApproval: "yubikey", MinExecution: "proxy"}, + }, + } + + exec, approval, err := cfg.ApplyPolicy("git", []string{"push"}, "local", "popup") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exec != "proxy" { + t.Fatalf("expected escalation to proxy, got %s", exec) + } + if approval != "yubikey" { + t.Fatalf("expected escalation to yubikey, got %s", approval) + } +} + +func TestApplyPolicyNoneApproval(t *testing.T) { + cfg := HostConfig{ + DefaultPolicy: "deny", + Rules: []HostRule{ + {Match: []string{"aws"}, Allow: true, MinApproval: "none", MinExecution: "proxy"}, + }, + } + + exec, approval, err := cfg.ApplyPolicy("aws", []string{"s3", "ls"}, "proxy", "none") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exec != "proxy" { + t.Fatalf("expected proxy, got %s", exec) + } + if approval != "none" { + t.Fatalf("expected none, got %s", approval) + } +} + +func TestApplyPolicyNoneEscalatedByHost(t *testing.T) { + cfg := HostConfig{ + DefaultPolicy: "deny", + Rules: []HostRule{ + {Match: []string{"aws"}, Allow: true, MinApproval: "popup", MinExecution: "proxy"}, + }, + } + + exec, approval, err := cfg.ApplyPolicy("aws", []string{"s3", "ls"}, "proxy", "none") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exec != "proxy" { + t.Fatalf("expected proxy, got %s", exec) + } + if approval != "popup" { + t.Fatalf("expected host to escalate none->popup, got %s", approval) + } +} + +func TestApplyPolicyNoDowngrade(t *testing.T) { + cfg := HostConfig{ + DefaultPolicy: "deny", + Rules: []HostRule{ + {Match: []string{"git", "push"}, Allow: true, MinApproval: "popup", MinExecution: "local"}, + }, + } + + exec, approval, err := cfg.ApplyPolicy("git", []string{"push"}, "proxy", "yubikey") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exec != "proxy" { + t.Fatalf("expected proxy to be preserved (more restrictive), got %s", exec) + } + if approval != "yubikey" { + t.Fatalf("expected yubikey to be preserved (more restrictive), got %s", approval) + } +} diff --git a/host-gate/internal/daemon/keymanager.go b/host-gate/internal/daemon/keymanager.go new file mode 100644 index 0000000..5ade2e0 --- /dev/null +++ b/host-gate/internal/daemon/keymanager.go @@ -0,0 +1,36 @@ +package daemon + +import ( + crypto_rand "crypto/rand" + "fmt" + "os" +) + +type KeyManager struct { + keyPath string + key []byte +} + +func NewKeyManager(keyPath string) (*KeyManager, error) { + key := make([]byte, 32) + if _, err := crypto_rand.Read(key); err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + + // O_TRUNC preserves inode, which is critical for Docker bind mounts + f, err := os.OpenFile(keyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return nil, fmt.Errorf("open key file: %w", err) + } + defer f.Close() + + if _, err := f.Write(key); err != nil { + return nil, fmt.Errorf("write key: %w", err) + } + + return &KeyManager{keyPath: keyPath, key: key}, nil +} + +func (km *KeyManager) Key() []byte { + return km.key +} diff --git a/host-gate/internal/daemon/keymanager_test.go b/host-gate/internal/daemon/keymanager_test.go new file mode 100644 index 0000000..bd5c076 --- /dev/null +++ b/host-gate/internal/daemon/keymanager_test.go @@ -0,0 +1,62 @@ +package daemon + +import ( + "os" + "path/filepath" + "testing" +) + +func TestKeyManagerCreatesKeyFile(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "hmac.key") + + km, err := NewKeyManager(keyPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(km.Key()) != 32 { + t.Fatalf("expected 32-byte key, got %d bytes", len(km.Key())) + } + + data, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("failed to read key file: %v", err) + } + if len(data) != 32 { + t.Fatalf("key file should be 32 bytes, got %d", len(data)) + } +} + +func TestKeyManagerPreservesInode(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "hmac.key") + + // Create initial key + NewKeyManager(keyPath) + info1, _ := os.Stat(keyPath) + + // Recreate with same path (simulates daemon restart) + NewKeyManager(keyPath) + info2, _ := os.Stat(keyPath) + + if !os.SameFile(info1, info2) { + t.Fatal("expected same inode after key regeneration (O_TRUNC)") + } +} + +func TestKeyManagerFilePermissions(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "hmac.key") + + NewKeyManager(keyPath) + + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("stat failed: %v", err) + } + perm := info.Mode().Perm() + if perm != 0600 { + t.Fatalf("expected 0600 permissions, got %o", perm) + } +} diff --git a/host-gate/internal/daemon/replay.go b/host-gate/internal/daemon/replay.go new file mode 100644 index 0000000..299d60e --- /dev/null +++ b/host-gate/internal/daemon/replay.go @@ -0,0 +1,58 @@ +package daemon + +import ( + "sync" + "time" +) + +type ReplayGuard struct { + mu sync.Mutex + seen map[string]time.Time + maxAge time.Duration +} + +func NewReplayGuard(maxAge time.Duration) *ReplayGuard { + rg := &ReplayGuard{ + seen: make(map[string]time.Time), + maxAge: maxAge, + } + go rg.pruneLoop() + return rg +} + +func (rg *ReplayGuard) Check(nonce string, timestamp int64) bool { + rg.mu.Lock() + defer rg.mu.Unlock() + + now := time.Now().Unix() + diff := now - timestamp + if diff < 0 { + diff = -diff + } + if diff > int64(rg.maxAge.Seconds()) { + return false + } + + if _, exists := rg.seen[nonce]; exists { + return false + } + + rg.seen[nonce] = time.Now() + return true +} + +func (rg *ReplayGuard) pruneLoop() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for range ticker.C { + rg.mu.Lock() + cutoff := time.Now().Add(-rg.maxAge) + for nonce, t := range rg.seen { + if t.Before(cutoff) { + delete(rg.seen, nonce) + } + } + rg.mu.Unlock() + } +} diff --git a/host-gate/internal/daemon/replay_test.go b/host-gate/internal/daemon/replay_test.go new file mode 100644 index 0000000..a4c4b1c --- /dev/null +++ b/host-gate/internal/daemon/replay_test.go @@ -0,0 +1,55 @@ +package daemon + +import ( + "testing" + "time" +) + +func TestReplayGuardAcceptsNewNonce(t *testing.T) { + rg := NewReplayGuard(30 * time.Second) + now := time.Now().Unix() + + if !rg.Check("nonce-1", now) { + t.Fatal("expected first use of nonce to pass") + } +} + +func TestReplayGuardRejectsDuplicateNonce(t *testing.T) { + rg := NewReplayGuard(30 * time.Second) + now := time.Now().Unix() + + rg.Check("nonce-1", now) + if rg.Check("nonce-1", now) { + t.Fatal("expected second use of same nonce to fail") + } +} + +func TestReplayGuardRejectsOldTimestamp(t *testing.T) { + rg := NewReplayGuard(30 * time.Second) + old := time.Now().Unix() - 60 + + if rg.Check("nonce-old", old) { + t.Fatal("expected old timestamp to be rejected") + } +} + +func TestReplayGuardRejectsFutureTimestamp(t *testing.T) { + rg := NewReplayGuard(30 * time.Second) + future := time.Now().Unix() + 60 + + if rg.Check("nonce-future", future) { + t.Fatal("expected far-future timestamp to be rejected") + } +} + +func TestReplayGuardAcceptsDifferentNonces(t *testing.T) { + rg := NewReplayGuard(30 * time.Second) + now := time.Now().Unix() + + if !rg.Check("nonce-a", now) { + t.Fatal("expected nonce-a to pass") + } + if !rg.Check("nonce-b", now) { + t.Fatal("expected nonce-b to pass") + } +} diff --git a/host-gate/internal/daemon/server.go b/host-gate/internal/daemon/server.go new file mode 100644 index 0000000..15c8b98 --- /dev/null +++ b/host-gate/internal/daemon/server.go @@ -0,0 +1,99 @@ +package daemon + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type Server struct { + cfg Config + keyManager *KeyManager + hostConfig *HostConfig + replay *ReplayGuard + mux *http.ServeMux + httpServer *http.Server + workspaceMaps map[string]string +} + +func NewServer(cfg Config) (*Server, error) { + if err := os.MkdirAll(cfg.SocketDir, 0700); err != nil { + return nil, fmt.Errorf("create socket dir: %w", err) + } + + km, err := NewKeyManager(filepath.Join(cfg.SocketDir, "hmac.key")) + if err != nil { + return nil, fmt.Errorf("key manager: %w", err) + } + + hostCfg, err := LoadHostConfig(cfg.HostConfigPath) + if err != nil { + slog.Warn("Could not load host config, using deny-all default", "error", err) + hostCfg = &HostConfig{DefaultPolicy: "deny"} + } + + wsMaps := make(map[string]string) + for _, m := range cfg.WorkspaceMaps { + parts := strings.SplitN(m, ":", 2) + if len(parts) == 2 { + wsMaps[parts[0]] = parts[1] + } + } + + mux := http.NewServeMux() + s := &Server{ + cfg: cfg, + keyManager: km, + hostConfig: hostCfg, + replay: NewReplayGuard(30 * time.Second), + mux: mux, + workspaceMaps: wsMaps, + } + + mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("POST /execute", s.handleExecute) + + return s, nil +} + +func (s *Server) ListenAndServe() error { + socketPath := filepath.Join(s.cfg.SocketDir, "gate.sock") + os.Remove(socketPath) + + listener, err := net.Listen("unix", socketPath) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + + if err := os.Chmod(socketPath, 0600); err != nil { + return fmt.Errorf("chmod socket: %w", err) + } + + slog.Info("Listening", "socket", socketPath) + s.httpServer = &http.Server{Handler: s.mux} + return s.httpServer.Serve(listener) +} + +func (s *Server) Shutdown(ctx context.Context) error { + if s.httpServer != nil { + return s.httpServer.Shutdown(ctx) + } + return nil +} + +// mapCwd translates a container working directory to a host path using workspace maps. +func (s *Server) mapCwd(containerCwd string) string { + for containerPrefix, hostPrefix := range s.workspaceMaps { + if strings.HasPrefix(containerCwd, containerPrefix) { + suffix := strings.TrimPrefix(containerCwd, containerPrefix) + return filepath.Join(hostPrefix, suffix) + } + } + return containerCwd +} diff --git a/host-gate/internal/daemon/server_test.go b/host-gate/internal/daemon/server_test.go new file mode 100644 index 0000000..7e8be80 --- /dev/null +++ b/host-gate/internal/daemon/server_test.go @@ -0,0 +1,29 @@ +package daemon + +import ( + "testing" +) + +func TestMapCwd(t *testing.T) { + s := &Server{ + workspaceMaps: map[string]string{ + "/workspace": "/home/user/project", + }, + } + + tests := []struct { + input string + expected string + }{ + {"/workspace", "/home/user/project"}, + {"/workspace/src/main.go", "/home/user/project/src/main.go"}, + {"/other/path", "/other/path"}, + } + + for _, tt := range tests { + result := s.mapCwd(tt.input) + if result != tt.expected { + t.Errorf("mapCwd(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} diff --git a/host-gate/internal/daemon/yubikey.go b/host-gate/internal/daemon/yubikey.go new file mode 100644 index 0000000..b23b3d7 --- /dev/null +++ b/host-gate/internal/daemon/yubikey.go @@ -0,0 +1,39 @@ +package daemon + +import ( + "context" + crypto_rand "crypto/rand" + "encoding/hex" + "fmt" + "os/exec" + "strconv" + "strings" +) + +func (s *Server) requireYubiKeyTouch(ctx context.Context) error { + challenge := make([]byte, 32) + if _, err := crypto_rand.Read(challenge); err != nil { + return fmt.Errorf("generate challenge: %w", err) + } + challengeHex := hex.EncodeToString(challenge) + + ctx, cancel := context.WithTimeout(ctx, s.cfg.YubiKeyTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "ykman", "otp", "calculate", + strconv.Itoa(s.cfg.YubiKeySlot), + challengeHex, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("yubikey challenge-response failed: %w (output: %s)", err, string(output)) + } + + response := strings.TrimSpace(string(output)) + if len(response) == 0 { + return fmt.Errorf("empty yubikey response") + } + + return nil +} diff --git a/host-gate/internal/protocol/hmac.go b/host-gate/internal/protocol/hmac.go new file mode 100644 index 0000000..a9fe6ca --- /dev/null +++ b/host-gate/internal/protocol/hmac.go @@ -0,0 +1,18 @@ +package protocol + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +func Sign(key []byte, canonical string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(canonical)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func Verify(key []byte, canonical string, providedHMAC string) bool { + expected := Sign(key, canonical) + return hmac.Equal([]byte(expected), []byte(providedHMAC)) +} diff --git a/host-gate/internal/protocol/hmac_test.go b/host-gate/internal/protocol/hmac_test.go new file mode 100644 index 0000000..9837131 --- /dev/null +++ b/host-gate/internal/protocol/hmac_test.go @@ -0,0 +1,108 @@ +package protocol + +import ( + "testing" +) + +func TestSignAndVerify(t *testing.T) { + key := []byte("test-key-32-bytes-long-padding!!") + canonical := "v1:nonce123:1711540800:git:[\"push\"]:cwd::proxy:popup" + + sig := Sign(key, canonical) + if sig == "" { + t.Fatal("Sign returned empty string") + } + + if !Verify(key, canonical, sig) { + t.Fatal("Verify failed for valid signature") + } +} + +func TestVerifyRejectsWrongKey(t *testing.T) { + key1 := []byte("key-one-32-bytes-long-padding!!!") + key2 := []byte("key-two-32-bytes-long-padding!!!") + canonical := "v1:nonce123:1711540800:git:[\"push\"]:cwd::proxy:popup" + + sig := Sign(key1, canonical) + if Verify(key2, canonical, sig) { + t.Fatal("Verify should reject signature made with different key") + } +} + +func TestVerifyRejectsTamperedCanonical(t *testing.T) { + key := []byte("test-key-32-bytes-long-padding!!") + canonical := "v1:nonce123:1711540800:git:[\"push\"]:cwd::proxy:popup" + + sig := Sign(key, canonical) + tampered := "v1:nonce123:1711540800:git:[\"push\",\"--force\"]:cwd::proxy:popup" + if Verify(key, tampered, sig) { + t.Fatal("Verify should reject tampered canonical string") + } +} + +func TestVerifyRejectsEmptyHMAC(t *testing.T) { + key := []byte("test-key-32-bytes-long-padding!!") + canonical := "v1:nonce123:1711540800:git:[\"push\"]:cwd::proxy:popup" + + if Verify(key, canonical, "") { + t.Fatal("Verify should reject empty HMAC") + } +} + +func TestCanonicalString(t *testing.T) { + req := ExecuteRequest{ + Version: 1, + Nonce: "test-nonce", + Timestamp: 1711540800, + Command: "git", + Args: []string{"push", "origin", "main"}, + Cwd: "/workspace", + Execution: "proxy", + Approval: "popup", + } + + canonical := req.CanonicalString() + expected := `v1:test-nonce:1711540800:git:["push","origin","main"]:/workspace::proxy:popup` + if canonical != expected { + t.Fatalf("CanonicalString mismatch:\n got: %s\n want: %s", canonical, expected) + } +} + +func TestCanonicalStringWithHostCwd(t *testing.T) { + req := ExecuteRequest{ + Version: 1, + Nonce: "n", + Timestamp: 100, + Command: "aws", + Args: []string{"s3", "ls"}, + Cwd: "/workspace", + HostCwd: "/home/user/project", + Execution: "proxy", + Approval: "popup", + } + + canonical := req.CanonicalString() + expected := `v1:n:100:aws:["s3","ls"]:/workspace:/home/user/project:proxy:popup` + if canonical != expected { + t.Fatalf("CanonicalString mismatch:\n got: %s\n want: %s", canonical, expected) + } +} + +func TestCanonicalStringEmptyArgs(t *testing.T) { + req := ExecuteRequest{ + Version: 1, + Nonce: "nonce", + Timestamp: 100, + Command: "ls", + Args: []string{}, + Cwd: "/home", + Execution: "local", + Approval: "popup", + } + + canonical := req.CanonicalString() + expected := "v1:nonce:100:ls:[]:/home::local:popup" + if canonical != expected { + t.Fatalf("CanonicalString mismatch:\n got: %s\n want: %s", canonical, expected) + } +} diff --git a/host-gate/internal/protocol/request.go b/host-gate/internal/protocol/request.go new file mode 100644 index 0000000..88ead3b --- /dev/null +++ b/host-gate/internal/protocol/request.go @@ -0,0 +1,65 @@ +package protocol + +import ( + "encoding/json" + "fmt" + "strings" +) + +type ExecuteRequest struct { + Version int `json:"version"` + Nonce string `json:"nonce"` + Timestamp int64 `json:"timestamp"` + Command string `json:"command"` + Args []string `json:"args"` + Cwd string `json:"cwd"` + HostCwd string `json:"hostCwd,omitempty"` + Execution string `json:"execution"` + Approval string `json:"approval"` + HMAC string `json:"hmac"` +} + +func (r *ExecuteRequest) Validate() error { + if r.Version != 1 { + return fmt.Errorf("unsupported protocol version: %d", r.Version) + } + if r.Nonce == "" { + return fmt.Errorf("nonce is required") + } + if r.Timestamp == 0 { + return fmt.Errorf("timestamp is required") + } + if r.Command == "" { + return fmt.Errorf("command is required") + } + if r.Cwd == "" { + return fmt.Errorf("cwd is required") + } + if r.Execution != "proxy" && r.Execution != "local" { + return fmt.Errorf("execution must be 'proxy' or 'local', got %q", r.Execution) + } + if r.Approval != "none" && r.Approval != "popup" && r.Approval != "yubikey" { + return fmt.Errorf("approval must be 'none', 'popup', or 'yubikey', got %q", r.Approval) + } + if r.HMAC == "" { + return fmt.Errorf("hmac is required") + } + return nil +} + +// CanonicalString produces the string that is HMAC-signed. +// Format: v1:{nonce}:{timestamp}:{command}:{args_json}:{cwd}:{hostCwd}:{execution}:{approval} +func (r *ExecuteRequest) CanonicalString() string { + argsJSON, _ := json.Marshal(r.Args) + return strings.Join([]string{ + "v1", + r.Nonce, + fmt.Sprintf("%d", r.Timestamp), + r.Command, + string(argsJSON), + r.Cwd, + r.HostCwd, + r.Execution, + r.Approval, + }, ":") +} diff --git a/host-gate/internal/protocol/request_test.go b/host-gate/internal/protocol/request_test.go new file mode 100644 index 0000000..bcc2425 --- /dev/null +++ b/host-gate/internal/protocol/request_test.go @@ -0,0 +1,64 @@ +package protocol + +import ( + "testing" +) + +func TestValidateHappyPath(t *testing.T) { + req := ExecuteRequest{ + Version: 1, + Nonce: "test-nonce", + Timestamp: 1711540800, + Command: "git", + Args: []string{"push"}, + Cwd: "/workspace", + Execution: "proxy", + Approval: "popup", + HMAC: "abc123", + } + if err := req.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateRejectsWrongVersion(t *testing.T) { + req := ExecuteRequest{Version: 2, Nonce: "n", Timestamp: 1, Command: "c", Cwd: "/", Execution: "proxy", Approval: "popup", HMAC: "h"} + if err := req.Validate(); err == nil { + t.Fatal("expected error for version 2") + } +} + +func TestValidateRejectsEmptyCommand(t *testing.T) { + req := ExecuteRequest{Version: 1, Nonce: "n", Timestamp: 1, Command: "", Cwd: "/", Execution: "proxy", Approval: "popup", HMAC: "h"} + if err := req.Validate(); err == nil { + t.Fatal("expected error for empty command") + } +} + +func TestValidateRejectsInvalidExecution(t *testing.T) { + req := ExecuteRequest{Version: 1, Nonce: "n", Timestamp: 1, Command: "c", Cwd: "/", Execution: "invalid", Approval: "popup", HMAC: "h"} + if err := req.Validate(); err == nil { + t.Fatal("expected error for invalid execution mode") + } +} + +func TestValidateAcceptsNoneApproval(t *testing.T) { + req := ExecuteRequest{Version: 1, Nonce: "n", Timestamp: 1, Command: "c", Cwd: "/", Execution: "proxy", Approval: "none", HMAC: "h"} + if err := req.Validate(); err != nil { + t.Fatalf("unexpected error for approval=none: %v", err) + } +} + +func TestValidateRejectsInvalidApproval(t *testing.T) { + req := ExecuteRequest{Version: 1, Nonce: "n", Timestamp: 1, Command: "c", Cwd: "/", Execution: "proxy", Approval: "invalid", HMAC: "h"} + if err := req.Validate(); err == nil { + t.Fatal("expected error for invalid approval mode") + } +} + +func TestValidateRejectsEmptyHMAC(t *testing.T) { + req := ExecuteRequest{Version: 1, Nonce: "n", Timestamp: 1, Command: "c", Cwd: "/", Execution: "proxy", Approval: "popup", HMAC: ""} + if err := req.Validate(); err == nil { + t.Fatal("expected error for empty hmac") + } +} diff --git a/host-gate/internal/protocol/response.go b/host-gate/internal/protocol/response.go new file mode 100644 index 0000000..60e8b9a --- /dev/null +++ b/host-gate/internal/protocol/response.go @@ -0,0 +1,10 @@ +package protocol + +// StreamMsg is a single NDJSON message in the streaming response. +type StreamMsg struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + Stream string `json:"stream,omitempty"` + Data string `json:"data,omitempty"` + Code int `json:"code,omitempty"` +} diff --git a/host-gate/scripts/configure-yubikey-slot.sh b/host-gate/scripts/configure-yubikey-slot.sh new file mode 100755 index 0000000..977fdf9 --- /dev/null +++ b/host-gate/scripts/configure-yubikey-slot.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +SLOT=${1:-2} + +echo "This will configure YubiKey OTP slot $SLOT for HMAC-SHA1 challenge-response with touch required." +echo "WARNING: This will overwrite any existing configuration on slot $SLOT." +read -p "Continue? (y/N) " -n 1 -r +echo + +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +ykman otp chalresp --touch --generate "$SLOT" +echo "YubiKey slot $SLOT configured for host-gate challenge-response." diff --git a/host-gate/scripts/init-project.sh b/host-gate/scripts/init-project.sh new file mode 100755 index 0000000..fe32007 --- /dev/null +++ b/host-gate/scripts/init-project.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +FEATURE_SRC="$(cd "$(dirname "$0")/../devcontainer-feature/src/host-gate" && pwd)" +TARGET_DIR="${1:-.}/.devcontainer" + +if [ ! -d "$TARGET_DIR" ]; then + echo "No .devcontainer/ directory found at $(cd "${1:-.}" && pwd)" >&2 + echo "Usage: $0 [project-path]" >&2 + exit 1 +fi + +mkdir -p "$TARGET_DIR/host-gate" +cp "$FEATURE_SRC/devcontainer-feature.json" "$TARGET_DIR/host-gate/" +cp "$FEATURE_SRC/install.sh" "$TARGET_DIR/host-gate/" +chmod +x "$TARGET_DIR/host-gate/install.sh" + +echo "Host gate feature copied to $TARGET_DIR/host-gate/" +echo "" +echo "Add to your devcontainer.json:" +echo ' "features": {' +echo ' "./host-gate": {}' +echo ' }' + +if [ ! -f "$TARGET_DIR/host-gate.json" ]; then + cat > "$TARGET_DIR/host-gate.json" <<'EOF' +{ + "rules": [ + { + "match": ["git", "push"], + "execution": "proxy", + "approval": "popup" + } + ] +} +EOF + echo "" + echo "Created default container config at $TARGET_DIR/host-gate.json" +fi diff --git a/host-gate/systemd/host-gate.service b/host-gate/systemd/host-gate.service new file mode 100644 index 0000000..ad9a1a0 --- /dev/null +++ b/host-gate/systemd/host-gate.service @@ -0,0 +1,18 @@ +[Unit] +Description=Host Gate - Container Command Approval Daemon +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/host-gate-daemon +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=%t/host-gate %h/.local/share/ykman +PrivateTmp=true + +[Install] +WantedBy=default.target diff --git a/lib/menus.sh b/lib/menus.sh index a3c0fc5..d7c54e5 100644 --- a/lib/menus.sh +++ b/lib/menus.sh @@ -34,6 +34,7 @@ declare -A configuration_menu=( ["5:Network printer discovery"]="load_module configuration/configure-printing.sh" ["6:Logitech C920 microphone"]="load_module configuration/configure-webcam-mic.sh" ["7:Cursor release channel"]="load_module configuration/configure-cursor.sh" + ["8:Host gate policy"]="load_module configuration/configure-host-gate.sh" ) declare -A security_menu=(