Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/configuration/configure-host-gate.sh
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions app/installations/optional/host-gate.sh
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions host-gate/Makefile
Original file line number Diff line number Diff line change
@@ -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/
195 changes: 195 additions & 0 deletions host-gate/README.md
Original file line number Diff line number Diff line change
@@ -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)
```
Binary file added host-gate/bin/host-gate-client-linux-amd64
Binary file not shown.
Binary file added host-gate/bin/host-gate-client-linux-arm64
Binary file not shown.
Binary file added host-gate/bin/host-gate-daemon
Binary file not shown.
41 changes: 41 additions & 0 deletions host-gate/cmd/host-gate-client/main.go
Original file line number Diff line number Diff line change
@@ -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 <exec|setup> [args...]")
os.Exit(1)
}

switch os.Args[1] {
case "exec":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: host-gate-client exec <command> [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 <exec|setup> [args...]\n", os.Args[1])
os.Exit(1)
}
}
Loading