diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..32e98d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,135 @@ +name: ci + +on: + push: + branches: [main, canary] + pull_request: + branches: [main, canary] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: format_check + run: cargo fmt --check + - name: clippy + run: cargo clippy --all-targets -- -D warnings + - name: unit_tests + run: cargo test --all + - name: script_syntax + run: bash -n setup uninstall build update + + api_e2e: + runs-on: ubuntu-latest + env: + ADMIN_TOKEN: admin_test_token + READONLY_TOKEN: readonly_test_token + DOCKER_SOCKET: /var/run/docker.sock + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build_binary + run: cargo build --release --bin docker-proxy + - name: write_ci_config + run: | + cat > ci-config.yaml <<'YAML' + global: + port: 2376 + bind: 127.0.0.1 + metrics: + enabled: true + path: /metrics + auth: + type: bearer + tokens: + - token: "${ADMIN_TOKEN}" + role: admin + - token: "${READONLY_TOKEN}" + role: readonly + rules: + - name: block_secrets + conditions: + - field: path + operator: starts_with + value: /secrets + action: deny + status: 403 + message: secrets blocked + YAML + - name: start_proxy + run: | + ./target/release/docker-proxy --config ci-config.yaml & + echo "proxy_pid=$!" >> "$GITHUB_ENV" + for i in $(seq 1 40); do + c=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:2376/version || true) + if [ -n "$c" ] && [ "$c" != "000" ]; then break; fi + sleep 0.5 + done + - name: assert_api + run: | + fail() { echo "::error::$1"; kill "$proxy_pid" 2>/dev/null; exit 1; } + code() { curl -s -o /dev/null -w '%{http_code}' "$@"; } + + c=$(code http://127.0.0.1:2376/version) + [ "$c" = "401" ] || fail "no_token expected 401 got $c" + + c=$(code -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:2376/version) + [ "$c" = "200" ] || fail "admin /version expected 200 got $c" + + c=$(code -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:2376/secrets) + [ "$c" = "403" ] || fail "deny rule expected 403 got $c" + + c=$(code -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:2376/metrics) + [ "$c" = "200" ] || fail "metrics expected 200 got $c" + - name: stop_proxy + if: always() + run: kill "$proxy_pid" 2>/dev/null || true + + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + zig: true + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + zig: true + - os: macos-latest + target: aarch64-apple-darwin + zig: false + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: install_zigbuild + if: matrix.zig + run: | + curl -fsSL https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz | tar -xJ + echo "$PWD/zig-linux-x86_64-0.13.0" >> "$GITHUB_PATH" + cargo install cargo-zigbuild + - name: build_zig + if: matrix.zig + run: cargo zigbuild --release --target ${{ matrix.target }} + - name: build_native + if: ${{ !matrix.zig }} + run: cargo build --release --target ${{ matrix.target }} + - uses: actions/upload-artifact@v4 + with: + name: docker-proxy-${{ matrix.target }} + path: target/${{ matrix.target }}/release/docker-proxy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3a6a2c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: version tag to publish, e.g. v1.0.1 + required: true + +permissions: + contents: write + +env: + bin_name: docker-proxy + zig_version: "0.13.0" + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + asset: docker-proxy-linux-x86_64 + zig: true + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + asset: docker-proxy-linux-aarch64 + zig: true + - os: macos-latest + target: x86_64-apple-darwin + asset: docker-proxy-macos-x86_64 + zig: false + - os: macos-latest + target: aarch64-apple-darwin + asset: docker-proxy-macos-arm64 + zig: false + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: install_zigbuild + if: matrix.zig + run: | + curl -fsSL "https://ziglang.org/download/${zig_version}/zig-linux-x86_64-${zig_version}.tar.xz" | tar -xJ + echo "$PWD/zig-linux-x86_64-${zig_version}" >> "$GITHUB_PATH" + cargo install cargo-zigbuild + - name: build_zig + if: matrix.zig + run: cargo zigbuild --release --target ${{ matrix.target }} --bin "$bin_name" + - name: build_native + if: ${{ !matrix.zig }} + run: cargo build --release --target ${{ matrix.target }} --bin "$bin_name" + - name: stage_asset + run: | + mkdir -p dist + cp "target/${{ matrix.target }}/release/${bin_name}" "dist/${{ matrix.asset }}" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: dist/${{ matrix.asset }} + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + env: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} + steps: + - name: resolve_tag + run: echo "release_tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_ENV" + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: generate_checksums + working-directory: dist + run: sha256sum ${bin_name}-* > SHA256SUMS + - name: sign_checksums + working-directory: dist + run: | + if [ -z "$gpg_private_key" ]; then + echo "no signing key configured, publishing checksums without a signature" + exit 0 + fi + echo "$gpg_private_key" | gpg --batch --import + key_id=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit}') + gpg --batch --pinentry-mode loopback --passphrase "$gpg_passphrase" \ + --armor --detach-sign --local-user "$key_id" \ + -o SHA256SUMS.asc SHA256SUMS + - name: publish_release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.release_tag }} + name: ${{ env.release_tag }} + fail_on_unmatched_files: false + files: | + dist/docker-proxy-linux-x86_64 + dist/docker-proxy-linux-aarch64 + dist/docker-proxy-macos-x86_64 + dist/docker-proxy-macos-arm64 + dist/SHA256SUMS + dist/SHA256SUMS.asc + body: | + ## install + + ```bash + curl -fsSL https://raw.githubusercontent.com/Nemu-Bridge/docker-proxy/main/setup -o setup + sudo bash setup + ``` + + The installer downloads the matching binary for your platform, verifies it against `SHA256SUMS`, generates admin and readonly tokens into `/etc/docker-proxy/config.yaml`, and installs and starts the systemd service. + + Linux builds are statically linked (musl). macOS builds: `arm64` for Apple Silicon, `x86_64` for Intel. + + If `SHA256SUMS.asc` is attached, verify it with the project release signing key before trusting the checksums. diff --git a/Cargo.lock b/Cargo.lock index 28f3024..c59bada 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,7 @@ dependencies = [ "regex", "rustls", "rustls-pemfile", + "secrecy", "serde", "serde_json", "serde_yaml", @@ -226,6 +227,7 @@ dependencies = [ "tracing", "tracing-subscriber", "x509-parser", + "zeroize", ] [[package]] @@ -868,6 +870,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1625,6 +1637,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index a1092e9..6675855 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ serde_yaml = "0.9" serde_json = "1" regex = "1" ipnet = "2" +secrecy = { version = "0.10", features = ["serde"] } +zeroize = { version = "1", features = ["derive"] } dialoguer = { version = "0.11", features = ["fuzzy-select"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } rustls = { version = "0.23", default-features = false, features = ["std", "ring", "tls12", "logging"] } diff --git a/README.md b/README.md index 2f5d777..035f6c2 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,90 @@ dependencies beyond the Docker socket it talks to. ## Quick start +One command, run as root: + ```bash curl -fsSL https://raw.githubusercontent.com/Nemu-Bridge/docker-proxy/main/setup | sudo bash ``` -This downloads the latest binary, generates secure tokens, writes a config to -`/etc/docker-proxy/config.yaml`, and optionally installs a systemd service -(Linux). macOS users get manual start instructions printed at the end. +That's it. The proxy is now running on `127.0.0.1:2376`. + +Prefer to read the script before running it as root (recommended)? Download it +first, inspect it, then run: + +```bash +curl -fsSL -o setup https://raw.githubusercontent.com/Nemu-Bridge/docker-proxy/main/setup +less setup +sudo ./setup +``` + +The installer downloads the latest release binary for your platform, verifies +it against the published `SHA256SUMS`, generates secure admin and readonly +tokens, writes a config to `/etc/docker-proxy/config.yaml`, and (on Linux) +installs and starts a systemd service. macOS users get manual start +instructions printed at the end. If the download fails (private repo, no `gh` +CLI), the script prints the exact download URL for your platform and manual +install instructions. + +### Your tokens (the only secrets you store) + +Setup generates two bearer tokens and writes them into +`/etc/docker-proxy/config.yaml` (mode `640`, readable by root only). You don't +create them - you just read them back and hand them to whatever talks to the +proxy: + +```bash +sudo grep -A1 'role: admin' /etc/docker-proxy/config.yaml # admin token (full access) +sudo grep -A1 'role: readonly' /etc/docker-proxy/config.yaml # readonly token (GET-only) +``` + +Use a token by sending it as a Bearer header: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:2376/version +docker -H tcp://127.0.0.1:2376 ps # for a docker client, pass the token via your client config +``` + +Treat these like passwords. Rotate them by editing the config and restarting +the service (see below). + +### Changing the configuration + +Everything is in `/etc/docker-proxy/config.yaml` - port, bind address, Docker +socket path, auth, and the ordered access rules. To change anything: + +```bash +sudo nano /etc/docker-proxy/config.yaml # edit port / rules / tokens +docker-proxy --check-config # validate before applying +sudo systemctl restart docker-proxy # apply the change +journalctl -u docker-proxy -f # watch it come back up +``` + +The rule syntax and every available knob are documented in +[Configuration file](#configuration-file) below. + +### Verifying release artifacts + +Each release publishes a `SHA256SUMS` file. The setup script downloads and +verifies it automatically - this is the default and requires nothing from the +user. -If the download fails (private repo, no `gh` CLI), the script prints the exact -download URL for your platform and instructions to install manually. +Releases can additionally be GPG-signed. The automated release pipeline signs +`SHA256SUMS` into `SHA256SUMS.asc` whenever the `GPG_PRIVATE_KEY` and +`GPG_PASSPHRASE` repository secrets are configured (see +[Releasing](#releasing)). For a local/manual release, set +`DOCKER_PROXY_SIGNING_KEY` to the key ID before running `./update`: + +```bash +export DOCKER_PROXY_SIGNING_KEY="0xYOURKEYID" +./update +``` + +When a `SHA256SUMS.asc` is present, `setup` verifies it - but only succeeds if +the release signing key is already imported into the user's GPG keyring, and +**aborts the install if verification fails**. Only enable signing once you also +publish and document the public key, otherwise it breaks the one-command +install for everyone who hasn't imported it. To build from source instead: @@ -54,25 +128,27 @@ global: ``` The proxy logs a warning when binding off-loopback without TLS. Docker API -access is powerful — you should **either**: +access is powerful - you should **either**: - Configure TLS (`global.tls`) so traffic is encrypted and authenticated. - Restrict access with a firewall (`ufw`, `iptables`, or your cloud provider's security group) to only allow trusted IPs. -To use the bundled example config with authentication, copy it and supply a -token: +To use the bundled example config with authentication, copy it and set the +required environment variables: ```bash cp config.yaml my-config.yaml -# edit my-config.yaml to set your own tokens +# Tokens must be supplied via environment variables in the example config. +export ADMIN_TOKEN="$(openssl rand -base64 48)" +export READONLY_TOKEN="$(openssl rand -base64 48)" # Admin token (full access) -curl -H "Authorization: Bearer admin-token-abc123" \ +curl -H "Authorization: Bearer $ADMIN_TOKEN" \ http://127.0.0.1:2376/containers/json # Readonly token (GET-only) -curl -H "Authorization: Bearer readonly-token-xyz789" \ +curl -H "Authorization: Bearer $READONLY_TOKEN" \ http://127.0.0.1:2376/volumes ``` @@ -84,93 +160,100 @@ file exists, the proxy runs with all defaults -- no authentication, no rules, port 2376, auto-detected Docker socket. Environment variables can be referenced anywhere in the config using -`${VARIABLE_NAME}` syntax. Unset variables expand to an empty string. +`${VARIABLE_NAME}` syntax. For authentication tokens and secrets, a missing +variable causes the proxy to refuse to start. For other values, unset variables +expand to an empty string. ### Top-level structure ```yaml -global: # optional -- override defaults -auth: # optional -- authentication configuration -rules: # optional -- ordered access control rules +global: # optional -- override defaults +auth: # optional -- authentication configuration +rules: # optional -- ordered access control rules ``` ### `global` -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `port` | `u16` | `2376` | TCP port the proxy binds to. Overrides `DOCKER_PROXY_PORT`. | -| `bind` | `string` | `127.0.0.1` | Host/IP to bind. Use `0.0.0.0` only with TLS configured; the proxy logs a loud warning if you bind off-loopback without TLS. | -| `socket` | `string` | auto | Path to the Docker Unix socket. Overrides `DOCKER_SOCKET`. | -| `log_level` | `string` | `info` | Log level filter (`trace`, `debug`, `info`, `warn`, `error`). | -| `log_format` | `string` | `text` | `text` for human logs, `json` for structured one-line-per-event output suitable for Loki/Elasticsearch. | -| `audit_log` | `string` | -- | Path to an append-only JSON audit log of denied / dry-run / auth-failure events. Each line is a self-contained JSON record. | -| `tls` | `object` | -- | TLS termination. See below. | -| `metrics` | `object` | -- | Prometheus metrics endpoint. See below. | +| Key | Type | Default | Description | +| ----------------- | -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `port` | `u16` | `2376` | TCP port the proxy binds to. Overrides `DOCKER_PROXY_PORT`. | +| `bind` | `string` | `127.0.0.1` | Host/IP to bind. Use `0.0.0.0` only with TLS configured; the proxy logs a loud warning if you bind off-loopback without TLS. | +| `socket` | `string` | auto | Path to the Docker Unix socket. Overrides `DOCKER_SOCKET`. | +| `log_level` | `string` | `info` | Log level filter (`trace`, `debug`, `info`, `warn`, `error`). | +| `log_format` | `string` | `text` | `text` for human logs, `json` for structured one-line-per-event output suitable for Loki/Elasticsearch. | +| `audit_log` | `string` | -- | Path to an append-only JSON audit log of denied / dry-run / auth-failure events. Each line is a self-contained JSON record. | +| `tls` | `object` | -- | TLS termination. See below. | +| `metrics` | `object` | -- | Prometheus metrics endpoint. See below. | +| `trusted_proxies` | `array` | -- | CIDR list of trusted reverse proxies. When the direct peer matches, `client_ip` rules use `X-Forwarded-For`, `X-Real-Ip`, or `Forwarded`. | #### `global.tls` When set, the listener is wrapped in rustls (ring backend, TLS 1.2+1.3). Clients must speak HTTPS. -| Key | Type | Description | -|-----|------|-------------| -| `cert` | `string` | Path to a PEM-encoded server certificate chain. | -| `key` | `string` | Path to a PEM-encoded private key (PKCS#8, PKCS#1, or SEC1). | -| `client_ca` | `string` | Optional. Path to a PEM bundle of CAs used to verify client certificates. Enables mTLS. | -| `require_client_cert` | `bool` | When `true`, the TLS handshake fails unless the client presents a valid cert. Defaults to `false` (optional client cert). | +| Key | Type | Description | +| --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| `cert` | `string` | Path to a PEM-encoded server certificate chain. | +| `key` | `string` | Path to a PEM-encoded private key (PKCS#8, PKCS#1, or SEC1). | +| `client_ca` | `string` | Optional. Path to a PEM bundle of CAs used to verify client certificates. Enables mTLS. | +| `require_client_cert` | `bool` | When `true`, the TLS handshake fails unless the client presents a valid cert. Defaults to `false` (optional client cert). | #### `global.metrics` -| Key | Type | Description | -|-----|------|-------------| -| `enabled` | `bool` | Set `true` to expose the metrics endpoint on the proxy port. | -| `path` | `string` | URL path the metrics live at. Defaults to `/metrics`. | +| Key | Type | Description | +| --------- | -------- | ------------------------------------------------------------ | +| `enabled` | `bool` | Set `true` to expose the metrics endpoint on the proxy port. | +| `path` | `string` | URL path the metrics live at. Defaults to `/metrics`. | The endpoint emits Prometheus text format (counters + a histogram of upstream latency). **Counters:** -| Metric | Description | -|--------|-------------| -| `docker_proxy_requests_total` | Total requests received | -| `docker_proxy_requests_allowed_total` | Requests explicitly allowed (past the rule engine) | -| `docker_proxy_requests_denied_total` | Requests denied by rule or auth | -| `docker_proxy_requests_dry_run_total` | Requests matched by dry-run rules (allowed but logged) | -| `docker_proxy_auth_failures_total` | Failed authentication attempts | -| `docker_proxy_auth_lockouts_total` | IP-based auth lockouts triggered | -| `docker_proxy_rate_limited_total` | Requests denied due to rate limiting | -| `docker_proxy_upgrade_total` | Successful upgrade connections (exec/attach/logs) | -| `docker_proxy_upstream_errors_total` | Docker connection or handshake failures | -| `docker_proxy_upstream_timeouts_total` | Docker upstream timeouts | -| `docker_proxy_body_too_large_total` | Requests rejected for exceeding 10 MB body limit | -| `docker_proxy_rule_decisions_total{rule, mode}` | Per-rule deny/dry_run counts | +| Metric | Description | +| ----------------------------------------------- | ------------------------------------------------------ | +| `docker_proxy_requests_total` | Total requests received | +| `docker_proxy_requests_allowed_total` | Requests explicitly allowed (past the rule engine) | +| `docker_proxy_requests_denied_total` | Requests denied by rule or auth | +| `docker_proxy_requests_dry_run_total` | Requests matched by dry-run rules (allowed but logged) | +| `docker_proxy_auth_failures_total` | Failed authentication attempts | +| `docker_proxy_auth_lockouts_total` | IP-based auth lockouts triggered | +| `docker_proxy_rate_limited_total` | Requests denied due to rate limiting | +| `docker_proxy_upgrade_total` | Successful upgrade connections (exec/attach/logs) | +| `docker_proxy_upstream_errors_total` | Docker connection or handshake failures | +| `docker_proxy_upstream_timeouts_total` | Docker upstream timeouts | +| `docker_proxy_body_too_large_total` | Requests rejected for exceeding 10 MB body limit | +| `docker_proxy_rule_decisions_total{rule, mode}` | Per-rule deny/dry_run counts | **Histogram:** `docker_proxy_upstream_latency_ms` with buckets: `5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, +Inf` -The metrics endpoint does not require authentication — keep it on a private network or wrap it in TLS. +The metrics endpoint is served after authentication; a valid bearer token or +client certificate is required to access it. Keep the metrics path private and +avoid setting it to a path that overlaps with the Docker API. ### `auth` Controls how incoming requests are authenticated. -| Key | Type | Description | -|-----|------|-------------| -| `type` | `string` | Auth scheme. `bearer` enforces Bearer token auth. `none` disables auth entirely. `mtls` uses the client TLS certificate as the identity. | -| `secret` | `string` | A shared Bearer token. Authenticated clients receive the `admin` role. Supports env interpolation (`"${MY_SECRET}"`). | -| `tokens` | `array` | Per-token configuration for fine-grained role assignment. | -| `mtls` | `object` | Settings used when `type: mtls`. See below. | +| Key | Type | Description | +| -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | `string` | Auth scheme. `bearer` enforces Bearer token auth. `none` disables auth entirely. `mtls` uses the client TLS certificate as the identity. | +| `secret` | `string` | A shared Bearer token. Authenticated clients receive the `admin` role. Supports env interpolation (`"${MY_SECRET}"`). | +| `tokens` | `array` | Per-token configuration for fine-grained role assignment. | +| `mtls` | `object` | Settings used when `type: mtls`. See below. | #### `auth.mtls` When `auth.type` is `mtls`, the client cert subject is consulted to determine the role. -| Key | Type | Description | -|-----|------|-------------| -| `cert_role_map` | `array` | Ordered list of `{cn, role}` entries. The cert's CN and SANs are matched against each entry; `*.example.com` wildcards match one label. First match wins. | -| `default_role` | `string` | Role assigned when no map entry matches and the cert has no CN. | +| Key | Type | Description | +| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cert_role_map` | `array` | Ordered list of `{cn, role}` entries. The cert's CN and SANs are matched against each entry; `*.example.com` wildcards match one label. First match wins. | +| `default_role` | `string` | Role assigned when no map entry matches and the cert has no CN. | -If neither a map entry matches nor `default_role` is set but the cert has a CN, the CN itself is used as the role string — so a cert with `CN=admin` gets role `admin` automatically. This means you can ship mTLS with no per-cert config and just name your certs after your roles, or use the explicit map for stricter control. +If no map entry matches, the request is denied unless `default_role` is set. +The CN is never used as a role automatically; doing so would allow any CA that +issues arbitrary CNs to grant arbitrary roles. **Fail-closed defaults.** If no auth section is set, or if `tokens: []` is empty, the proxy refuses every request with 401. To run without authentication you must set `auth.type: none` explicitly. A valid `Authorization: Bearer ` header is required when any token or secret is configured. The proxy checks tokens first, then falls back to the shared secret. Token-based roles take precedence. @@ -178,10 +261,10 @@ Failed auth attempts are tracked **per source IP** (not per token). 10 failures Each entry under `tokens`: -| Key | Type | Description | -|-----|------|-------------| +| Key | Type | Description | +| ------- | -------- | --------------------------------------------------- | | `token` | `string` | The Bearer token value. Supports env interpolation. | -| `role` | `string` | Role assigned to this token (default: `user`). | +| `role` | `string` | Role assigned to this token (default: `user`). | **Environment variable overrides.** `DOCKER_PROXY_SECRET` overrides `auth.secret` @@ -198,18 +281,18 @@ If no rule matches, the request is allowed. #### Rule fields -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `name` | `string` | required | Human-readable identifier. | -| `description` | `string` | -- | Optional description of what the rule does. | -| `action` | `string` | required | One of `deny`, `allow`, `require_role`, `response_filter`. | -| `conditions` | `array` | `[]` | List of conditions. All must evaluate true for the rule to fire. | -| `message` | `string` | -- | Custom response body returned when the rule blocks a request. | -| `status` | `u16` | `403` | HTTP status code for blocked requests. | -| `role` | `string` | `admin` | Required role for `require_role` action. | -| `response_filter` | `array` | -- | List of filter entries. Used only with `action: response_filter`. | -| `priority` | `u32` | `0` | Higher priority rules are evaluated first. Rules with equal priority keep their declaration order (stable sort). | -| `dry_run` | `bool` | `false` | When `true`, a rule that would otherwise deny instead allows the request through and emits an audit-log event tagged `dry_run`. Useful for rolling out new policies. | +| Key | Type | Default | Description | +| ----------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | required | Human-readable identifier. | +| `description` | `string` | -- | Optional description of what the rule does. | +| `action` | `string` | required | One of `deny`, `allow`, `require_role`, `response_filter`. | +| `conditions` | `array` | `[]` | List of conditions. All must evaluate true for the rule to fire. | +| `message` | `string` | -- | Custom response body returned when the rule blocks a request. | +| `status` | `u16` | `403` | HTTP status code for blocked requests. | +| `role` | `string` | `admin` | Required role for `require_role` action. | +| `response_filter` | `array` | -- | List of filter entries. Used only with `action: response_filter`. | +| `priority` | `u32` | `0` | Higher priority rules are evaluated first. Rules with equal priority keep their declaration order (stable sort). | +| `dry_run` | `bool` | `false` | When `true`, a rule that would otherwise deny instead allows the request through and emits an audit-log event tagged `dry_run`. Useful for rolling out new policies. | #### Conditions @@ -218,30 +301,30 @@ to compare, and an optional `value` to compare against. ##### Condition fields -| Field | Description | -|-------|-------------| -| `path` | The request path (e.g. `/containers/json`). | -| `method` | The HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`). | -| `client_ip` | The connecting client's IP address. | -| `header.` | A specific request header. `` is case-insensitive. | -| `body.` | A field within the parsed JSON request body. `` uses dot notation for nested objects (e.g. `HostConfig.Privileged`) and numeric indices for array elements (e.g. `Env.0`). | +| Field | Description | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path` | The request path (e.g. `/containers/json`). | +| `method` | The HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`). | +| `client_ip` | The connecting client's IP address. | +| `header.` | A specific request header. `` is case-insensitive. | +| `body.` | A field within the parsed JSON request body. `` uses dot notation for nested objects (e.g. `HostConfig.Privileged`) and numeric indices for array elements (e.g. `Env.0`). | ##### Condition operators -| Operator | Applies To | Description | -|----------|-----------|-------------| -| `equals` | all | Exact value match. For `body` fields, compares typed values (strings, booleans, numbers). | -| `not_equals` | all | Negation of `equals`. | -| `contains` | path, header, body | Substring match. | -| `not_contains` | path, header, body | Negation of `contains`. | -| `starts_with` | path, header, body | Prefix match. | -| `ends_with` | path, header | Suffix match. | -| `matches` | path, header, body | Regex match (Rust regex syntax). | -| `not_matches` | path, header | Negation of `matches`. | -| `in` | all | Value is present in a YAML list. For `client_ip`, list entries are parsed as CIDR ranges. | -| `not_in` | all | Value is absent from a YAML list. CIDR-aware for `client_ip`. | -| `exists` | header, body | The field exists or is present in the JSON body. | -| `not_exists` | header, body | The field is missing or absent from the JSON body. | +| Operator | Applies To | Description | +| -------------- | ------------------ | ----------------------------------------------------------------------------------------- | +| `equals` | all | Exact value match. For `body` fields, compares typed values (strings, booleans, numbers). | +| `not_equals` | all | Negation of `equals`. | +| `contains` | path, header, body | Substring match. | +| `not_contains` | path, header, body | Negation of `contains`. | +| `starts_with` | path, header, body | Prefix match. | +| `ends_with` | path, header | Suffix match. | +| `matches` | path, header, body | Regex match (Rust regex syntax). | +| `not_matches` | path, header | Negation of `matches`. | +| `in` | all | Value is present in a YAML list. For `client_ip`, list entries are parsed as CIDR ranges. | +| `not_in` | all | Value is absent from a YAML list. CIDR-aware for `client_ip`. | +| `exists` | header, body | The field exists or is present in the JSON body. | +| `not_exists` | header, body | The field is missing or absent from the JSON body. | ##### Condition Grouping (AND / OR) @@ -290,39 +373,44 @@ supported and continue to behave as before. ##### Actions -| Action | Terminating? | Description | -|--------|-------------|-------------| -| `deny` | yes | Blocks the request immediately with the configured `status` and `message`. | -| `allow` | yes | Explicitly allows the request, short-circuiting further rule evaluation. Useful for carving out exceptions ordered before broad deny rules. | -| `require_role` | yes | Blocks the request if the authenticated user's role is not `admin` and does not match the rule's required `role`. The `admin` role always passes. | -| `response_filter` | no | Allows the request but applies JSON transformations to the Docker response body. Filters from multiple matching rules accumulate. Only applied when the response `content-type` is `application/json`. | -| `rate_limit` | yes (when exceeded) | Enforces a token-bucket rate limit per client IP. If the bucket has tokens available, the request continues and 1 token is consumed. If the bucket is empty, the request is denied with `status` (default `429`). When the limit is not exceeded, evaluation continues to the next rule. | +| Action | Terminating? | Description | +| ----------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deny` | yes | Blocks the request immediately with the configured `status` and `message`. | +| `allow` | yes | Explicitly allows the request, short-circuiting further rule evaluation. Useful for carving out exceptions ordered before broad deny rules. | +| `require_role` | yes | Blocks the request if the authenticated user's role is not `admin` and does not match the rule's required `role`. The `admin` role always passes. | +| `response_filter` | no | Allows the request but applies JSON transformations to the Docker response body. Filters from multiple matching rules accumulate. Only applied when the response `content-type` is `application/json`. | +| `rate_limit` | yes (when exceeded) | Enforces a token-bucket rate limit per client IP. If the bucket has tokens available, the request continues and 1 token is consumed. If the bucket is empty, the request is denied with `status` (default `429`). When the limit is not exceeded, evaluation continues to the next rule. | ##### Response filter entries Used within the `response_filter` array of a rule. -| Key | Type | Description | -|-----|------|-------------| -| `field` | `string` | Dot-notation path to the JSON field to modify (e.g. `Config.Env`, `Items.0.Name`). Supports array indices. | -| `action` | `string` | `redact` (replace with `***REDACTED***`), `remove` (delete the field), or `replace` (set to `replacement`). | -| `replacement` | `string` | Replacement value for the `replace` action. | +| Key | Type | Description | +| ------------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `field` | `string` | Dot-notation path to the JSON field to modify (e.g. `Config.Env`, `Items.0.Name`). Supports array indices. | +| `action` | `string` | `redact` (replace with `***REDACTED***`), `remove` (delete the field), or `replace` (set to `replacement`). | +| `replacement` | `string` | Replacement value for the `replace` action. | Response filters only apply when the Docker response `content-type` is `application/json`. Non-JSON responses pass through unchanged. Filters are accumulated from all matching `response_filter` rules but are **not** applied to -upgrade (streaming) connections — `docker exec`, `attach`, and `logs -f` +upgrade (streaming) connections - `docker exec`, `attach`, and `logs -f` bypass the response filter pipeline. +**Security note:** If you rely on response filters to hide secrets such as +`Config.Env`, also add a rule that denies `/containers/*/exec` and +`/exec/*/start`, or an attacker can extract the same data through a streaming +exec session. + ##### Rate limit config Used within the `rate_limit` field of a rule with `action: rate_limit`. -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `requests` | `u64` | `50` | Maximum requests allowed in the window (bucket capacity). | -| `period` | `u64` | `30` | Window size in seconds. Tokens refill at `requests / period` per second. | -| `penalty` | `u64` | `30` | Cooldown in seconds after hitting the limit. Once the bucket empties, the client is blocked for this many seconds. After the penalty expires, the bucket resets to full capacity. | +| Key | Type | Default | Description | +| ---------- | ----- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `requests` | `u64` | `50` | Maximum requests allowed in the window (bucket capacity). | +| `period` | `u64` | `30` | Window size in seconds. Tokens refill at `requests / period` per second. | +| `penalty` | `u64` | `30` | Cooldown in seconds after hitting the limit. Once the bucket empties, the client is blocked for this many seconds. After the penalty expires, the bucket resets to full capacity. | The limiter uses a per-rule, per-client-IP token bucket. Each client starts with a full bucket. Every request consumes 1 token. Tokens refill continuously. @@ -398,24 +486,24 @@ Every HTTP request to the proxy opens a **new** Unix socket connection to Docker with `connection: close` (no connection reuse). The proxy enforces these limits: -| Limit | Value | Behavior | -|-------|-------|----------| -| Request body | 10 MB | Exceeding returns `413 Payload Too Large` | -| Response body | 64 MB | Exceeding returns `502 Bad Gateway` | -| Concurrent connections | 1,024 | Additional connections are dropped with a warning | -| Path length | 8,192 bytes | Longer paths return `400 Bad Request` | -| Token length | 4,096 bytes | Longer tokens are rejected | -| Rate-limit buckets | 16,384 distinct IPs | New IPs beyond this cap are denied | -| Auth-lockout keys | 16,384 distinct IPs | New IPs beyond this cap are not tracked | - -| Timeout | Value | Applies to | -|---------|-------|------------| -| Docker socket connect | 5 s | Unix socket connection to Docker | -| Request body read | 30 s | Client sending the request body | -| Docker upstream response | 300 s (5 min) | Docker processing and sending the response | -| TLS handshake | 10 s | TLS negotiation | -| Upgrade resolve | 30 s | 101 Switching Protocols handshake | -| Upgrade idle | 3,600 s (1 hr) | Streaming connections with no traffic | +| Limit | Value | Behavior | +| ---------------------- | ------------------- | ------------------------------------------------- | +| Request body | 10 MB | Exceeding returns `413 Payload Too Large` | +| Response body | 64 MB | Exceeding returns `502 Bad Gateway` | +| Concurrent connections | 1,024 | Additional connections are dropped with a warning | +| Path length | 8,192 bytes | Longer paths return `400 Bad Request` | +| Token length | 4,096 bytes | Longer tokens are rejected | +| Rate-limit buckets | 16,384 distinct IPs | New IPs beyond this cap are denied | +| Auth-lockout keys | 16,384 distinct IPs | New IPs beyond this cap are not tracked | + +| Timeout | Value | Applies to | +| ------------------------ | -------------- | ------------------------------------------ | +| Docker socket connect | 5 s | Unix socket connection to Docker | +| Request body read | 30 s | Client sending the request body | +| Docker upstream response | 300 s (5 min) | Docker processing and sending the response | +| TLS handshake | 10 s | TLS negotiation | +| Upgrade resolve | 30 s | 101 Switching Protocols handshake | +| Upgrade idle | 3,600 s (1 hr) | Streaming connections with no traffic | ## Streaming (`docker exec`, `attach`, `logs -f`) @@ -432,7 +520,21 @@ Endpoints that upgrade to a raw TCP stream are forwarded transparently. The prox If `global.audit_log` is set, the proxy spawns a writer task that appends one JSON record per line for each denied, dry-run, or auth-failure event: ```json -{"timestamp":"2026-05-12T19:09:18Z","event":"deny","peer_ip":"10.0.0.5","method":"POST","path":"/containers/create","user_agent":"docker/24.0","user_role":"readonly","identity":null,"rule_name":"admin-only-create","rule_action":"require_role","status":403,"dry_run":false,"message":"Admin role required to create containers"} +{ + "timestamp": "2026-05-12T19:09:18Z", + "event": "deny", + "peer_ip": "10.0.0.5", + "method": "POST", + "path": "/containers/create", + "user_agent": "docker/24.0", + "user_role": "readonly", + "identity": null, + "rule_name": "admin-only-create", + "rule_action": "require_role", + "status": 403, + "dry_run": false, + "message": "Admin role required to create containers" +} ``` Event types: `deny` (rule blocked), `auth_denied` (auth failure), `dry_run` (would-be deny logged but allowed through). @@ -443,7 +545,12 @@ event is immediately flushed to disk for crash consistency. ## Hot reload -Send `SIGHUP` to the proxy process and it re-reads the same config file it started from. The new rules and auth tables apply to the next accepted request; in-flight connections keep their old config. If the new file fails to parse, the proxy logs an error and keeps running on the previous config. +Send `SIGHUP` to the proxy process and it re-reads the same config file it +started from. The listener is closed and rebound using the new `global.bind`, +`global.port`, and `global.tls` settings, so network and TLS changes take +effect. In-flight connections are dropped during the brief restart window. If +the new file fails to parse, the proxy logs an error and keeps running on the +previous config and listener. ```bash kill -HUP $(pgrep docker-proxy) @@ -459,11 +566,11 @@ Running with defaults does not spawn the reloader. docker-proxy [--config ] [--check-config] ``` -| Flag | Description | -|------|-------------| -| `--config ` | Path to the YAML config (overrides `DOCKER_PROXY_CONFIG`). | -| `--check-config` | Parse the config, print the effective rule set sorted by priority, exit. Returns non-zero if parsing fails. Use in CI before deploys. | -| `--help`, `-h` | Print usage and exit. | +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `--config ` | Path to the YAML config (overrides `DOCKER_PROXY_CONFIG`). | +| `--check-config` | Parse the config, print the effective rule set sorted by priority, exit. Returns non-zero if parsing fails. Use in CI before deploys. | +| `--help`, `-h` | Print usage and exit. | ## Logging @@ -476,7 +583,7 @@ and the peer IP, method, path, user agent, and status code. Paths are truncated to 256 characters and control characters are sanitized. System events (config reloads, TLS errors, etc.) use the tracing subscriber -without timestamps — only request/response lines carry the manual timestamp. +without timestamps - only request/response lines carry the manual timestamp. When shipping logs to Loki or Elasticsearch, attach the collection timestamp for system events. @@ -589,6 +696,21 @@ can cover multiple endpoint patterns without duplication. message: "Access restricted to internal network" ``` +When running behind a trusted reverse proxy such as Cloudflare, set +`global.trusted_proxies`: + +```yaml +global: + trusted_proxies: + - "10.0.0.0/8" + - "103.21.244.0/22" # example Cloudflare range +``` + +If the direct peer matches a trusted proxy CIDR, `client_ip` is taken from +`X-Real-Ip`, `X-Forwarded-For`, or `Forwarded` (in that order). For +`X-Forwarded-For`, the rightmost untrusted address is used. If the peer is not +trusted, proxy headers are ignored and the direct peer IP is used. + ### Redact sensitive response data ```yaml @@ -646,7 +768,7 @@ For endpoint-specific limits, narrow the `path` condition: ```yaml - name: "watch-image-pulls" - description: Log image pulls but don't block — yet + description: Log image pulls but don't block - yet priority: 100 conditions: - field: path @@ -724,37 +846,39 @@ rule, so health checks pass while everything else is blocked. ## Environment variables -| Variable | Description | Default | -|----------|-------------|---------| -| `DOCKER_PROXY_CONFIG` | Path to the YAML configuration file. | `./config.yaml` | -| `DOCKER_PROXY_SECRET` | Shared Bearer token. Overrides `auth.secret` (unless `auth.type: none`). | (none) | -| `DOCKER_PROXY_PORT` | TCP listen port. Overridden by `global.port` if set. | `2376` | -| `DOCKER_SOCKET` | Path to the Docker Unix socket. Overridden by `global.socket` if the path exists. | auto-detected | -| `RUST_LOG` | Tracing log filter. Overrides `global.log_level`. | `docker_proxy=info` | +| Variable | Description | Default | +| --------------------- | --------------------------------------------------------------------------------- | ------------------- | +| `DOCKER_PROXY_CONFIG` | Path to the YAML configuration file. | `./config.yaml` | +| `DOCKER_PROXY_SECRET` | Shared Bearer token. Overrides `auth.secret` (unless `auth.type: none`). | (none) | +| `DOCKER_PROXY_PORT` | TCP listen port. Overridden by `global.port` if set. | `2376` | +| `DOCKER_SOCKET` | Path to the Docker Unix socket. Overridden by `global.socket` if the path exists. | auto-detected | +| `RUST_LOG` | Tracing log filter. Overrides `global.log_level`. | `docker_proxy=info` | Socket auto-detection order: + - macOS: `~/.docker/run/docker.sock`, `/var/run/docker.sock`, `~/.docker/desktop/docker.sock` - Linux: `/var/run/docker.sock`, `/run/docker.sock` **Override precedence** (highest to lowest): -1. CLI `--config ` — sets `DOCKER_PROXY_CONFIG` +1. CLI `--config ` - sets `DOCKER_PROXY_CONFIG` 2. `DOCKER_PROXY_PORT` / `DOCKER_SOCKET` / `DOCKER_PROXY_SECRET` env vars 3. `${ENV_VAR}` interpolation in YAML values 4. YAML file values -5. `RUST_LOG` — overrides `global.log_level` +5. `RUST_LOG` - overrides `global.log_level` 6. Built-in defaults ## Scripts -| Script | Purpose | -|--------|---------| -| `setup` | Download binary, generate config, install service. Detects OS/arch. | -| `build` | Cross-compile for all 4 targets (linux x86_64, linux aarch64, macos x86_64, macos arm64). | -| `update` | Publish built binaries as a GitHub release. Bumps version automatically. | -| `uninstall` | Stop and remove the service, binary, and config directory (with confirmation). | +| Script | Purpose | +| ----------- | ----------------------------------------------------------------------------------------- | +| `setup` | Download binary, generate config, install service. Detects OS/arch. | +| `build` | Cross-compile for all 4 targets (linux x86_64, linux aarch64, macos x86_64, macos arm64). | +| `update` | Publish built binaries as a GitHub release. Bumps version automatically. | +| `uninstall` | Stop and remove the service, binary, and config directory (with confirmation). | -**Release workflow:** +The `build` and `update` scripts are the **manual / offline** path. The normal +release path is fully automated - see [Releasing](#releasing). ```bash ./build # compiles all 4 targets into release/ @@ -763,6 +887,40 @@ Socket auto-detection order: Requires `cargo-zigbuild`, `zig`, and `gh` CLI authenticated. +## Continuous integration + +Two GitHub Actions workflows live in `.github/workflows/`: + +| Workflow | Trigger | What it does | +| ------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ci.yml` | push / PR to `main` or `canary` | `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`, bash script syntax checks, a cross-compile build matrix, and an end-to-end `api_e2e` job that runs the proxy against the runner's real Docker socket and asserts auth + a deny rule + the metrics endpoint behave correctly. | +| `release.yml` | push of a `v*.*.*` tag (or manual dispatch) | Cross-compiles all 4 targets, generates `SHA256SUMS`, optionally GPG-signs, and publishes a GitHub Release with the assets - the same artifact layout `setup` expects. | + +## Releasing + +Releases are automated. To cut one, tag a commit and push the tag: + +```bash +git tag v1.0.1 +git push origin v1.0.1 +``` + +`release.yml` then builds every target, checksums them, optionally signs, and +publishes the release. No local toolchain, no `gh` login, no running `./build` +or `./update` by hand. + +**Repository secrets:** + +| Secret | Required? | Purpose | +| ----------------- | --------- | ---------------------------------------------------------------------- | +| `GITHUB_TOKEN` | automatic | Provided by Actions; used to create the release. Nothing to configure. | +| `GPG_PRIVATE_KEY` | optional | ASCII-armored private key. When set, the pipeline signs `SHA256SUMS`. | +| `GPG_PASSPHRASE` | optional | Passphrase for that key. | + +Leave the GPG secrets unset for a frictionless, checksum-only install (the +recommended default). The manual `./build` + `./update` scripts remain available +for local or offline releases. + ## systemd service The setup script asks if you want to install and start a systemd service on @@ -805,7 +963,7 @@ The wizard is a separate binary (`docker-proxy-setup`). It walks through: - Port and socket configuration - Authentication (none, shared secret, or per-token roles) -- 14 built-in rule templates: +- 15 built-in rule templates: 1. Block exec operations 2. Block docker build 3. Readonly volumes @@ -816,10 +974,12 @@ The wizard is a separate binary (`docker-proxy-setup`). It walks through: 8. Block privileged containers 9. Block bind mounts 10. Block host network mode - 11. Admin-only container lifecycle (create, start, stop, delete) - 12. Internal network only (CIDR whitelist) - 13. Redact container environment variables from inspect - 14. Rate limit all requests + 11. Admin-only container lifecycle + 12. Admin-only create containers + 13. Admin-only delete resources + 14. Internal network only (CIDR whitelist) + 15. Redact container environment variables from inspect + 16. Rate limit all requests - Custom rule builder with all 5 actions, configurable status codes, multiple conditions, rate limit parameters, and response filter values @@ -833,14 +993,14 @@ The binary is at `target/release/docker-proxy`. ## Dependencies -| Crate | Purpose | -|-------|---------| -| `tokio` | Async runtime | -| `hyper` + `hyper-util` | HTTP server and client (Unix socket transport) | -| `http-body-util` | Request/response body utilities | -| `tracing` + `tracing-subscriber` | Structured logging | -| `serde` + `serde_yaml` | YAML config parsing | -| `serde_json` | JSON body inspection and response filtering | -| `regex` | Pattern matching in rule conditions | -| `ipnet` | CIDR matching for IP-based rules | -| `chrono` | Request log timestamps | +| Crate | Purpose | +| -------------------------------- | ---------------------------------------------- | +| `tokio` | Async runtime | +| `hyper` + `hyper-util` | HTTP server and client (Unix socket transport) | +| `http-body-util` | Request/response body utilities | +| `tracing` + `tracing-subscriber` | Structured logging | +| `serde` + `serde_yaml` | YAML config parsing | +| `serde_json` | JSON body inspection and response filtering | +| `regex` | Pattern matching in rule conditions | +| `ipnet` | CIDR matching for IP-based rules | +| `chrono` | Request log timestamps | diff --git a/build b/build index 9bfd994..eb99f79 100755 --- a/build +++ b/build @@ -22,11 +22,9 @@ check_deps() { fi if ! command -v cargo-zigbuild &>/dev/null; then - warn "cargo-zigbuild not found, installing ..." - cargo install cargo-zigbuild || { - err "Failed to install cargo-zigbuild" - exit 1 - } + err "cargo-zigbuild is not installed." + err " Install: cargo install cargo-zigbuild" + exit 1 fi if ! command -v zig &>/dev/null; then @@ -96,10 +94,21 @@ main() { native_arch=$(uname -m) if [ "$native_arch" = "arm64" ]; then build_target "aarch64-apple-darwin" "macOS arm64" "${BIN_NAME}-macos-arm64" + elif [ "$native_arch" = "x86_64" ]; then + build_target "aarch64-apple-darwin" "macOS arm64 (cross)" "${BIN_NAME}-macos-arm64" else - build_target "$native_arch-apple-darwin" "macOS native" "${BIN_NAME}-macos-arm64" + err "Unsupported macOS architecture: $native_arch" + exit 1 fi + echo "" + log "Generating checksums ..." + ( + cd "$RELEASE_DIR" + sha256sum ${BIN_NAME}-* > SHA256SUMS + ) + log " -> ${RELEASE_DIR}/SHA256SUMS" + echo "" log "All builds complete." echo "" diff --git a/config.yaml b/config.yaml index 715087f..e72b58a 100644 --- a/config.yaml +++ b/config.yaml @@ -1,5 +1,9 @@ # Docker Proxy Configuration # =========================== +# +# WARNING: Replace the placeholder token environment variables with real values +# before deploying. The proxy will refuse to start if ADMIN_TOKEN or READONLY_TOKEN +# is unset. global: port: 2376 @@ -11,9 +15,9 @@ global: auth: type: bearer tokens: - - token: "admin-token-abc123" + - token: "${ADMIN_TOKEN}" role: admin - - token: "readonly-token-xyz789" + - token: "${READONLY_TOKEN}" role: readonly # Access Rules (evaluated in order, first match wins) @@ -77,8 +81,8 @@ rules: operator: starts_with value: "/images" - field: method - operator: in - value: ["PUT", "POST", "DELETE"] + operator: not_equals + value: GET action: deny message: "Image mutations are forbidden" @@ -132,8 +136,12 @@ rules: - field: path operator: equals value: "/containers/create" - - field: body.HostConfig.Binds - operator: exists + - or: + - field: body.HostConfig.Binds + operator: exists + - field: body.HostConfig.Mounts + operator: matches + value: '"Type"\s*:\s*"bind"' action: deny message: "Bind mounts are not allowed" diff --git a/docker-proxy.service b/docker-proxy.service index 4fc9cbb..794efce 100644 --- a/docker-proxy.service +++ b/docker-proxy.service @@ -24,7 +24,7 @@ Group=docker NoNewPrivileges=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/etc/docker-proxy +ReadWritePaths=/etc/docker-proxy /var/log PrivateTmp=true [Install] diff --git a/examples/config-mtls.yaml b/examples/config-mtls.yaml index 9e0090e..13f49f5 100644 --- a/examples/config-mtls.yaml +++ b/examples/config-mtls.yaml @@ -82,6 +82,21 @@ rules: action: deny message: "Adding privileged Linux capabilities is not allowed" + - name: "block-bind-mounts" + priority: 500 + conditions: + - field: path + operator: equals + value: /containers/create + - or: + - field: body.HostConfig.Binds + operator: exists + - field: body.HostConfig.Mounts + operator: matches + value: '"Type"\s*:\s*"bind"' + action: deny + message: "Bind mounts are not allowed" + - name: "admin-mutations" conditions: - field: method diff --git a/examples/config-production.yaml b/examples/config-production.yaml index 028e227..808d64f 100644 --- a/examples/config-production.yaml +++ b/examples/config-production.yaml @@ -1,6 +1,6 @@ # Production config: full security rules with token-based auth # -# Swap the tokens below with ones generated by the setup script. +# Set ADMIN_TOKEN and READONLY_TOKEN in the environment before starting the proxy. global: port: 2376 @@ -93,8 +93,8 @@ rules: operator: starts_with value: "/images" - field: method - operator: in - value: ["PUT", "POST", "DELETE"] + operator: not_equals + value: GET action: deny message: "Image mutations are forbidden" @@ -138,8 +138,12 @@ rules: - field: path operator: equals value: "/containers/create" - - field: body.HostConfig.Binds - operator: exists + - or: + - field: body.HostConfig.Binds + operator: exists + - field: body.HostConfig.Mounts + operator: matches + value: '"Type"\s*:\s*"bind"' action: deny message: "Bind mounts are not allowed" diff --git a/examples/config-tls.yaml b/examples/config-tls.yaml index 369eac3..ccfb604 100644 --- a/examples/config-tls.yaml +++ b/examples/config-tls.yaml @@ -4,6 +4,8 @@ # rustls; clients must speak HTTPS. Tokens still flow at the application # layer. # +# Set ADMIN_TOKEN and READONLY_TOKEN in the environment before starting. +# # Generate a self-signed cert for local testing with: # openssl req -x509 -newkey rsa:4096 -nodes -days 365 \ # -keyout /etc/docker-proxy/server.key \ @@ -67,6 +69,33 @@ rules: action: deny message: "Exec operations are not permitted" + - name: "block-bind-mounts" + priority: 500 + conditions: + - field: path + operator: equals + value: "/containers/create" + - or: + - field: body.HostConfig.Binds + operator: exists + - field: body.HostConfig.Mounts + operator: matches + value: '"Type"\s*:\s*"bind"' + action: deny + message: "Bind mounts are not allowed" + + - name: "block-privileged" + priority: 500 + conditions: + - field: path + operator: equals + value: "/containers/create" + - field: body.HostConfig.Privileged + operator: equals + value: true + action: deny + message: "Privileged containers are not allowed" + - name: "admin-only-mutations" conditions: - field: method diff --git a/setup b/setup index 86a6219..10b20b9 100755 --- a/setup +++ b/setup @@ -110,20 +110,101 @@ download_via_curl() { return 1 } +download_checksums_via_gh() { + local tmp="$1" + if command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then + log "Downloading SHA256SUMS via gh CLI ..." + gh release download -R "$REPO" -p "SHA256SUMS" -D "$tmp" 2>/dev/null && return 0 + warn "gh SHA256SUMS download failed" + fi + return 1 +} + +download_checksums_via_curl() { + local tag="$1" tmp="$2" + local url="https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS" + log "Downloading SHA256SUMS from ${url} ..." + if command -v curl &>/dev/null; then + curl -fSL "$url" -o "$tmp/SHA256SUMS" 2>/dev/null && return 0 + elif command -v wget &>/dev/null; then + wget -q "$url" -O "$tmp/SHA256SUMS" 2>/dev/null && return 0 + fi + return 1 +} + +verify_checksum() { + local tmp="$1" asset_name="$2" + local sums="$tmp/SHA256SUMS" + if [ ! -f "$sums" ]; then + err "SHA256SUMS not found for release" + return 1 + fi + if ! command -v sha256sum &>/dev/null; then + err "sha256sum is required for checksum verification" + return 1 + fi + local expected + expected=$(awk -v name="$asset_name" '$2 == name {print $1}' "$sums") + if [ -z "$expected" ]; then + err "No checksum found for ${asset_name} in SHA256SUMS" + return 1 + fi + local actual + actual=$(sha256sum "$tmp/$BIN_NAME" | awk '{print $1}') + if [ "$expected" != "$actual" ]; then + err "Checksum mismatch for ${asset_name}" + err " expected: ${expected}" + err " actual: ${actual}" + return 1 + fi + log "Checksum verified for ${asset_name}" + return 0 +} + +download_signature_via_gh() { + local tmp="$1" + if command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then + gh release download -R "$REPO" -p "SHA256SUMS.asc" -D "$tmp" 2>/dev/null && return 0 + fi + return 1 +} + +download_signature_via_curl() { + local tag="$1" tmp="$2" + local url="https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS.asc" + if command -v curl &>/dev/null; then + curl -fSL "$url" -o "$tmp/SHA256SUMS.asc" 2>/dev/null && return 0 + elif command -v wget &>/dev/null; then + wget -q "$url" -O "$tmp/SHA256SUMS.asc" 2>/dev/null && return 0 + fi + return 1 +} + +verify_signature() { + local tmp="$1" + local sig="$tmp/SHA256SUMS.asc" + if [ ! -f "$sig" ]; then + return 0 + fi + if ! command -v gpg &>/dev/null; then + err "SHA256SUMS.asc is present but gpg is not installed" + return 1 + fi + if gpg --verify "$sig" "$tmp/SHA256SUMS" >/dev/null 2>&1; then + log "GPG signature on SHA256SUMS verified" + return 0 + fi + err "GPG signature verification failed for SHA256SUMS" + err "Import the project release signing key and try again" + return 1 +} + try_clone_and_setup() { log "Attempting to clone repository ..." local clone_dir clone_dir=$(mktemp -d) - local cloned=0 - if ssh -T git@github.com 2>&1 | grep -q "successfully authenticated"; then - git clone "git@github.com:${REPO}.git" "$clone_dir" 2>/dev/null && cloned=1 - fi - if [ "$cloned" -eq 0 ]; then - git clone "https://github.com/${REPO}.git" "$clone_dir" 2>/dev/null && cloned=1 - fi - - if [ "$cloned" -eq 1 ]; then + if git clone "https://github.com/${REPO}.git" "$clone_dir" 2>/dev/null; then log "Repository cloned. Running local setup ..." cd "$clone_dir" if [ -f ./setup ]; then @@ -152,11 +233,24 @@ download_binary() { if [ -n "$tag" ]; then log "Latest release: ${tag}" + local downloaded=0 if download_via_gh "$os" "$arch" "$tmp" "$asset_name"; then - install_binary "$tmp" && return 0 + downloaded=1 + elif download_via_curl "$tag" "$asset_name" "$tmp"; then + downloaded=1 fi - if download_via_curl "$tag" "$asset_name" "$tmp"; then - install_binary "$tmp" && return 0 + + if [ "$downloaded" -eq 1 ]; then + if download_checksums_via_gh "$tmp" || download_checksums_via_curl "$tag" "$tmp"; then + download_signature_via_gh "$tmp" || download_signature_via_curl "$tag" "$tmp" || true + if verify_signature "$tmp" && verify_checksum "$tmp" "$asset_name"; then + install_binary "$tmp" && return 0 + fi + return 1 + else + err "Downloaded binary but could not fetch SHA256SUMS for verification" + return 1 + fi fi else warn "Could not determine latest release tag" @@ -192,8 +286,20 @@ download_binary() { install_binary() { local tmp="$1" chmod +x "$tmp/$BIN_NAME" - mv "$tmp/$BIN_NAME" "$INSTALL_DIR/$BIN_NAME" - log "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}" + + # Verify the binary runs before installing + if ! "$tmp/$BIN_NAME" --help >/dev/null 2>&1; then + err "Downloaded binary does not execute (wrong architecture or corrupt)" + return 1 + fi + + # Atomic install within the target directory + local target="$INSTALL_DIR/$BIN_NAME" + local temp_target="${target}.tmp.$$" + cp "$tmp/$BIN_NAME" "$temp_target" + chmod 755 "$temp_target" + mv -f "$temp_target" "$target" + log "Installed ${BIN_NAME} to ${target}" return 0 } @@ -202,9 +308,10 @@ write_config() { local readonly_token="$2" mkdir -p "$CONFIG_DIR" + chmod 750 "$CONFIG_DIR" if [ -f "$CONFIG_FILE" ]; then - cp "$CONFIG_FILE" "${CONFIG_FILE}.bak" + cp "$CONFIG_FILE" "${CONFIG_FILE}.bak.$(date +%s)" fi log "Writing config to ${CONFIG_FILE}" @@ -223,6 +330,171 @@ auth: role: readonly rules: + - name: "block-exec-operations" + description: Prevent creating or starting exec sessions + conditions: + - or: + - field: path + operator: matches + value: "^/containers/[^/]+/exec$" + - field: path + operator: matches + value: "^/exec/[^/]+/start$" + action: deny + message: "Exec operations are not permitted" + status: 403 + + - name: "block-docker-build" + description: Prevent image builds via the API + conditions: + - field: path + operator: starts_with + value: "/build" + action: deny + message: "Image building is not permitted via this proxy" + + - name: "readonly-volumes" + description: Only GET is allowed on volumes + conditions: + - field: path + operator: starts_with + value: "/volumes" + - field: method + operator: not_equals + value: GET + action: deny + message: "Volume mutations are forbidden" + + - name: "readonly-networks" + description: Only GET is allowed on networks + conditions: + - field: path + operator: starts_with + value: "/networks" + - field: method + operator: not_equals + value: GET + action: deny + message: "Network mutations are forbidden" + + - name: "readonly-images" + description: Only GET is allowed on images + conditions: + - field: path + operator: starts_with + value: "/images" + - field: method + operator: not_equals + value: GET + action: deny + message: "Image mutations are forbidden" + + - name: "block-secrets" + description: Completely block access to secrets + conditions: + - field: path + operator: starts_with + value: "/secrets" + action: deny + message: "Secrets are not accessible" + + - name: "block-configs" + description: Completely block access to configs + conditions: + - field: path + operator: starts_with + value: "/configs" + action: deny + message: "Configs are not accessible" + + - name: "block-privileged-containers" + description: Prevent creating containers with --privileged flag + conditions: + - field: path + operator: equals + value: "/containers/create" + - field: body.HostConfig.Privileged + operator: equals + value: true + action: deny + message: "Privileged containers are not allowed" + status: 403 + + - name: "block-host-network-mode" + description: Prevent containers from using host networking + conditions: + - field: path + operator: equals + value: "/containers/create" + - field: body.HostConfig.NetworkMode + operator: equals + value: "host" + action: deny + message: "Host network mode is not allowed" + status: 403 + + - name: "block-bind-mounts" + description: Prevent containers from bind mounting host paths + conditions: + - field: path + operator: equals + value: "/containers/create" + - or: + - field: body.HostConfig.Binds + operator: exists + - field: body.HostConfig.Mounts + operator: matches + value: '"Type"\s*:\s*"bind"' + action: deny + message: "Bind mounts are not allowed" + status: 403 + + - name: "block-host-pid-namespace" + description: Prevent containers from using the host PID namespace + conditions: + - field: path + operator: equals + value: "/containers/create" + - field: body.HostConfig.PidMode + operator: equals + value: "host" + action: deny + message: "Host PID namespace is not allowed" + status: 403 + + - name: "admin-only-container-lifecycle" + description: Only admins can start/stop/restart/kill containers + conditions: + - field: path + operator: matches + value: "^/containers/[^/]+/(start|stop|restart|kill|pause|unpause|rename|update)$" + action: require_role + role: admin + message: "Admin role required for container lifecycle operations" + status: 403 + + - name: "admin-only-create" + description: Only admins can create containers + conditions: + - field: path + operator: equals + value: "/containers/create" + action: require_role + role: admin + message: "Admin role required to create containers" + status: 403 + + - name: "admin-only-delete" + description: Only admins can delete resources + conditions: + - field: method + operator: equals + value: DELETE + action: require_role + role: admin + message: "Admin role required for delete operations" + status: 403 + - name: "rate-limit-all" description: Limit all requests to 50 per 30 seconds, 30s penalty on exceed conditions: @@ -276,7 +548,7 @@ Group=docker NoNewPrivileges=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/etc/docker-proxy +ReadWritePaths=/etc/docker-proxy /var/log PrivateTmp=true [Install] @@ -357,15 +629,8 @@ main() { fi echo "" - echo -e " ${BOLD}Your tokens:${NC}" - echo "" - echo -e " ${CYAN}Admin (full access):${NC}" - echo -e " ${BOLD}${admin_token}${NC}" - echo "" - echo -e " ${CYAN}Readonly (GET only):${NC}" - echo -e " ${BOLD}${readonly_token}${NC}" - echo "" - echo -e " ${YELLOW}Save these tokens now. They will not be shown again.${NC}" + echo -e " ${YELLOW}Tokens have been written to ${CONFIG_FILE}.${NC}" + echo -e " ${YELLOW}Retrieve them from that file (readable by root only).${NC}" echo "" if [ "$os" = "linux" ]; then @@ -395,11 +660,8 @@ main() { echo " Binary: ${INSTALL_DIR}/${BIN_NAME}" echo " Config: ${CONFIG_FILE}" echo "" - echo " Test (admin):" - echo " curl -H 'Authorization: Bearer ${admin_token}' http://127.0.0.1:2376/version" - echo "" - echo " Test (readonly):" - echo " curl -H 'Authorization: Bearer ${readonly_token}' http://127.0.0.1:2376/version" + echo " Test (replace with the value from ${CONFIG_FILE}):" + echo " curl -H 'Authorization: Bearer ' http://127.0.0.1:2376/version" echo "" } diff --git a/src/audit.rs b/src/audit.rs index 31fd96b..e3e7067 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -63,12 +63,11 @@ impl AuditSink { pub fn spawn(path: PathBuf) -> Self { let (tx, mut rx) = mpsc::channel::(4096); tokio::spawn(async move { - let file = match OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .await - { + let mut open_opts = OpenOptions::new(); + open_opts.create(true).append(true); + #[cfg(unix)] + open_opts.mode(0o600); + let file = match open_opts.open(&path).await { Ok(f) => f, Err(e) => { error!("audit log open failed at {}: {e}", path.display()); diff --git a/src/config.rs b/src/config.rs index 48d15db..7f72d5a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,8 @@ use regex::Regex; -use serde::{Deserialize, Serialize}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; use std::{env, fs, path::PathBuf}; -use tracing::{info, warn}; +use tracing::info; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GlobalConfig { @@ -21,6 +22,8 @@ pub struct GlobalConfig { pub tls: Option, #[serde(default)] pub metrics: Option, + #[serde(default)] + pub trusted_proxies: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -55,9 +58,48 @@ pub struct MtlsConfig { pub default_role: Option, } +#[derive(Debug, Clone)] +pub struct SecretToken(SecretString); + +impl SecretToken { + pub fn new(s: String) -> Self { + Self(SecretString::new(s.into_boxed_str())) + } + + pub fn expose_secret(&self) -> &str { + self.0.expose_secret() + } +} + +impl<'de> Deserialize<'de> for SecretToken { + fn deserialize>(deserializer: D) -> Result { + struct SecretTokenVisitor; + + impl<'de> Visitor<'de> for SecretTokenVisitor { + type Value = SecretToken; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string") + } + + fn visit_str(self, v: &str) -> Result { + Ok(SecretToken::new(v.to_string())) + } + } + + deserializer.deserialize_str(SecretTokenVisitor) + } +} + +impl Serialize for SecretToken { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.expose_secret()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenEntry { - pub token: String, + pub token: SecretToken, #[serde(default)] pub role: Option, } @@ -67,32 +109,30 @@ pub struct AuthConfig { #[serde(default, rename = "type")] pub auth_type: Option, #[serde(default)] - pub secret: Option, + pub secret: Option, #[serde(default)] pub tokens: Option>, #[serde(default)] pub mtls: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Condition { pub field: String, #[serde(default)] pub operator: String, #[serde(default)] pub value: Option, + #[serde(skip)] + pub compiled_regex: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ConditionNode { Leaf(Condition), - And { - and: Vec, - }, - Or { - or: Vec, - }, + And { and: Vec }, + Or { or: Vec }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -125,6 +165,7 @@ pub struct RateLimitConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[allow(dead_code)] +#[derive(Default)] pub struct Rule { pub name: String, #[serde(default)] @@ -151,26 +192,7 @@ pub struct Rule { pub dry_run: Option, } -impl Default for Rule { - fn default() -> Self { - Rule { - name: String::new(), - description: None, - action: String::new(), - priority: None, - conditions: Vec::new(), - message: None, - role: None, - response_filter: None, - status: None, - methods: None, - rate_limit: None, - dry_run: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProxyConfig { #[serde(default)] pub global: Option, @@ -180,77 +202,193 @@ pub struct ProxyConfig { pub rules: Option>, } -#[allow(dead_code)] -fn expand_env(s: &str) -> String { +fn expand_env(s: &str, required: bool) -> Result { let re = Regex::new(r"\$\{(?\w+)\}").expect("invalid env var regex"); - re.replace_all(s, |caps: ®ex::Captures| { - env::var(&caps["name"]).unwrap_or_default() - }) - .to_string() + let mut missing: Vec = Vec::new(); + let result = re + .replace_all(s, |caps: ®ex::Captures| match env::var(&caps["name"]) { + Ok(v) => v, + Err(_) => { + missing.push(caps["name"].to_string()); + String::new() + } + }) + .to_string(); + if required && !missing.is_empty() { + return Err(format!( + "required environment variables missing: {}", + missing.join(", ") + )); + } + Ok(result) } -#[allow(dead_code)] -fn expand_value_env(value: &mut serde_yaml::Value) { +fn expand_value_env(value: &mut serde_yaml::Value, required: bool) -> Result<(), String> { match value { - serde_yaml::Value::String(s) => *s = expand_env(s), + serde_yaml::Value::String(s) => *s = expand_env(s, required)?, serde_yaml::Value::Sequence(seq) => { for v in seq.iter_mut() { - expand_value_env(v); + expand_value_env(v, required)?; } } _ => {} } + Ok(()) } -#[allow(dead_code)] -fn expand_node_env(node: &mut ConditionNode) { +fn expand_node_env(node: &mut ConditionNode, required: bool) -> Result<(), String> { match node { ConditionNode::Leaf(condition) => { if let Some(ref mut value) = condition.value { - expand_value_env(value); + expand_value_env(value, required)?; } } ConditionNode::And { and } => { for n in and.iter_mut() { - expand_node_env(n); + expand_node_env(n, required)?; } } ConditionNode::Or { or } => { for n in or.iter_mut() { - expand_node_env(n); + expand_node_env(n, required)?; } } } + Ok(()) +} + +fn compile_regex_in_condition(condition: &mut Condition) -> Result<(), String> { + if matches!(condition.operator.as_str(), "matches" | "not_matches") { + if let Some(ref value) = condition.value { + if let Some(s) = yaml_value_to_string(value) { + let regex = Regex::new(&s) + .map_err(|e| format!("invalid regex for field '{}': {e}", condition.field))?; + condition.compiled_regex = Some(regex); + } + } + } + Ok(()) +} + +fn compile_regexes_in_node(node: &mut ConditionNode) -> Result<(), String> { + match node { + ConditionNode::Leaf(condition) => compile_regex_in_condition(condition), + ConditionNode::And { and } => and.iter_mut().try_for_each(compile_regexes_in_node), + ConditionNode::Or { or } => or.iter_mut().try_for_each(compile_regexes_in_node), + } +} + +pub fn yaml_value_to_string(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Bool(b) => Some(b.to_string()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + _ => None, + } } -#[allow(dead_code)] impl ProxyConfig { - fn resolve_env_vars(&mut self) { + fn resolve_env_vars(&mut self) -> Result<(), String> { if let Some(ref mut auth) = self.auth { if let Some(ref mut secret) = auth.secret { - *secret = expand_env(secret); + let expanded = expand_env(secret.expose_secret(), true)?; + *secret = SecretToken::new(expanded); } if let Some(ref mut tokens) = auth.tokens { for t in tokens.iter_mut() { - t.token = expand_env(&t.token); + let expanded = expand_env(t.token.expose_secret(), true)?; + t.token = SecretToken::new(expanded); } } } if let Some(ref mut rules) = self.rules { for rule in rules.iter_mut() { if let Some(ref mut msg) = rule.message { - *msg = expand_env(msg); + *msg = expand_env(msg, false)?; } for node in rule.conditions.iter_mut() { - expand_node_env(node); + expand_node_env(node, false)?; } if let Some(ref mut methods) = rule.methods { for m in methods.iter_mut() { - *m = expand_env(m); + *m = expand_env(m, false)?; + } + } + } + } + Ok(()) + } + + fn compile_regexes(&mut self) -> Result<(), String> { + if let Some(ref mut rules) = self.rules { + for rule in rules.iter_mut() { + for node in rule.conditions.iter_mut() { + compile_regexes_in_node(node)?; + } + } + } + Ok(()) + } + + fn validate(&self) -> Result<(), String> { + if let Some(ref auth) = self.auth { + if let Some(ref t) = auth.auth_type { + match t.as_str() { + "none" | "bearer" | "mtls" => {} + other => return Err(format!("invalid auth.type: {other}")), + } + } + } + + if let Some(ref rules) = self.rules { + for rule in rules { + if rule.name.is_empty() { + return Err("rule name cannot be empty".into()); + } + if rule.action.is_empty() { + return Err(format!("rule '{}' has no action", rule.name)); + } + if rule.conditions.is_empty() { + return Err(format!("rule '{}' has no conditions", rule.name)); + } + if rule.action == "require_role" && rule.role.is_none() { + return Err(format!( + "rule '{}' uses require_role but has no role specified", + rule.name + )); + } + if rule.action == "rate_limit" && rule.rate_limit.is_none() { + return Err(format!( + "rule '{}' uses rate_limit but has no rate_limit config", + rule.name + )); + } + if rule.action == "response_filter" && rule.response_filter.is_none() { + return Err(format!( + "rule '{}' uses response_filter but has no response_filter entries", + rule.name + )); + } + } + } + + if let Some(ref global) = self.global { + if let Some(ref metrics) = global.metrics { + if metrics.enabled == Some(true) { + let path = metrics.path.as_deref().unwrap_or("/metrics"); + if path == "/" || path.is_empty() { + return Err("metrics path cannot be '/' or empty".into()); + } + if path.starts_with("/v") { + return Err(format!( + "metrics path '{path}' looks like a Docker API versioned path" + )); } } } } + + Ok(()) } fn apply_env_overrides(&mut self) { @@ -277,7 +415,10 @@ impl ProxyConfig { tokens: None, mtls: None, }); - auth.secret = Some(secret); + auth.secret = Some(SecretToken::new(secret)); + if auth.auth_type.is_none() { + auth.auth_type = Some("bearer".to_string()); + } } } } @@ -289,20 +430,21 @@ impl ProxyConfig { a.auth_type.as_deref() == Some("none") || a.auth_type.as_deref() == Some("mtls") || a.tokens.as_ref().map(|t| !t.is_empty()).unwrap_or(false) - || a.secret.as_ref().map(|s| !s.is_empty()).unwrap_or(false) + || a.secret + .as_ref() + .map(|s| !s.expose_secret().is_empty()) + .unwrap_or(false) } } } pub fn sort_rules_by_priority(&mut self) { if let Some(ref mut rules) = self.rules { - rules.sort_by(|a, b| { - b.priority.unwrap_or(0).cmp(&a.priority.unwrap_or(0)) - }); + rules.sort_by_key(|r| std::cmp::Reverse(r.priority.unwrap_or(0))); } } - pub fn load() -> Self { + pub fn load() -> Result { let config_path = env::var("DOCKER_PROXY_CONFIG") .ok() .map(PathBuf::from) @@ -318,19 +460,10 @@ impl ProxyConfig { let mut config = match config_path { Some(ref path) if path.exists() => { info!("loading config from {}", path.display()); - match fs::read_to_string(path) { - Ok(content) => match serde_yaml::from_str::(&content) { - Ok(cfg) => cfg, - Err(e) => { - warn!("failed to parse config, using defaults: {e}"); - ProxyConfig::default() - } - }, - Err(e) => { - warn!("failed to read config file, using defaults: {e}"); - ProxyConfig::default() - } - } + let content = fs::read_to_string(path) + .map_err(|e| format!("failed to read config file: {e}"))?; + serde_yaml::from_str::(&content) + .map_err(|e| format!("failed to parse config: {e}"))? } _ => { info!("no config file found, using defaults"); @@ -338,33 +471,27 @@ impl ProxyConfig { } }; - config.resolve_env_vars(); + config.resolve_env_vars()?; config.apply_env_overrides(); + config.compile_regexes()?; + config.validate()?; config.sort_rules_by_priority(); - config + Ok(config) } pub fn load_from_path(path: &std::path::Path) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("read failed: {e}"))?; - let mut cfg: ProxyConfig = serde_yaml::from_str(&content) - .map_err(|e| format!("parse failed: {e}"))?; - cfg.resolve_env_vars(); + let mut cfg: ProxyConfig = + serde_yaml::from_str(&content).map_err(|e| format!("parse failed: {e}"))?; + cfg.resolve_env_vars()?; cfg.apply_env_overrides(); + cfg.compile_regexes()?; + cfg.validate()?; cfg.sort_rules_by_priority(); Ok(cfg) } } -impl Default for ProxyConfig { - fn default() -> Self { - ProxyConfig { - global: None, - auth: None, - rules: None, - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -389,7 +516,10 @@ rules: "#; let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(cfg.global.as_ref().unwrap().port, Some(1234)); - assert_eq!(cfg.auth.as_ref().unwrap().auth_type.as_deref(), Some("bearer")); + assert_eq!( + cfg.auth.as_ref().unwrap().auth_type.as_deref(), + Some("bearer") + ); assert_eq!(cfg.rules.as_ref().unwrap().len(), 1); assert_eq!(cfg.rules.as_ref().unwrap()[0].name, "test-rule"); } @@ -458,17 +588,23 @@ rules: #[test] fn test_env_expansion() { std::env::set_var("TEST_VAR", "expanded_value"); - let result = expand_env("prefix_${TEST_VAR}_suffix"); + let result = expand_env("prefix_${TEST_VAR}_suffix", false).unwrap(); assert_eq!(result, "prefix_expanded_value_suffix"); std::env::remove_var("TEST_VAR"); } #[test] - fn test_env_expansion_unset() { - let result = expand_env("${NONEXISTENT_VAR_12345}"); + fn test_env_expansion_unset_not_required() { + let result = expand_env("${NONEXISTENT_VAR_12345}", false).unwrap(); assert_eq!(result, ""); } + #[test] + fn test_env_expansion_unset_required_errors() { + let result = expand_env("${NONEXISTENT_VAR_12345}", true); + assert!(result.is_err()); + } + #[test] fn test_or_condition_parsing() { let yaml = r#" @@ -559,7 +695,10 @@ rules: auth: Some(AuthConfig { auth_type: Some("bearer".into()), secret: None, - tokens: Some(vec![TokenEntry { token: "t".into(), role: Some("admin".into()) }]), + tokens: Some(vec![TokenEntry { + token: SecretToken::new("t".into()), + role: Some("admin".into()), + }]), mtls: None, }), ..Default::default() @@ -572,7 +711,7 @@ rules: let cfg = ProxyConfig { auth: Some(AuthConfig { auth_type: Some("bearer".into()), - secret: Some("s".into()), + secret: Some(SecretToken::new("s".into())), tokens: None, mtls: None, }), @@ -649,7 +788,13 @@ rules: "#; let mut cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap(); cfg.sort_rules_by_priority(); - let names: Vec<&str> = cfg.rules.as_ref().unwrap().iter().map(|r| r.name.as_str()).collect(); + let names: Vec<&str> = cfg + .rules + .as_ref() + .unwrap() + .iter() + .map(|r| r.name.as_str()) + .collect(); assert_eq!(names, vec!["b", "c", "d", "a"]); } @@ -726,4 +871,49 @@ rules: let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(cfg.rules.unwrap()[0].conditions.len(), 2); } + + #[test] + fn test_validation_rejects_empty_action() { + let yaml = r#" +rules: + - name: r + conditions: + - field: path + operator: equals + value: /x +"#; + let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_validation_rejects_missing_require_role() { + let yaml = r#" +rules: + - name: r + action: require_role + conditions: + - field: path + operator: equals + value: /x +"#; + let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_validation_accepts_valid_require_role() { + let yaml = r#" +rules: + - name: r + action: require_role + role: admin + conditions: + - field: path + operator: equals + value: /x +"#; + let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(cfg.validate().is_ok()); + } } diff --git a/src/main.rs b/src/main.rs index cdd4804..627855b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use docker_proxy::rules::{ }; use docker_proxy::tls::{self, CertIdentity}; use docker_proxy::upgrade::is_upgrade_request; -use http_body_util::{BodyExt, Full, Limited}; +use http_body_util::{BodyExt, Full, LengthLimitError, Limited}; use hyper::{ body::{Bytes, Incoming}, server::conn::http1, @@ -16,6 +16,8 @@ use hyper::{ HeaderMap, Request, Response, StatusCode, }; use hyper_util::rt::TokioIo; +use ipnet::IpNet; +use regex::Regex; use serde_json::Value as JsonValue; use std::{ collections::HashMap, @@ -27,10 +29,13 @@ use std::{ time::{Duration, Instant}, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream, UnixStream}; -use tokio::sync::Semaphore; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Notify, Semaphore}; use tokio::time::timeout; use tokio_rustls::TlsAcceptor; + +#[cfg(unix)] +use tokio::net::UnixStream; use tracing::{error, info, warn}; type BoxError = Box; @@ -52,6 +57,23 @@ const AUTH_FAIL_MAX: u32 = 10; const AUTH_FAIL_WINDOW: Duration = Duration::from_secs(60); const AUTH_FAIL_LOCKOUT: Duration = Duration::from_secs(300); +#[cfg(unix)] +type UpstreamIo = TokioIo; + +#[cfg(windows)] +type UpstreamIo = TokioIo; + +#[cfg(unix)] +async fn connect_upstream(path: &std::path::Path) -> Result { + let stream = timeout(CONNECT_TIMEOUT, UnixStream::connect(path)).await??; + Ok(TokioIo::new(stream)) +} + +#[cfg(windows)] +async fn connect_upstream(_path: &std::path::Path) -> Result { + Err("Windows is not a supported runtime platform for docker-proxy".into()) +} + const HOP_BY_HOP_HEADERS: &[&str] = &[ "connection", "keep-alive", @@ -95,6 +117,7 @@ impl ProxyState { .clone() } + #[cfg(unix)] fn swap_config(&self, new_cfg: Arc) { let mut g = self .config_holder @@ -193,7 +216,13 @@ fn sanitize_for_log(s: &str) -> String { .collect() } -fn log_request(peer: SocketAddr, method: &hyper::Method, path: &str, user_agent: &str, status: StatusCode) { +fn log_request( + peer: SocketAddr, + method: &hyper::Method, + path: &str, + user_agent: &str, + status: StatusCode, +) { let now = Local::now().format("%m/%d/%Y %H:%M:%S"); info!( "[{}] {} {} {} - {} - \"{}\"", @@ -244,6 +273,82 @@ fn percent_decode(s: &str) -> Option> { Some(out) } +fn strip_api_version_prefix(path: &str) -> &str { + if !path.starts_with("/v") || path.len() < 3 { + return path; + } + if let Some(second_slash) = path[2..].find('/') { + let version_part = &path[2..2 + second_slash]; + if !version_part.is_empty() + && version_part != "." + && version_part.chars().all(|c| c.is_ascii_digit() || c == '.') + { + return &path[2 + second_slash..]; + } + } + path +} + +fn requires_json_body(path: &str) -> bool { + if path == "/containers/create" { + return true; + } + if Regex::new(r"^/containers/[^/]+/update$") + .map(|r| r.is_match(path)) + .unwrap_or(false) + { + return true; + } + false +} + +fn parse_trusted_proxies(raw: &[String]) -> Vec { + raw.iter().filter_map(|s| s.parse::().ok()).collect() +} + +fn compute_client_ip(peer: &SocketAddr, headers: &HeaderMap, trusted_proxies: &[IpNet]) -> IpAddr { + let peer_ip = peer.ip(); + if !trusted_proxies.iter().any(|net| net.contains(&peer_ip)) { + return peer_ip; + } + + if let Some(v) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { + if let Ok(ip) = v.trim().parse::() { + return ip; + } + } + + if let Some(v) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { + for entry in v.split(',').map(|s| s.trim()).rev() { + if let Ok(ip) = entry.parse::() { + if !trusted_proxies.iter().any(|net| net.contains(&ip)) { + return ip; + } + } + } + } + + if let Some(v) = headers.get("forwarded").and_then(|v| v.to_str().ok()) { + for segment in v.split(',').rev() { + for param in segment.split(';') { + let param = param.trim(); + if let Some(rest) = param.strip_prefix("for=") { + let raw = rest.trim_matches('"'); + let raw = raw + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(raw); + if let Ok(ip) = raw.parse::() { + return ip; + } + } + } + } + } + + peer_ip +} + fn canonicalize_path(raw: &str) -> Option { if raw.is_empty() || !raw.starts_with('/') { return None; @@ -259,6 +364,7 @@ fn canonicalize_path(raw: &str) -> Option { return None; } let decoded = String::from_utf8(decoded_bytes).ok()?; + let decoded = strip_api_version_prefix(&decoded).to_string(); let trailing_slash = decoded.len() > 1 && decoded.ends_with('/'); let mut segments: Vec<&str> = Vec::new(); for seg in decoded.split('/') { @@ -300,16 +406,19 @@ fn resolve_token_role(config: &ProxyConfig, env_secret: &str, token: &str) -> Op let mut matched: Option = None; if let Some(ref tokens) = auth_config.tokens { for t in tokens.iter() { - if constant_time_eq(t.token.as_bytes(), token_bytes) && matched.is_none() { + let eq = constant_time_eq(t.token.expose_secret().as_bytes(), token_bytes); + if eq && matched.is_none() { matched = Some(t.role.clone().unwrap_or_else(|| "user".to_string())); } } } - if matched.is_some() { - return matched; + if let Some(role) = matched { + return Some(role); } if let Some(ref secret) = auth_config.secret { - if !secret.is_empty() && constant_time_eq(secret.as_bytes(), token_bytes) { + if !secret.expose_secret().is_empty() + && constant_time_eq(secret.expose_secret().as_bytes(), token_bytes) + { return Some("admin".to_string()); } } @@ -333,12 +442,12 @@ fn is_auth_strictly_configured(config: &ProxyConfig, env_secret: &str) -> bool { let has_tokens = auth .tokens .as_ref() - .map(|tk| tk.iter().any(|x| !x.token.is_empty())) + .map(|tk| tk.iter().any(|x| !x.token.expose_secret().is_empty())) .unwrap_or(false); let has_secret = auth .secret .as_ref() - .map(|s| !s.is_empty()) + .map(|s| !s.expose_secret().is_empty()) .unwrap_or(false); return has_tokens || has_secret; } @@ -346,7 +455,10 @@ fn is_auth_strictly_configured(config: &ProxyConfig, env_secret: &str) -> bool { } enum AuthOutcome { - Allowed { role: Option, identity: Option }, + Allowed { + role: Option, + identity: Option, + }, Denied(StatusCode, &'static str), } @@ -396,14 +508,25 @@ fn authenticate( } } None => { - state.metrics.auth_failures_total.fetch_add(1, Ordering::Relaxed); - state.auth_limiter.record_failure( + state + .metrics + .auth_failures_total + .fetch_add(1, Ordering::Relaxed); + if state.auth_limiter.record_failure( &auth_key, AUTH_FAIL_MAX, AUTH_FAIL_WINDOW, AUTH_FAIL_LOCKOUT, - ); - AuthOutcome::Denied(StatusCode::UNAUTHORIZED, "Client certificate not authorized") + ) { + state + .metrics + .auth_lockouts_total + .fetch_add(1, Ordering::Relaxed); + } + AuthOutcome::Denied( + StatusCode::UNAUTHORIZED, + "Client certificate not authorized", + ) } } } else { @@ -416,23 +539,39 @@ fn authenticate( let token = match extract_bearer_token(headers) { Ok(Some(t)) => t, Ok(None) => { - state.metrics.auth_failures_total.fetch_add(1, Ordering::Relaxed); - state.auth_limiter.record_failure( + state + .metrics + .auth_failures_total + .fetch_add(1, Ordering::Relaxed); + if state.auth_limiter.record_failure( &auth_key, AUTH_FAIL_MAX, AUTH_FAIL_WINDOW, AUTH_FAIL_LOCKOUT, - ); + ) { + state + .metrics + .auth_lockouts_total + .fetch_add(1, Ordering::Relaxed); + } return AuthOutcome::Denied(StatusCode::UNAUTHORIZED, "Missing authorization"); } Err(()) => { - state.metrics.auth_failures_total.fetch_add(1, Ordering::Relaxed); - state.auth_limiter.record_failure( + state + .metrics + .auth_failures_total + .fetch_add(1, Ordering::Relaxed); + if state.auth_limiter.record_failure( &auth_key, AUTH_FAIL_MAX, AUTH_FAIL_WINDOW, AUTH_FAIL_LOCKOUT, - ); + ) { + state + .metrics + .auth_lockouts_total + .fetch_add(1, Ordering::Relaxed); + } return AuthOutcome::Denied(StatusCode::UNAUTHORIZED, "Malformed authorization"); } }; @@ -446,13 +585,21 @@ fn authenticate( } } None => { - state.metrics.auth_failures_total.fetch_add(1, Ordering::Relaxed); - state.auth_limiter.record_failure( + state + .metrics + .auth_failures_total + .fetch_add(1, Ordering::Relaxed); + if state.auth_limiter.record_failure( &auth_key, AUTH_FAIL_MAX, AUTH_FAIL_WINDOW, AUTH_FAIL_LOCKOUT, - ); + ) { + state + .metrics + .auth_lockouts_total + .fetch_add(1, Ordering::Relaxed); + } AuthOutcome::Denied(StatusCode::UNAUTHORIZED, "Invalid token") } } @@ -481,20 +628,76 @@ async fn handle( let canonical_path = match canonicalize_path(&raw_path) { Some(p) => p, None => { - state.metrics.bad_request_total.fetch_add(1, Ordering::Relaxed); - log_request(peer, &method, &raw_path, &user_agent, StatusCode::BAD_REQUEST); - return Ok(make_response(StatusCode::BAD_REQUEST, "Malformed request path")); + state + .metrics + .bad_request_total + .fetch_add(1, Ordering::Relaxed); + log_request( + peer, + &method, + &raw_path, + &user_agent, + StatusCode::BAD_REQUEST, + ); + return Ok(make_response( + StatusCode::BAD_REQUEST, + "Malformed request path", + )); } }; if let Some(ref q) = raw_query { if q.len() > MAX_PATH_LEN || q.bytes().any(|b| b == 0 || b < 0x20 || b == 0x7f) { - state.metrics.bad_request_total.fetch_add(1, Ordering::Relaxed); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_REQUEST); - return Ok(make_response(StatusCode::BAD_REQUEST, "Malformed query string")); + state + .metrics + .bad_request_total + .fetch_add(1, Ordering::Relaxed); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_REQUEST, + ); + return Ok(make_response( + StatusCode::BAD_REQUEST, + "Malformed query string", + )); } } + let canonical_path_and_query = match &raw_query { + Some(q) => format!("{canonical_path}?{q}"), + None => canonical_path.clone(), + }; + + let config = state.current_config(); + + let (user_role, identity) = + match authenticate(&state, &config, req.headers(), &peer, cert.as_deref()) { + AuthOutcome::Allowed { role, identity } => (role, identity), + AuthOutcome::Denied(status, msg) => { + state + .metrics + .requests_denied + .fetch_add(1, Ordering::Relaxed); + if state.audit.is_enabled() { + let mut ev = AuditEvent::new( + "auth_denied", + peer.ip(), + method.as_str(), + &canonical_path, + &user_agent, + ); + ev.status = status.as_u16(); + ev.message = Some(msg.to_string()); + state.audit.send(ev); + } + log_request(peer, &method, &canonical_path, &user_agent, status); + return Ok(make_response(status, msg)); + } + }; + if let Some(ref mp) = state.metrics_path { if canonical_path == *mp && method == hyper::Method::GET { let body = state.metrics.render_prometheus(); @@ -506,33 +709,13 @@ async fn handle( } } - let canonical_path_and_query = match &raw_query { - Some(q) => format!("{canonical_path}?{q}"), - None => canonical_path.clone(), - }; - - let config = state.current_config(); - - let (user_role, identity) = match authenticate(&state, &config, req.headers(), &peer, cert.as_deref()) { - AuthOutcome::Allowed { role, identity } => (role, identity), - AuthOutcome::Denied(status, msg) => { - state.metrics.requests_denied.fetch_add(1, Ordering::Relaxed); - if state.audit.is_enabled() { - let mut ev = AuditEvent::new( - "auth_denied", - peer.ip(), - method.as_str(), - &canonical_path, - &user_agent, - ); - ev.status = status.as_u16(); - ev.message = Some(msg.to_string()); - state.audit.send(ev); - } - log_request(peer, &method, &canonical_path, &user_agent, status); - return Ok(make_response(status, msg)); - } - }; + let trusted_proxies: Vec = config + .global + .as_ref() + .and_then(|g| g.trusted_proxies.as_ref()) + .map(|v| parse_trusted_proxies(v)) + .unwrap_or_default(); + let client_ip = compute_client_ip(&peer, req.headers(), &trusted_proxies).to_string(); let upgrade_requested = is_upgrade_request(req.headers()); @@ -569,8 +752,6 @@ async fn handle( }) .collect(); - let client_ip = peer.ip().to_string(); - let rules = config.rules.as_deref().unwrap_or(&[]); if upgrade_requested { @@ -586,7 +767,10 @@ async fn handle( let decision = evaluate_request_detailed(rules, &eval_ctx, &state.rate_limiter); if let Some(ref rn) = decision.rule_name { if decision.dry_run { - state.metrics.requests_dry_run.fetch_add(1, Ordering::Relaxed); + state + .metrics + .requests_dry_run + .fetch_add(1, Ordering::Relaxed); state.metrics.record_rule_deny(rn, true); if state.audit.is_enabled() { let mut ev = AuditEvent::new( @@ -606,7 +790,10 @@ async fn handle( } } if let RuleResult::Deny { status, message } = decision.result { - state.metrics.requests_denied.fetch_add(1, Ordering::Relaxed); + state + .metrics + .requests_denied + .fetch_add(1, Ordering::Relaxed); if let Some(ref rn) = decision.rule_name { state.metrics.record_rule_deny(rn, false); } @@ -630,7 +817,10 @@ async fn handle( log_request(peer, &method, &canonical_path, &user_agent, status_code); return Ok(make_response_string(status_code, message)); } - state.metrics.requests_allowed.fetch_add(1, Ordering::Relaxed); + state + .metrics + .requests_allowed + .fetch_add(1, Ordering::Relaxed); return Ok(handle_upgrade( req, peer, @@ -651,17 +841,53 @@ async fn handle( let body_bytes: Bytes = match timeout(BODY_READ_TIMEOUT, limited.collect()).await { Ok(Ok(b)) => b.to_bytes(), Ok(Err(e)) => { - state.metrics.body_too_large_total.fetch_add(1, Ordering::Relaxed); + if e.is::() { + state + .metrics + .body_too_large_total + .fetch_add(1, Ordering::Relaxed); + warn!("body exceeded size limit"); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::PAYLOAD_TOO_LARGE, + ); + return Ok(make_response( + StatusCode::PAYLOAD_TOO_LARGE, + "Request body too large", + )); + } + state + .metrics + .bad_request_total + .fetch_add(1, Ordering::Relaxed); warn!("body read failed: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::PAYLOAD_TOO_LARGE); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_REQUEST, + ); return Ok(make_response( - StatusCode::PAYLOAD_TOO_LARGE, - "Request body too large or invalid", + StatusCode::BAD_REQUEST, + "Request body invalid", )); } Err(_) => { - state.metrics.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::REQUEST_TIMEOUT); + state + .metrics + .request_timeouts_total + .fetch_add(1, Ordering::Relaxed); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::REQUEST_TIMEOUT, + ); return Ok(make_response( StatusCode::REQUEST_TIMEOUT, "Request body read timeout", @@ -671,7 +897,29 @@ async fn handle( let body_json: Option = if body_bytes.is_empty() { None } else { - serde_json::from_slice(&body_bytes).ok() + match serde_json::from_slice(&body_bytes) { + Ok(v) => Some(v), + Err(e) => { + if requires_json_body(&canonical_path) { + state + .metrics + .bad_request_total + .fetch_add(1, Ordering::Relaxed); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_REQUEST, + ); + return Ok(make_response_string( + StatusCode::BAD_REQUEST, + format!("Request body must be valid JSON for this endpoint: {e}"), + )); + } + None + } + } }; let eval_ctx = EvaluationContext { @@ -687,7 +935,10 @@ async fn handle( if decision.dry_run { if let Some(ref rn) = decision.rule_name { - state.metrics.requests_dry_run.fetch_add(1, Ordering::Relaxed); + state + .metrics + .requests_dry_run + .fetch_add(1, Ordering::Relaxed); state.metrics.record_rule_deny(rn, true); if state.audit.is_enabled() { let mut ev = AuditEvent::new( @@ -709,9 +960,15 @@ async fn handle( match decision.result { RuleResult::Deny { status, message } => { - state.metrics.requests_denied.fetch_add(1, Ordering::Relaxed); + state + .metrics + .requests_denied + .fetch_add(1, Ordering::Relaxed); if decision.action.as_deref() == Some("rate_limit") { - state.metrics.rate_limited_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .rate_limited_total + .fetch_add(1, Ordering::Relaxed); } if let Some(ref rn) = decision.rule_name { state.metrics.record_rule_deny(rn, false); @@ -744,21 +1001,39 @@ async fn handle( let upstream_start = Instant::now(); - let unix_stream = match timeout(CONNECT_TIMEOUT, UnixStream::connect(&state.socket_path)).await { - Ok(Ok(s)) => s, + let upstream_io = match timeout(CONNECT_TIMEOUT, connect_upstream(&state.socket_path)).await { + Ok(Ok(io)) => io, Ok(Err(e)) => { - state.metrics.upstream_errors_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); error!("failed to connect to docker socket: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); return Ok(make_response( StatusCode::BAD_GATEWAY, "docker socket unavailable", )); } Err(_) => { - state.metrics.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .upstream_timeouts_total + .fetch_add(1, Ordering::Relaxed); error!("docker socket connect timed out"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::GATEWAY_TIMEOUT); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::GATEWAY_TIMEOUT, + ); return Ok(make_response( StatusCode::GATEWAY_TIMEOUT, "docker socket connect timeout", @@ -766,19 +1041,27 @@ async fn handle( } }; - let (mut sender, conn) = - match hyper::client::conn::http1::handshake(TokioIo::new(unix_stream)).await { - Ok(v) => v, - Err(e) => { - state.metrics.upstream_errors_total.fetch_add(1, Ordering::Relaxed); - error!("docker handshake failed: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); - return Ok(make_response( - StatusCode::BAD_GATEWAY, - "docker handshake failed", - )); - } - }; + let (mut sender, conn) = match hyper::client::conn::http1::handshake(upstream_io).await { + Ok(v) => v, + Err(e) => { + state + .metrics + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); + error!("docker handshake failed: {e}"); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); + return Ok(make_response( + StatusCode::BAD_GATEWAY, + "docker handshake failed", + )); + } + }; tokio::spawn(async move { if let Err(e) = conn.await { @@ -800,7 +1083,13 @@ async fn handle( Ok(r) => r, Err(e) => { error!("failed to build upstream request: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); return Ok(make_response( StatusCode::BAD_GATEWAY, "failed to build upstream request", @@ -811,17 +1100,35 @@ async fn handle( let res = match timeout(UPSTREAM_TIMEOUT, sender.send_request(proxied_req)).await { Ok(Ok(r)) => r, Ok(Err(e)) => { - state.metrics.upstream_errors_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); error!("docker request failed: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); return Ok(make_response( StatusCode::BAD_GATEWAY, "docker request failed", )); } Err(_) => { - state.metrics.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::GATEWAY_TIMEOUT); + state + .metrics + .upstream_timeouts_total + .fetch_add(1, Ordering::Relaxed); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::GATEWAY_TIMEOUT, + ); return Ok(make_response( StatusCode::GATEWAY_TIMEOUT, "docker upstream timeout", @@ -845,17 +1152,35 @@ async fn handle( let response_bytes = match timeout(UPSTREAM_TIMEOUT, limited_resp.collect()).await { Ok(Ok(b)) => b.to_bytes(), Ok(Err(e)) => { - state.metrics.upstream_errors_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); error!("failed to read docker response: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); return Ok(make_response( StatusCode::BAD_GATEWAY, "failed to read docker response", )); } Err(_) => { - state.metrics.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::GATEWAY_TIMEOUT); + state + .metrics + .upstream_timeouts_total + .fetch_add(1, Ordering::Relaxed); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::GATEWAY_TIMEOUT, + ); return Ok(make_response( StatusCode::GATEWAY_TIMEOUT, "docker response timeout", @@ -863,7 +1188,10 @@ async fn handle( } }; - let elapsed_ms = upstream_start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + let elapsed_ms = upstream_start + .elapsed() + .as_millis() + .min(u128::from(u64::MAX)) as u64; state.metrics.observe_upstream_latency_ms(elapsed_ms); let final_bytes = if !response_filters.is_empty() && content_type.contains("application/json") { @@ -872,7 +1200,10 @@ async fn handle( response_bytes.to_vec() }; - state.metrics.requests_allowed.fetch_add(1, Ordering::Relaxed); + state + .metrics + .requests_allowed + .fetch_add(1, Ordering::Relaxed); log_request(peer, &method, &canonical_path, &user_agent, status); let mut response_builder = Response::builder().status(status); @@ -907,6 +1238,7 @@ async fn handle( Ok(resp) } +#[allow(clippy::too_many_arguments)] async fn handle_upgrade( mut req: Request, peer: SocketAddr, @@ -918,31 +1250,57 @@ async fn handle_upgrade( upgrade_value: Option, state: Arc, ) -> HttpResponse { - let unix_stream = match timeout(CONNECT_TIMEOUT, UnixStream::connect(&state.socket_path)).await { - Ok(Ok(s)) => s, + let upstream_io = match timeout(CONNECT_TIMEOUT, connect_upstream(&state.socket_path)).await { + Ok(Ok(io)) => io, Ok(Err(e)) => { - state.metrics.upstream_errors_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); error!("upgrade: docker connect failed: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); return make_response(StatusCode::BAD_GATEWAY, "docker socket unavailable"); } Err(_) => { - state.metrics.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::GATEWAY_TIMEOUT); + state + .metrics + .upstream_timeouts_total + .fetch_add(1, Ordering::Relaxed); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::GATEWAY_TIMEOUT, + ); return make_response(StatusCode::GATEWAY_TIMEOUT, "docker socket connect timeout"); } }; - let (mut sender, conn) = - match hyper::client::conn::http1::handshake(TokioIo::new(unix_stream)).await { - Ok(v) => v, - Err(e) => { - state.metrics.upstream_errors_total.fetch_add(1, Ordering::Relaxed); - error!("upgrade: docker handshake failed: {e}"); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); - return make_response(StatusCode::BAD_GATEWAY, "docker handshake failed"); - } - }; + let (mut sender, conn) = match hyper::client::conn::http1::handshake(upstream_io).await { + Ok(v) => v, + Err(e) => { + state + .metrics + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); + error!("upgrade: docker handshake failed: {e}"); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); + return make_response(StatusCode::BAD_GATEWAY, "docker handshake failed"); + } + }; let conn_with_upgrades = conn.with_upgrades(); let conn_join = tokio::spawn(async move { @@ -969,7 +1327,13 @@ async fn handle_upgrade( Err(e) => { error!("upgrade: build request failed: {e}"); conn_join.abort(); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); return make_response(StatusCode::BAD_GATEWAY, "failed to build upstream request"); } }; @@ -977,16 +1341,34 @@ async fn handle_upgrade( let upstream_res = match timeout(UPSTREAM_TIMEOUT, sender.send_request(proxied_req)).await { Ok(Ok(r)) => r, Ok(Err(e)) => { - state.metrics.upstream_errors_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); error!("upgrade: send_request failed: {e}"); conn_join.abort(); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::BAD_GATEWAY); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::BAD_GATEWAY, + ); return make_response(StatusCode::BAD_GATEWAY, "docker request failed"); } Err(_) => { - state.metrics.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); + state + .metrics + .upstream_timeouts_total + .fetch_add(1, Ordering::Relaxed); conn_join.abort(); - log_request(peer, &method, &canonical_path, &user_agent, StatusCode::GATEWAY_TIMEOUT); + log_request( + peer, + &method, + &canonical_path, + &user_agent, + StatusCode::GATEWAY_TIMEOUT, + ); return make_response(StatusCode::GATEWAY_TIMEOUT, "docker upstream timeout"); } }; @@ -1026,13 +1408,17 @@ async fn handle_upgrade( let client_upgraded = match timeout(UPGRADE_RESOLVE_TIMEOUT, client_upgrade_fut).await { Ok(Ok(u)) => u, Ok(Err(e)) => { - metrics_for_task.upstream_errors_total.fetch_add(1, Ordering::Relaxed); + metrics_for_task + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); tracing::debug!("client upgrade failed: {e}"); conn_join.abort(); return; } Err(_) => { - metrics_for_task.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); + metrics_for_task + .upstream_timeouts_total + .fetch_add(1, Ordering::Relaxed); tracing::debug!("client upgrade resolve timeout"); conn_join.abort(); return; @@ -1041,13 +1427,17 @@ async fn handle_upgrade( let docker_upgraded = match timeout(UPGRADE_RESOLVE_TIMEOUT, docker_upgrade_fut).await { Ok(Ok(u)) => u, Ok(Err(e)) => { - metrics_for_task.upstream_errors_total.fetch_add(1, Ordering::Relaxed); + metrics_for_task + .upstream_errors_total + .fetch_add(1, Ordering::Relaxed); tracing::debug!("docker upgrade failed: {e}"); conn_join.abort(); return; } Err(_) => { - metrics_for_task.upstream_timeouts_total.fetch_add(1, Ordering::Relaxed); + metrics_for_task + .upstream_timeouts_total + .fetch_add(1, Ordering::Relaxed); tracing::debug!("docker upgrade resolve timeout"); conn_join.abort(); return; @@ -1112,9 +1502,12 @@ async fn handle_upgrade( .unwrap_or_else(|_| make_response(StatusCode::BAD_GATEWAY, "upgrade response build failed")) } -fn init_tracing(log_format: &str) -> Result<(), BoxError> { - let filter = tracing_subscriber::EnvFilter::from_default_env() +fn init_tracing(log_format: &str, log_level: Option<&str>) -> Result<(), BoxError> { + let mut filter = tracing_subscriber::EnvFilter::from_default_env() .add_directive("docker_proxy=info".parse()?); + if let Some(level) = log_level { + filter = filter.add_directive(format!("docker_proxy={level}").parse()?); + } if log_format.eq_ignore_ascii_case("json") { tracing_subscriber::fmt() .json() @@ -1175,7 +1568,11 @@ fn render_effective_rules(config: &ProxyConfig) -> String { r.name, r.priority.unwrap_or(0), r.action, - if r.dry_run.unwrap_or(false) { ", dry_run" } else { "" } + if r.dry_run.unwrap_or(false) { + ", dry_run" + } else { + "" + } )); } } else { @@ -1201,8 +1598,58 @@ fn load_config_from_default_path() -> Result<(ProxyConfig, Option), Str let cfg = ProxyConfig::load_from_path(&p)?; Ok((cfg, Some(p))) } - None => Ok((ProxyConfig::load(), None)), + None => Ok((ProxyConfig::load()?, None)), + } +} + +async fn create_listener( + config: &ProxyConfig, +) -> Result<(TcpListener, Option, String), BoxError> { + let port = config + .global + .as_ref() + .and_then(|g| g.port) + .or_else(|| { + env::var("DOCKER_PROXY_PORT") + .ok() + .and_then(|p| p.parse().ok()) + }) + .unwrap_or(2376); + + let bind_host = config + .global + .as_ref() + .and_then(|g| g.bind.clone()) + .unwrap_or_else(|| "127.0.0.1".to_string()); + + let tls_config_opt = config.global.as_ref().and_then(|g| g.tls.clone()); + if bind_host != "127.0.0.1" && bind_host != "localhost" && tls_config_opt.is_none() { + warn!( + "binding to non-loopback {} without TLS — tokens will travel in cleartext", + bind_host + ); } + + let tls_acceptor = match tls_config_opt { + Some(ref tls_cfg) => { + tls::install_default_crypto_provider(); + let sc = tls::build_server_config(tls_cfg).map_err(|e| { + error!("TLS init failed: {e}"); + e + })?; + info!( + "TLS enabled (cert={}, client_ca={})", + tls_cfg.cert, + tls_cfg.client_ca.as_deref().unwrap_or("(none)") + ); + Some(TlsAcceptor::from(sc)) + } + None => None, + }; + + let addr_str = format!("{bind_host}:{port}"); + let listener = TcpListener::bind(&addr_str).await?; + Ok((listener, tls_acceptor, addr_str)) } #[tokio::main] @@ -1266,12 +1713,11 @@ async fn main() -> Result<(), BoxError> { return Ok(()); } - init_tracing(&log_format)?; + let log_level = config.global.as_ref().and_then(|g| g.log_level.as_deref()); + init_tracing(&log_format, log_level)?; - if let Some(ref global) = config.global { - if let Some(ref level) = global.log_level { - info!("config log_level override: {}", level); - } + if let Some(level) = log_level { + info!("config log_level override: {level}"); } let config_socket = config.global.as_ref().and_then(|g| g.socket.as_ref()); @@ -1283,11 +1729,12 @@ async fn main() -> Result<(), BoxError> { } }; - let env_secret = if config.is_auth_configured() { - String::new() - } else { - env::var("DOCKER_PROXY_SECRET").unwrap_or_default() - }; + let env_secret = env::var("DOCKER_PROXY_SECRET").unwrap_or_default(); + if !env_secret.is_empty() + && config.auth.as_ref().and_then(|a| a.auth_type.as_deref()) == Some("none") + { + warn!("DOCKER_PROXY_SECRET is set but auth.type is 'none'; the secret will be ignored"); + } if !is_auth_strictly_configured(&config, &env_secret) { error!("auth is not configured — proxy will reject all requests"); @@ -1297,48 +1744,6 @@ async fn main() -> Result<(), BoxError> { ); } - let port: u16 = config - .global - .as_ref() - .and_then(|g| g.port) - .or_else(|| { - env::var("DOCKER_PROXY_PORT") - .ok() - .and_then(|p| p.parse().ok()) - }) - .unwrap_or(2376); - - let bind_host = config - .global - .as_ref() - .and_then(|g| g.bind.clone()) - .unwrap_or_else(|| "127.0.0.1".to_string()); - - let tls_config_opt = config.global.as_ref().and_then(|g| g.tls.clone()); - if bind_host != "127.0.0.1" && bind_host != "localhost" && tls_config_opt.is_none() { - warn!( - "binding to non-loopback {} without TLS — tokens will travel in cleartext", - bind_host - ); - } - - let tls_acceptor = match tls_config_opt { - Some(ref tls_cfg) => { - tls::install_default_crypto_provider(); - let sc = tls::build_server_config(tls_cfg).map_err(|e| { - error!("TLS init failed: {e}"); - e - })?; - info!( - "TLS enabled (cert={}, client_ca={})", - tls_cfg.cert, - tls_cfg.client_ca.as_deref().unwrap_or("(none)") - ); - Some(TlsAcceptor::from(sc)) - } - None => None, - }; - let metrics = Arc::new(Metrics::new()); let metrics_path = config .global @@ -1355,11 +1760,7 @@ async fn main() -> Result<(), BoxError> { info!("metrics endpoint exposed at {}", p); } - let audit = match config - .global - .as_ref() - .and_then(|g| g.audit_log.clone()) - { + let audit = match config.global.as_ref().and_then(|g| g.audit_log.clone()) { Some(p) if !p.is_empty() => { info!("audit log enabled at {}", p); AuditSink::spawn(PathBuf::from(p)) @@ -1395,54 +1796,78 @@ async fn main() -> Result<(), BoxError> { } }); - spawn_sighup_reloader(state.clone(), loaded_from.clone()); - - let addr_str = format!("{bind_host}:{port}"); - let listener = TcpListener::bind(&addr_str).await?; - - let conn_sem = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNS)); - - let cfg = state.current_config(); - let rule_count = cfg.rules.as_ref().map(|r| r.len()).unwrap_or(0); - info!( - "docker-proxy listening on {} ({}{} rule{}, log_format={})", - addr_str, - if tls_acceptor.is_some() { "TLS, " } else { "" }, - rule_count, - if rule_count == 1 { "" } else { "s" }, - log_format, - ); - drop(cfg); + let reload_notify = Arc::new(Notify::new()); + spawn_sighup_reloader(state.clone(), loaded_from.clone(), reload_notify.clone()); + let mut first_bind = true; loop { - let (stream, peer) = match listener.accept().await { - Ok(v) => v, - Err(e) => { - error!("accept error: {e}"); - continue; - } - }; - - let permit = match conn_sem.clone().try_acquire_owned() { - Ok(p) => p, - Err(_) => { - warn!("connection limit reached — dropping connection from {}", peer); - drop(stream); - continue; + let config = state.current_config(); + match create_listener(&config).await { + Ok((listener, tls_acceptor, addr_str)) => { + let conn_sem = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNS)); + let rule_count = config.rules.as_ref().map(|r| r.len()).unwrap_or(0); + info!( + "docker-proxy listening on {} ({}{} rule{}, log_format={})", + addr_str, + if tls_acceptor.is_some() { "TLS, " } else { "" }, + rule_count, + if rule_count == 1 { "" } else { "s" }, + log_format, + ); + drop(config); + + let mut reload = false; + while !reload { + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok((stream, peer)) => { + let permit = match conn_sem.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + warn!( + "connection limit reached — dropping connection from {}", + peer + ); + drop(stream); + continue; + } + }; + + let state = state.clone(); + let tls_acceptor = tls_acceptor.clone(); + + tokio::spawn(async move { + let _permit = permit; + if let Some(acc) = tls_acceptor { + serve_tls(stream, peer, acc, state).await; + } else { + serve_plain(stream, peer, state).await; + } + }); + } + Err(e) => { + error!("accept error: {e}"); + continue; + } + } + } + _ = reload_notify.notified() => { + info!("SIGHUP received; restarting listener"); + reload = true; + } + } + } + first_bind = false; } - }; - - let state = state.clone(); - let tls_acceptor = tls_acceptor.clone(); - - tokio::spawn(async move { - let _permit = permit; - if let Some(acc) = tls_acceptor { - serve_tls(stream, peer, acc, state).await; - } else { - serve_plain(stream, peer, state).await; + Err(e) => { + if first_bind { + return Err(e); + } + error!("failed to rebind listener after SIGHUP: {e}"); + tokio::time::sleep(Duration::from_secs(5)).await; } - }); + } } } @@ -1500,7 +1925,11 @@ async fn serve_tls( } #[cfg(unix)] -fn spawn_sighup_reloader(state: Arc, loaded_from: Option) { +fn spawn_sighup_reloader( + state: Arc, + loaded_from: Option, + reload_notify: Arc, +) { let path = match loaded_from { Some(p) => p, None => return, @@ -1515,26 +1944,29 @@ fn spawn_sighup_reloader(state: Arc, loaded_from: Option) { } }; info!("SIGHUP reload listener installed for {}", path.display()); - loop { - match signal.recv().await { - Some(()) => match ProxyConfig::load_from_path(&path) { - Ok(cfg) => { - let rules_n = cfg.rules.as_ref().map(|r| r.len()).unwrap_or(0); - state.swap_config(Arc::new(cfg)); - info!("config reloaded ({} rules active)", rules_n); - } - Err(e) => { - error!("config reload failed (keeping current): {e}"); - } - }, - None => break, + while let Some(()) = signal.recv().await { + match ProxyConfig::load_from_path(&path) { + Ok(cfg) => { + let rules_n = cfg.rules.as_ref().map(|r| r.len()).unwrap_or(0); + state.swap_config(Arc::new(cfg)); + info!("config reloaded ({} rules active)", rules_n); + reload_notify.notify_one(); + } + Err(e) => { + error!("config reload failed (keeping current): {e}"); + } } } }); } #[cfg(not(unix))] -fn spawn_sighup_reloader(_state: Arc, _loaded_from: Option) {} +fn spawn_sighup_reloader( + _state: Arc, + _loaded_from: Option, + _reload_notify: Arc, +) { +} #[allow(dead_code)] fn _force_ipaddr_use(_: IpAddr) {} diff --git a/src/metrics.rs b/src/metrics.rs index f7c4fa9..e403a9d 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -17,6 +17,7 @@ pub struct Metrics { pub upstream_timeouts_total: AtomicU64, pub body_too_large_total: AtomicU64, pub bad_request_total: AtomicU64, + pub request_timeouts_total: AtomicU64, pub upgrade_total: AtomicU64, by_rule: Mutex>, latency: LatencyHistogram, @@ -70,6 +71,12 @@ impl LatencyHistogram { } } +impl Default for Metrics { + fn default() -> Self { + Self::new() + } +} + impl Metrics { pub fn new() -> Self { Metrics { @@ -84,6 +91,7 @@ impl Metrics { upstream_timeouts_total: AtomicU64::new(0), body_too_large_total: AtomicU64::new(0), bad_request_total: AtomicU64::new(0), + request_timeouts_total: AtomicU64::new(0), upgrade_total: AtomicU64::new(0), by_rule: Mutex::new(HashMap::new()), latency: LatencyHistogram::new(), @@ -113,9 +121,21 @@ impl Metrics { self.requests_total.load(Ordering::Relaxed) )); for (name, label, atomic) in [ - ("docker_proxy_requests_allowed_total", "allowed", &self.requests_allowed), - ("docker_proxy_requests_denied_total", "denied", &self.requests_denied), - ("docker_proxy_requests_dry_run_total", "dry_run", &self.requests_dry_run), + ( + "docker_proxy_requests_allowed_total", + "allowed", + &self.requests_allowed, + ), + ( + "docker_proxy_requests_denied_total", + "denied", + &self.requests_denied, + ), + ( + "docker_proxy_requests_dry_run_total", + "dry_run", + &self.requests_dry_run, + ), ] { out.push_str(&format!("# HELP {name} Requests with outcome '{label}'\n")); out.push_str(&format!("# TYPE {name} counter\n")); @@ -123,14 +143,51 @@ impl Metrics { } for (name, help, atomic) in [ - ("docker_proxy_auth_failures_total", "Failed auth attempts", &self.auth_failures_total), - ("docker_proxy_auth_lockouts_total", "IP auth lockouts triggered", &self.auth_lockouts_total), - ("docker_proxy_rate_limited_total", "Requests blocked by rate limit", &self.rate_limited_total), - ("docker_proxy_upstream_errors_total", "Upstream docker errors", &self.upstream_errors_total), - ("docker_proxy_upstream_timeouts_total", "Upstream docker timeouts", &self.upstream_timeouts_total), - ("docker_proxy_body_too_large_total", "Requests rejected for body size", &self.body_too_large_total), - ("docker_proxy_bad_request_total", "Requests rejected as malformed", &self.bad_request_total), - ("docker_proxy_upgrade_total", "HTTP upgrade (exec/attach) requests proxied", &self.upgrade_total), + ( + "docker_proxy_auth_failures_total", + "Failed auth attempts", + &self.auth_failures_total, + ), + ( + "docker_proxy_auth_lockouts_total", + "IP auth lockouts triggered", + &self.auth_lockouts_total, + ), + ( + "docker_proxy_rate_limited_total", + "Requests blocked by rate limit", + &self.rate_limited_total, + ), + ( + "docker_proxy_upstream_errors_total", + "Upstream docker errors", + &self.upstream_errors_total, + ), + ( + "docker_proxy_upstream_timeouts_total", + "Upstream docker timeouts", + &self.upstream_timeouts_total, + ), + ( + "docker_proxy_body_too_large_total", + "Requests rejected for body size", + &self.body_too_large_total, + ), + ( + "docker_proxy_bad_request_total", + "Requests rejected as malformed", + &self.bad_request_total, + ), + ( + "docker_proxy_request_timeouts_total", + "Client request read timeouts", + &self.request_timeouts_total, + ), + ( + "docker_proxy_upgrade_total", + "HTTP upgrade (exec/attach) requests proxied", + &self.upgrade_total, + ), ] { out.push_str(&format!("# HELP {name} {help}\n")); out.push_str(&format!("# TYPE {name} counter\n")); @@ -153,11 +210,12 @@ impl Metrics { } drop(by_rule); - out.push_str("# HELP docker_proxy_upstream_latency_ms Latency to docker upstream in milliseconds\n"); + out.push_str( + "# HELP docker_proxy_upstream_latency_ms Latency to docker upstream in milliseconds\n", + ); out.push_str("# TYPE docker_proxy_upstream_latency_ms histogram\n"); - let bucket_snapshots: [u64; LATENCY_BUCKET_COUNT] = std::array::from_fn(|i| { - self.latency.buckets[i].load(Ordering::Relaxed) - }); + let bucket_snapshots: [u64; LATENCY_BUCKET_COUNT] = + std::array::from_fn(|i| self.latency.buckets[i].load(Ordering::Relaxed)); let mut cumulative = 0u64; for (i, bound) in LATENCY_BUCKETS_MS.iter().enumerate() { cumulative = cumulative.saturating_add(bucket_snapshots[i]); @@ -166,8 +224,7 @@ impl Metrics { bound, cumulative )); } - cumulative = cumulative - .saturating_add(bucket_snapshots[LATENCY_BUCKET_COUNT - 1]); + cumulative = cumulative.saturating_add(bucket_snapshots[LATENCY_BUCKET_COUNT - 1]); out.push_str(&format!( "docker_proxy_upstream_latency_ms_bucket{{le=\"+Inf\"}} {}\n", cumulative @@ -207,6 +264,7 @@ mod tests { let m = Metrics::new(); m.requests_total.fetch_add(3, Ordering::Relaxed); m.requests_denied.fetch_add(1, Ordering::Relaxed); + m.request_timeouts_total.fetch_add(1, Ordering::Relaxed); m.record_rule_deny("block-secrets", false); m.record_rule_deny("watch-rule", true); m.observe_upstream_latency_ms(15); @@ -214,6 +272,7 @@ mod tests { let out = m.render_prometheus(); assert!(out.contains("docker_proxy_requests_total 3")); assert!(out.contains("docker_proxy_requests_denied_total 1")); + assert!(out.contains("docker_proxy_request_timeouts_total 1")); assert!(out.contains("rule=\"block-secrets\"")); assert!(out.contains("mode=\"dry_run\"")); assert!(out.contains("docker_proxy_upstream_latency_ms_count 2")); diff --git a/src/rules.rs b/src/rules.rs index 599f4af..9e7efbd 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -1,5 +1,4 @@ use crate::config::{Condition, ConditionNode, ResponseFilterEntry, Rule}; -use regex::Regex; use serde_json::Value as JsonValue; use std::{ collections::HashMap, @@ -16,10 +15,7 @@ const MAX_AUTH_LIMITER_KEYS: usize = 16384; #[derive(Debug, Clone)] pub enum RuleResult { Allow, - Deny { - status: u16, - message: String, - }, + Deny { status: u16, message: String }, } #[derive(Debug, Clone)] @@ -61,6 +57,12 @@ struct RateBucket { blocked_until: Option, } +impl Default for RateLimiter { + fn default() -> Self { + Self::new() + } +} + impl RateLimiter { pub fn new() -> Self { RateLimiter { @@ -84,12 +86,14 @@ impl RateLimiter { if !buckets.contains_key(key) && buckets.len() >= MAX_RATE_LIMIT_BUCKETS { return false; } - let bucket = buckets.entry(key.to_string()).or_insert_with(|| RateBucket { - last_check: now, - tokens: max_tokens, - max_tokens, - blocked_until: None, - }); + let bucket = buckets + .entry(key.to_string()) + .or_insert_with(|| RateBucket { + last_check: now, + tokens: max_tokens, + max_tokens, + blocked_until: None, + }); if let Some(blocked) = bucket.blocked_until { if now < blocked { @@ -137,6 +141,12 @@ struct AuthLockoutState { blocked_until: Option, } +impl Default for AuthLimiter { + fn default() -> Self { + Self::new() + } +} + impl AuthLimiter { pub fn new() -> Self { AuthLimiter { @@ -147,16 +157,16 @@ impl AuthLimiter { pub fn is_blocked(&self, key: &str) -> bool { let g = self.state.lock().unwrap_or_else(|p| p.into_inner()); match g.get(key) { - Some(s) => s.blocked_until.map_or(false, |u| Instant::now() < u), + Some(s) => s.blocked_until.is_some_and(|u| Instant::now() < u), None => false, } } - pub fn record_failure(&self, key: &str, max: u32, window: Duration, lockout: Duration) { + pub fn record_failure(&self, key: &str, max: u32, window: Duration, lockout: Duration) -> bool { let mut g = self.state.lock().unwrap_or_else(|p| p.into_inner()); let now = Instant::now(); if g.len() >= MAX_AUTH_LIMITER_KEYS && !g.contains_key(key) { - return; + return false; } let entry = g.entry(key.to_string()).or_insert(AuthLockoutState { failures: 0, @@ -175,8 +185,12 @@ impl AuthLimiter { entry.first_failure = now; } entry.failures = entry.failures.saturating_add(1); - if entry.failures >= max { + + if entry.failures >= max && entry.blocked_until.is_none() { entry.blocked_until = Some(now + lockout); + true + } else { + false } } @@ -250,10 +264,8 @@ fn json_remove(root: &mut JsonValue, path: &str) -> bool { } let parent_path = segments[..segments.len() - 1].join("."); let key = segments.last().unwrap(); - if let Some(parent) = json_get_mut(root, &parent_path) { - if let JsonValue::Object(ref mut map) = parent { - return map.remove(*key).is_some(); - } + if let Some(JsonValue::Object(ref mut map)) = json_get_mut(root, &parent_path) { + return map.remove(*key).is_some(); } false } @@ -274,28 +286,32 @@ fn match_path_condition(condition: &Condition, path: &str) -> bool { }; match condition.operator.as_str() { - "equals" => val.map_or(false, |v| path == v), - "not_equals" => val.map_or(true, |v| path != v), - "contains" => val.map_or(false, |v| path.contains(&v)), - "not_contains" => val.map_or(true, |v| !path.contains(&v)), - "starts_with" => val.map_or(false, |v| path.starts_with(&v)), - "ends_with" => val.map_or(false, |v| path.ends_with(&v)), - "matches" => val.map_or(false, |v| { - Regex::new(&v).map(|r| r.is_match(path)).unwrap_or(false) - }), - "not_matches" => val.map_or(true, |v| { - Regex::new(&v).map(|r| !r.is_match(path)).unwrap_or(true) - }), + "equals" => val.is_some_and(|v| path == v), + "not_equals" => val.is_none_or(|v| path != v), + "contains" => val.is_some_and(|v| path.contains(&v)), + "not_contains" => val.is_none_or(|v| !path.contains(&v)), + "starts_with" => val.is_some_and(|v| path.starts_with(&v)), + "ends_with" => val.is_some_and(|v| path.ends_with(&v)), + "matches" => condition + .compiled_regex + .as_ref() + .map(|r| r.is_match(path)) + .unwrap_or(false), + "not_matches" => condition + .compiled_regex + .as_ref() + .map(|r| !r.is_match(path)) + .unwrap_or(true), "in" => match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => seq.iter().any(|v| { - yaml_value_to_string(v).map_or(false, |s| s == path) - }), + Some(serde_yaml::Value::Sequence(seq)) => seq + .iter() + .any(|v| yaml_value_to_string(v).is_some_and(|s| s == path)), _ => false, }, "not_in" => match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => seq.iter().all(|v| { - yaml_value_to_string(v).map_or(true, |s| s != path) - }), + Some(serde_yaml::Value::Sequence(seq)) => seq + .iter() + .all(|v| yaml_value_to_string(v).is_none_or(|s| s != path)), _ => true, }, _ => false, @@ -309,25 +325,29 @@ fn match_method_condition(condition: &Condition, method: &str) -> bool { }; match condition.operator.as_str() { - "equals" => val.map_or(false, |v| method.eq_ignore_ascii_case(&v)), - "not_equals" => val.map_or(true, |v| !method.eq_ignore_ascii_case(&v)), + "equals" => val.is_some_and(|v| method.eq_ignore_ascii_case(&v)), + "not_equals" => val.is_none_or(|v| !method.eq_ignore_ascii_case(&v)), "in" => match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => seq.iter().any(|v| { - yaml_value_to_string(v).map_or(false, |s| method.eq_ignore_ascii_case(&s)) - }), + Some(serde_yaml::Value::Sequence(seq)) => seq + .iter() + .any(|v| yaml_value_to_string(v).is_some_and(|s| method.eq_ignore_ascii_case(&s))), _ => false, }, "not_in" => match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => seq.iter().all(|v| { - yaml_value_to_string(v).map_or(true, |s| !method.eq_ignore_ascii_case(&s)) - }), + Some(serde_yaml::Value::Sequence(seq)) => seq + .iter() + .all(|v| yaml_value_to_string(v).is_none_or(|s| !method.eq_ignore_ascii_case(&s))), _ => true, }, _ => false, } } -fn match_header_condition(condition: &Condition, headers: &HashMap, header_name: &str) -> bool { +fn match_header_condition( + condition: &Condition, + headers: &HashMap, + header_name: &str, +) -> bool { let header_val = headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case(header_name)) @@ -335,27 +355,33 @@ fn match_header_condition(condition: &Condition, headers: &HashMap header_val.map_or(false, |v| cond_val.as_deref() == Some(v)), - "not_equals" => header_val.map_or(true, |v| cond_val.as_deref() != Some(v)), - "contains" => header_val.map_or(false, |v| cond_val.map_or(false, |cv| v.contains(&cv))), - "not_contains" => header_val.map_or(true, |v| cond_val.map_or(true, |cv| !v.contains(&cv))), - "starts_with" => header_val.map_or(false, |v| cond_val.map_or(false, |cv| v.starts_with(&cv))), - "ends_with" => header_val.map_or(false, |v| cond_val.map_or(false, |cv| v.ends_with(&cv))), - "matches" => header_val.map_or(false, |v| { - cond_val.and_then(|cv| Regex::new(&cv).ok().map(|r| r.is_match(v))).unwrap_or(false) + "equals" => header_val.is_some_and(|v| cond_val.as_deref() == Some(v)), + "not_equals" => header_val.is_none_or(|v| cond_val.as_deref() != Some(v)), + "contains" => header_val.is_some_and(|v| cond_val.is_some_and(|cv| v.contains(&cv))), + "not_contains" => header_val.is_none_or(|v| cond_val.is_none_or(|cv| !v.contains(&cv))), + "starts_with" => header_val.is_some_and(|v| cond_val.is_some_and(|cv| v.starts_with(&cv))), + "ends_with" => header_val.is_some_and(|v| cond_val.is_some_and(|cv| v.ends_with(&cv))), + "matches" => header_val.is_some_and(|v| { + condition + .compiled_regex + .as_ref() + .map(|r| r.is_match(v)) + .unwrap_or(false) }), "exists" => header_val.is_some(), "not_exists" => header_val.is_none(), "in" => match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => { - header_val.map_or(false, |hv| seq.iter().any(|v| yaml_value_to_string(v).map_or(false, |s| s == hv))) - } + Some(serde_yaml::Value::Sequence(seq)) => header_val.is_some_and(|hv| { + seq.iter() + .any(|v| yaml_value_to_string(v).is_some_and(|s| s == hv)) + }), _ => false, }, "not_in" => match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => { - header_val.map_or(true, |hv| seq.iter().all(|v| yaml_value_to_string(v).map_or(true, |s| s != hv))) - } + Some(serde_yaml::Value::Sequence(seq)) => header_val.is_none_or(|hv| { + seq.iter() + .all(|v| yaml_value_to_string(v).is_none_or(|s| s != hv)) + }), _ => true, }, _ => false, @@ -366,49 +392,61 @@ fn match_ip_condition(condition: &Condition, client_ip_str: &str) -> bool { let client_ip: Option = client_ip_str.parse().ok(); match condition.operator.as_str() { - "equals" => condition.value.as_ref().and_then(yaml_value_to_string).map_or(false, |v| { - client_ip.map_or(false, |ip| ip.to_string() == v) - }), - "not_equals" => condition.value.as_ref().and_then(yaml_value_to_string).map_or(true, |v| { - client_ip.map_or(true, |ip| ip.to_string() != v) - }), + "equals" => condition + .value + .as_ref() + .and_then(yaml_value_to_string) + .is_some_and(|v| client_ip.is_some_and(|ip| ip.to_string() == v)), + "not_equals" => condition + .value + .as_ref() + .and_then(yaml_value_to_string) + .is_none_or(|v| client_ip.is_none_or(|ip| ip.to_string() != v)), "in" => { let cidrs: Vec = match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => seq - .iter() - .filter_map(|v| yaml_value_to_string(v)) - .collect(), + Some(serde_yaml::Value::Sequence(seq)) => { + seq.iter().filter_map(yaml_value_to_string).collect() + } Some(v) => yaml_value_to_string(v).into_iter().collect(), None => vec![], }; - let ipnets: Vec = cidrs.iter().filter_map(|c| c.parse::().ok()).collect(); + let ipnets: Vec = cidrs + .iter() + .filter_map(|c| c.parse::().ok()) + .collect(); if ipnets.is_empty() { - client_ip.map_or(false, |ip| cidrs.iter().any(|s| s == &ip.to_string())) + client_ip.is_some_and(|ip| cidrs.iter().any(|s| s == &ip.to_string())) } else { - client_ip.map_or(false, |ip| ipnets.iter().any(|net| net.contains(&ip))) + client_ip.is_some_and(|ip| ipnets.iter().any(|net| net.contains(&ip))) } } "not_in" => { let cidrs: Vec = match &condition.value { - Some(serde_yaml::Value::Sequence(seq)) => seq - .iter() - .filter_map(|v| yaml_value_to_string(v)) - .collect(), + Some(serde_yaml::Value::Sequence(seq)) => { + seq.iter().filter_map(yaml_value_to_string).collect() + } Some(v) => yaml_value_to_string(v).into_iter().collect(), None => vec![], }; - let ipnets: Vec = cidrs.iter().filter_map(|c| c.parse::().ok()).collect(); + let ipnets: Vec = cidrs + .iter() + .filter_map(|c| c.parse::().ok()) + .collect(); if ipnets.is_empty() { - client_ip.map_or(true, |ip| cidrs.iter().all(|s| s != &ip.to_string())) + client_ip.is_none_or(|ip| cidrs.iter().all(|s| s != &ip.to_string())) } else { - client_ip.map_or(true, |ip| !ipnets.iter().any(|net| net.contains(&ip))) + client_ip.is_none_or(|ip| !ipnets.iter().any(|net| net.contains(&ip))) } } _ => false, } } -fn match_body_condition(condition: &Condition, body_json: &Option, field_path: &str) -> bool { +fn match_body_condition( + condition: &Condition, + body_json: &Option, + field_path: &str, +) -> bool { let json_val = body_json.as_ref().and_then(|b| json_get(b, field_path)); match condition.operator.as_str() { @@ -421,48 +459,64 @@ fn match_body_condition(condition: &Condition, body_json: &Option, fi None => return false, }; match condition.operator.as_str() { - "equals" => cond_val.map_or(false, |cv| value_equal(cv, json_val)), - "not_equals" => cond_val.map_or(true, |cv| !value_equal(cv, json_val)), + "equals" => cond_val.is_some_and(|cv| value_equal(cv, json_val)), + "not_equals" => cond_val.is_none_or(|cv| !value_equal(cv, json_val)), "contains" => { - let json_str = match json_val { - JsonValue::String(s) => s.clone(), - other => other.to_string(), + let cond_str = match cond_val.and_then(yaml_value_to_string) { + Some(s) => s, + None => return false, }; - cond_val - .and_then(yaml_value_to_string) - .map_or(false, |cv| json_str.contains(&cv)) + match json_val { + JsonValue::String(s) => s.contains(&cond_str), + JsonValue::Array(arr) => arr + .iter() + .any(|item| item.as_str().is_some_and(|s| s == cond_str)), + other => other.to_string().contains(&cond_str), + } } "not_contains" => { - let json_str = match json_val { - JsonValue::String(s) => s.clone(), - other => other.to_string(), + let cond_str = match cond_val.and_then(yaml_value_to_string) { + Some(s) => s, + None => return true, }; - cond_val - .and_then(yaml_value_to_string) - .map_or(true, |cv| !json_str.contains(&cv)) + match json_val { + JsonValue::String(s) => !s.contains(&cond_str), + JsonValue::Array(arr) => arr + .iter() + .all(|item| item.as_str().is_none_or(|s| s != cond_str)), + other => !other.to_string().contains(&cond_str), + } } "in" => match cond_val { - Some(serde_yaml::Value::Sequence(seq)) => seq.iter().any(|v| value_equal(v, json_val)), + Some(serde_yaml::Value::Sequence(seq)) => { + seq.iter().any(|v| value_equal(v, json_val)) + } _ => false, }, "not_in" => match cond_val { - Some(serde_yaml::Value::Sequence(seq)) => seq.iter().all(|v| !value_equal(v, json_val)), + Some(serde_yaml::Value::Sequence(seq)) => { + seq.iter().all(|v| !value_equal(v, json_val)) + } _ => true, }, - "starts_with" => cond_val.and_then(yaml_value_to_string).map_or(false, |cv| { + "starts_with" => cond_val.and_then(yaml_value_to_string).is_some_and(|cv| { let s = match json_val { JsonValue::String(s) => s.clone(), other => other.to_string(), }; s.starts_with(&cv) }), - "matches" => cond_val.and_then(yaml_value_to_string).map_or(false, |cv| { + "matches" => { let s = match json_val { JsonValue::String(s) => s.clone(), other => other.to_string(), }; - Regex::new(&cv).map(|r| r.is_match(&s)).unwrap_or(false) - }), + condition + .compiled_regex + .as_ref() + .map(|r| r.is_match(&s)) + .unwrap_or(false) + } _ => false, } } @@ -484,12 +538,10 @@ fn value_equal(yaml_val: &serde_yaml::Value, json_val: &JsonValue) -> bool { false } } - _ => { - match json_val { - JsonValue::Null => matches!(yaml_val, serde_yaml::Value::Null), - _ => false, - } - } + _ => match json_val { + JsonValue::Null => matches!(yaml_val, serde_yaml::Value::Null), + _ => false, + }, } } @@ -518,7 +570,11 @@ pub fn evaluate_node(node: &ConditionNode, ctx: &EvaluationContext) -> bool { } } -pub fn evaluate_request(rules: &[Rule], ctx: &EvaluationContext, rate_limiter: &RateLimiter) -> RuleResult { +pub fn evaluate_request( + rules: &[Rule], + ctx: &EvaluationContext, + rate_limiter: &RateLimiter, +) -> RuleResult { evaluate_request_detailed(rules, ctx, rate_limiter).result } @@ -598,9 +654,17 @@ pub fn evaluate_request_detailed( continue; } "rate_limit" => { + if is_dry { + continue; + } if let Some(ref rl_config) = rule.rate_limit { let key = format!("{}:{}", rule.name, ctx.client_ip); - if !rate_limiter.check(&key, rl_config.requests, rl_config.period, rl_config.penalty) { + if !rate_limiter.check( + &key, + rl_config.requests, + rl_config.period, + rl_config.penalty, + ) { let status = rule.status.unwrap_or(429); let message = rule .message @@ -638,9 +702,7 @@ pub fn collect_response_filters( ) -> Vec { let mut all_filters = Vec::new(); for rule in rules { - if rule.conditions.is_empty() - || !rule.conditions.iter().all(|c| evaluate_node(c, ctx)) - { + if rule.conditions.is_empty() || !rule.conditions.iter().all(|c| evaluate_node(c, ctx)) { continue; } if let Some(ref filters) = rule.response_filter { @@ -694,6 +756,7 @@ pub fn apply_response_filters(filters: &[ResponseFilterEntry], body: &[u8]) -> V mod tests { use super::*; use crate::config::{Condition, ConditionNode, RateLimitConfig, ResponseFilterEntry, Rule}; + use regex::Regex; use serde_json::json; use std::collections::HashMap; use std::thread; @@ -711,35 +774,81 @@ mod tests { } fn make_condition(field: &str, operator: &str, value: Option) -> Condition { - Condition { field: field.to_string(), operator: operator.to_string(), value } + let mut condition = Condition { + compiled_regex: None, + field: field.to_string(), + operator: operator.to_string(), + value, + }; + if matches!(condition.operator.as_str(), "matches" | "not_matches") { + if let Some(ref v) = condition.value { + if let Some(s) = yaml_value_to_string(v) { + condition.compiled_regex = Regex::new(&s).ok(); + } + } + } + condition } // --- Path conditions --- #[test] fn test_path_equals() { - let c = make_condition("path", "equals", Some(serde_yaml::Value::String("/test".into()))); - assert!(evaluate_condition(&c, &make_ctx("/test", "GET", "127.0.0.1"))); - assert!(!evaluate_condition(&c, &make_ctx("/other", "GET", "127.0.0.1"))); + let c = make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/test".into())), + ); + assert!(evaluate_condition( + &c, + &make_ctx("/test", "GET", "127.0.0.1") + )); + assert!(!evaluate_condition( + &c, + &make_ctx("/other", "GET", "127.0.0.1") + )); } #[test] fn test_path_starts_with() { - let c = make_condition("path", "starts_with", Some(serde_yaml::Value::String("/containers".into()))); - assert!(evaluate_condition(&c, &make_ctx("/containers/json", "GET", "127.0.0.1"))); - assert!(!evaluate_condition(&c, &make_ctx("/images/json", "GET", "127.0.0.1"))); + let c = make_condition( + "path", + "starts_with", + Some(serde_yaml::Value::String("/containers".into())), + ); + assert!(evaluate_condition( + &c, + &make_ctx("/containers/json", "GET", "127.0.0.1") + )); + assert!(!evaluate_condition( + &c, + &make_ctx("/images/json", "GET", "127.0.0.1") + )); } #[test] fn test_path_matches_regex() { - let c = make_condition("path", "matches", Some(serde_yaml::Value::String(r"^/containers/[^/]+/exec$".into()))); - assert!(evaluate_condition(&c, &make_ctx("/containers/abc123/exec", "POST", "127.0.0.1"))); - assert!(!evaluate_condition(&c, &make_ctx("/containers/abc123/start", "POST", "127.0.0.1"))); + let c = make_condition( + "path", + "matches", + Some(serde_yaml::Value::String( + r"^/containers/[^/]+/exec$".into(), + )), + ); + assert!(evaluate_condition( + &c, + &make_ctx("/containers/abc123/exec", "POST", "127.0.0.1") + )); + assert!(!evaluate_condition( + &c, + &make_ctx("/containers/abc123/start", "POST", "127.0.0.1") + )); } #[test] fn test_path_in() { let c = Condition { + compiled_regex: None, field: "path".into(), operator: "in".into(), value: Some(serde_yaml::Value::Sequence(vec![ @@ -756,7 +865,11 @@ mod tests { #[test] fn test_method_equals() { - let c = make_condition("method", "equals", Some(serde_yaml::Value::String("POST".into()))); + let c = make_condition( + "method", + "equals", + Some(serde_yaml::Value::String("POST".into())), + ); assert!(evaluate_condition(&c, &make_ctx("/", "POST", "127.0.0.1"))); assert!(evaluate_condition(&c, &make_ctx("/", "post", "127.0.0.1"))); assert!(!evaluate_condition(&c, &make_ctx("/", "GET", "127.0.0.1"))); @@ -764,7 +877,11 @@ mod tests { #[test] fn test_method_not_equals() { - let c = make_condition("method", "not_equals", Some(serde_yaml::Value::String("GET".into()))); + let c = make_condition( + "method", + "not_equals", + Some(serde_yaml::Value::String("GET".into())), + ); assert!(evaluate_condition(&c, &make_ctx("/", "POST", "127.0.0.1"))); assert!(!evaluate_condition(&c, &make_ctx("/", "GET", "127.0.0.1"))); } @@ -772,6 +889,7 @@ mod tests { #[test] fn test_method_in() { let c = Condition { + compiled_regex: None, field: "method".into(), operator: "in".into(), value: Some(serde_yaml::Value::Sequence(vec![ @@ -789,6 +907,7 @@ mod tests { #[test] fn test_ip_in_cidr() { let c = Condition { + compiled_regex: None, field: "client_ip".into(), operator: "in".into(), value: Some(serde_yaml::Value::Sequence(vec![ @@ -798,12 +917,16 @@ mod tests { }; assert!(evaluate_condition(&c, &make_ctx("/", "GET", "10.1.2.3"))); assert!(evaluate_condition(&c, &make_ctx("/", "GET", "127.0.0.1"))); - assert!(!evaluate_condition(&c, &make_ctx("/", "GET", "192.168.1.1"))); + assert!(!evaluate_condition( + &c, + &make_ctx("/", "GET", "192.168.1.1") + )); } #[test] fn test_ip_not_in_cidr() { let c = Condition { + compiled_regex: None, field: "client_ip".into(), operator: "not_in".into(), value: Some(serde_yaml::Value::Sequence(vec![ @@ -816,7 +939,11 @@ mod tests { #[test] fn test_ip_equals() { - let c = make_condition("client_ip", "equals", Some(serde_yaml::Value::String("1.2.3.4".into()))); + let c = make_condition( + "client_ip", + "equals", + Some(serde_yaml::Value::String("1.2.3.4".into())), + ); assert!(evaluate_condition(&c, &make_ctx("/", "GET", "1.2.3.4"))); assert!(!evaluate_condition(&c, &make_ctx("/", "GET", "5.6.7.8"))); } @@ -825,7 +952,11 @@ mod tests { #[test] fn test_body_equals_bool() { - let c = make_condition("body.HostConfig.Privileged", "equals", Some(serde_yaml::Value::Bool(true))); + let c = make_condition( + "body.HostConfig.Privileged", + "equals", + Some(serde_yaml::Value::Bool(true)), + ); let mut ctx = make_ctx("/containers/create", "POST", "127.0.0.1"); ctx.body_json = Some(json!({"HostConfig": {"Privileged": true}})); assert!(evaluate_condition(&c, &ctx)); @@ -836,7 +967,11 @@ mod tests { #[test] fn test_body_equals_string() { - let c = make_condition("body.HostConfig.NetworkMode", "equals", Some(serde_yaml::Value::String("host".into()))); + let c = make_condition( + "body.HostConfig.NetworkMode", + "equals", + Some(serde_yaml::Value::String("host".into())), + ); let mut ctx = make_ctx("/containers/create", "POST", "127.0.0.1"); ctx.body_json = Some(json!({"HostConfig": {"NetworkMode": "host"}})); assert!(evaluate_condition(&c, &ctx)); @@ -847,7 +982,12 @@ mod tests { #[test] fn test_body_exists() { - let c = Condition { field: "body.HostConfig.Binds".into(), operator: "exists".into(), value: None }; + let c = Condition { + compiled_regex: None, + field: "body.HostConfig.Binds".into(), + operator: "exists".into(), + value: None, + }; let mut ctx = make_ctx("/containers/create", "POST", "127.0.0.1"); ctx.body_json = Some(json!({"HostConfig": {"Binds": ["/tmp:/tmp"]}})); assert!(evaluate_condition(&c, &ctx)); @@ -858,7 +998,12 @@ mod tests { #[test] fn test_body_not_exists() { - let c = Condition { field: "body.HostConfig.Binds".into(), operator: "not_exists".into(), value: None }; + let c = Condition { + compiled_regex: None, + field: "body.HostConfig.Binds".into(), + operator: "not_exists".into(), + value: None, + }; let mut ctx = make_ctx("/containers/create", "POST", "127.0.0.1"); ctx.body_json = Some(json!({"HostConfig": {}})); assert!(evaluate_condition(&c, &ctx)); @@ -869,7 +1014,11 @@ mod tests { #[test] fn test_body_contains() { - let c = make_condition("body.Image", "contains", Some(serde_yaml::Value::String("nginx".into()))); + let c = make_condition( + "body.Image", + "contains", + Some(serde_yaml::Value::String("nginx".into())), + ); let mut ctx = make_ctx("/containers/create", "POST", "127.0.0.1"); ctx.body_json = Some(json!({"Image": "nginx:latest"})); assert!(evaluate_condition(&c, &ctx)); @@ -880,7 +1029,11 @@ mod tests { #[test] fn test_body_number_equals() { - let c = make_condition("body.Count", "equals", Some(serde_yaml::Value::Number(serde_yaml::Number::from(42)))); + let c = make_condition( + "body.Count", + "equals", + Some(serde_yaml::Value::Number(serde_yaml::Number::from(42))), + ); let mut ctx = make_ctx("/", "POST", "127.0.0.1"); ctx.body_json = Some(json!({"Count": 42})); assert!(evaluate_condition(&c, &ctx)); @@ -893,15 +1046,24 @@ mod tests { #[test] fn test_header_equals() { - let c = make_condition("header.content-type", "equals", Some(serde_yaml::Value::String("application/json".into()))); + let c = make_condition( + "header.content-type", + "equals", + Some(serde_yaml::Value::String("application/json".into())), + ); let mut ctx = make_ctx("/", "POST", "127.0.0.1"); - ctx.headers.insert("content-type".into(), "application/json".into()); + ctx.headers + .insert("content-type".into(), "application/json".into()); assert!(evaluate_condition(&c, &ctx)); } #[test] fn test_header_case_insensitive() { - let c = make_condition("header.x-custom", "equals", Some(serde_yaml::Value::String("hello".into()))); + let c = make_condition( + "header.x-custom", + "equals", + Some(serde_yaml::Value::String("hello".into())), + ); let mut ctx = make_ctx("/", "GET", "127.0.0.1"); ctx.headers.insert("X-Custom".into(), "hello".into()); assert!(evaluate_condition(&c, &ctx)); @@ -909,10 +1071,16 @@ mod tests { #[test] fn test_header_exists() { - let c = Condition { field: "header.authorization".into(), operator: "exists".into(), value: None }; + let c = Condition { + compiled_regex: None, + field: "header.authorization".into(), + operator: "exists".into(), + value: None, + }; let mut ctx = make_ctx("/", "GET", "127.0.0.1"); assert!(!evaluate_condition(&c, &ctx)); - ctx.headers.insert("authorization".into(), "Bearer x".into()); + ctx.headers + .insert("authorization".into(), "Bearer x".into()); assert!(evaluate_condition(&c, &ctx)); } @@ -922,8 +1090,16 @@ mod tests { fn test_or_node_matches_any() { let node = ConditionNode::Or { or: vec![ - ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/a".into())))), - ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/b".into())))), + ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/a".into())), + )), + ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/b".into())), + )), ], }; assert!(evaluate_node(&node, &make_ctx("/a", "GET", "127.0.0.1"))); @@ -935,12 +1111,26 @@ mod tests { fn test_and_node_matches_all() { let node = ConditionNode::And { and: vec![ - ConditionNode::Leaf(make_condition("path", "starts_with", Some(serde_yaml::Value::String("/volumes".into())))), - ConditionNode::Leaf(make_condition("method", "not_equals", Some(serde_yaml::Value::String("GET".into())))), + ConditionNode::Leaf(make_condition( + "path", + "starts_with", + Some(serde_yaml::Value::String("/volumes".into())), + )), + ConditionNode::Leaf(make_condition( + "method", + "not_equals", + Some(serde_yaml::Value::String("GET".into())), + )), ], }; - assert!(evaluate_node(&node, &make_ctx("/volumes/create", "POST", "127.0.0.1"))); - assert!(!evaluate_node(&node, &make_ctx("/volumes", "GET", "127.0.0.1"))); + assert!(evaluate_node( + &node, + &make_ctx("/volumes/create", "POST", "127.0.0.1") + )); + assert!(!evaluate_node( + &node, + &make_ctx("/volumes", "GET", "127.0.0.1") + )); } #[test] @@ -949,11 +1139,23 @@ mod tests { and: vec![ ConditionNode::Or { or: vec![ - ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/a".into())))), - ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/b".into())))), + ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/a".into())), + )), + ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/b".into())), + )), ], }, - ConditionNode::Leaf(make_condition("method", "equals", Some(serde_yaml::Value::String("POST".into())))), + ConditionNode::Leaf(make_condition( + "method", + "equals", + Some(serde_yaml::Value::String("POST".into())), + )), ], }; assert!(evaluate_node(&node, &make_ctx("/a", "POST", "127.0.0.1"))); @@ -978,7 +1180,10 @@ mod tests { for _ in 0..5 { assert!(rl.check("test-ip-2", 5, 60, 60)); } - assert!(!rl.check("test-ip-2", 5, 60, 60), "6th request should be blocked"); + assert!( + !rl.check("test-ip-2", 5, 60, 60), + "6th request should be blocked" + ); } #[test] @@ -1106,7 +1311,11 @@ mod tests { let rules = vec![Rule { name: "block".into(), action: "deny".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/secret".into()))))], + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/secret".into())), + ))], status: Some(403), message: Some("blocked".into()), ..Default::default() @@ -1135,7 +1344,11 @@ mod tests { let rules = vec![Rule { name: "admin-only".into(), action: "require_role".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/create".into()))))], + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/create".into())), + ))], role: Some("admin".into()), ..Default::default() }]; @@ -1151,7 +1364,11 @@ mod tests { let rules = vec![Rule { name: "admin-only".into(), action: "require_role".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/create".into()))))], + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/create".into())), + ))], role: Some("admin".into()), ..Default::default() }]; @@ -1167,17 +1384,34 @@ mod tests { let rules = vec![Rule { name: "rl".into(), action: "rate_limit".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "matches", Some(serde_yaml::Value::String("^/".into()))))], - rate_limit: Some(RateLimitConfig { requests: 2, period: 60, penalty: 60 }), + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "matches", + Some(serde_yaml::Value::String("^/".into())), + ))], + rate_limit: Some(RateLimitConfig { + requests: 2, + period: 60, + penalty: 60, + }), status: Some(429), message: Some("too fast".into()), ..Default::default() }]; let rl = RateLimiter::new(); let ctx = make_ctx("/test", "GET", "10.0.0.1"); - assert!(matches!(evaluate_request(&rules, &ctx, &rl), RuleResult::Allow)); - assert!(matches!(evaluate_request(&rules, &ctx, &rl), RuleResult::Allow)); - assert!(matches!(evaluate_request(&rules, &ctx, &rl), RuleResult::Deny { status: 429, .. })); + assert!(matches!( + evaluate_request(&rules, &ctx, &rl), + RuleResult::Allow + )); + assert!(matches!( + evaluate_request(&rules, &ctx, &rl), + RuleResult::Allow + )); + assert!(matches!( + evaluate_request(&rules, &ctx, &rl), + RuleResult::Deny { status: 429, .. } + )); } #[test] @@ -1185,10 +1419,16 @@ mod tests { let rules = vec![Rule { name: "filter".into(), action: "response_filter".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/json".into()))))], - response_filter: Some(vec![ - ResponseFilterEntry { field: "Env".into(), action: "redact".into(), replacement: None }, - ]), + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/json".into())), + ))], + response_filter: Some(vec![ResponseFilterEntry { + field: "Env".into(), + action: "redact".into(), + replacement: None, + }]), ..Default::default() }]; let filters = collect_response_filters(&rules, &make_ctx("/json", "GET", "127.0.0.1")); @@ -1201,10 +1441,16 @@ mod tests { let rules = vec![Rule { name: "filter".into(), action: "response_filter".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/json".into()))))], - response_filter: Some(vec![ - ResponseFilterEntry { field: "Env".into(), action: "redact".into(), replacement: None }, - ]), + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/json".into())), + ))], + response_filter: Some(vec![ResponseFilterEntry { + field: "Env".into(), + action: "redact".into(), + replacement: None, + }]), ..Default::default() }]; let filters = collect_response_filters(&rules, &make_ctx("/other", "GET", "127.0.0.1")); @@ -1217,13 +1463,21 @@ mod tests { Rule { name: "allow".into(), action: "allow".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "equals", Some(serde_yaml::Value::String("/ping".into()))))], + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "equals", + Some(serde_yaml::Value::String("/ping".into())), + ))], ..Default::default() }, Rule { name: "block".into(), action: "deny".into(), - conditions: vec![ConditionNode::Leaf(make_condition("path", "matches", Some(serde_yaml::Value::String("^/".into()))))], + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "matches", + Some(serde_yaml::Value::String("^/".into())), + ))], ..Default::default() }, ]; @@ -1282,7 +1536,11 @@ mod tests { "matches", Some(serde_yaml::Value::String("^/".into())), ))], - rate_limit: Some(crate::config::RateLimitConfig { requests: 1, period: 60, penalty: 60 }), + rate_limit: Some(crate::config::RateLimitConfig { + requests: 1, + period: 60, + penalty: 60, + }), dry_run: Some(true), ..Default::default() }]; diff --git a/src/setup.rs b/src/setup.rs index 9569bc8..02c5c2a 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,8 +1,8 @@ +use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Password, Select}; use docker_proxy::config::{ AuthConfig, Condition, ConditionNode, GlobalConfig, ProxyConfig, RateLimitConfig, - ResponseFilterEntry, Rule, TokenEntry, + ResponseFilterEntry, Rule, SecretToken, TokenEntry, }; -use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme}; use std::fs; fn main() { @@ -29,7 +29,11 @@ fn main() { .unwrap(); println!(); - let auth_types = vec!["none -- no authentication", "bearer -- single shared secret", "tokens -- per-token roles"]; + let auth_types = vec![ + "none -- no authentication", + "bearer -- single shared secret", + "tokens -- per-token roles", + ]; let auth_choice = Select::with_theme(&theme) .with_prompt("Authentication type") .items(&auth_types) @@ -49,7 +53,7 @@ fn main() { println!(); Some(AuthConfig { auth_type: Some("bearer".to_string()), - secret: Some(secret), + secret: Some(SecretToken::new(secret)), tokens: None, mtls: None, }) @@ -66,9 +70,9 @@ fn main() { let mut tokens: Vec = Vec::new(); loop { println!("--- Add Token ---"); - let token: String = Input::with_theme(&theme) + let token: String = Password::with_theme(&theme) .with_prompt("Token value") - .interact_text() + .interact() .unwrap(); let roles = vec!["admin", "readonly", "user"]; let role_idx = Select::with_theme(&theme) @@ -78,7 +82,7 @@ fn main() { .interact() .unwrap(); tokens.push(TokenEntry { - token, + token: SecretToken::new(token), role: Some(roles[role_idx].to_string()), }); println!(); @@ -93,11 +97,19 @@ fn main() { println!(); } - let secret_opt = if secret.is_empty() { None } else { Some(secret) }; + let secret_opt = if secret.is_empty() { + None + } else { + Some(SecretToken::new(secret)) + }; Some(AuthConfig { auth_type: Some("bearer".to_string()), secret: secret_opt, - tokens: if tokens.is_empty() { None } else { Some(tokens) }, + tokens: if tokens.is_empty() { + None + } else { + Some(tokens) + }, mtls: None, }) } @@ -106,7 +118,11 @@ fn main() { let global = Some(GlobalConfig { port: Some(port), - socket: if socket_path.is_empty() { None } else { Some(socket_path) }, + socket: if socket_path.is_empty() { + None + } else { + Some(socket_path) + }, ..GlobalConfig::default() }); @@ -159,19 +175,24 @@ fn main() { description: Some("Prevent creating or starting exec sessions".to_string()), action: "deny".to_string(), conditions: vec![ConditionNode::Or { - or: vec![ConditionNode::Leaf(Condition { - field: "path".to_string(), - operator: "matches".to_string(), - value: Some(serde_yaml::Value::String( - "^/containers/[^/]+/exec$".to_string(), - )), - }), ConditionNode::Leaf(Condition { - field: "path".to_string(), - operator: "matches".to_string(), - value: Some(serde_yaml::Value::String( - "^/exec/[^/]+/start$".to_string(), - )), - })], + or: vec![ + ConditionNode::Leaf(Condition { + compiled_regex: None, + field: "path".to_string(), + operator: "matches".to_string(), + value: Some(serde_yaml::Value::String( + "^/containers/[^/]+/exec$".to_string(), + )), + }), + ConditionNode::Leaf(Condition { + compiled_regex: None, + field: "path".to_string(), + operator: "matches".to_string(), + value: Some(serde_yaml::Value::String( + "^/exec/[^/]+/start$".to_string(), + )), + }), + ], }], message: Some("Exec operations are not permitted".to_string()), status: Some(403), @@ -180,7 +201,9 @@ fn main() { rules.push(build_simple_deny( "block-docker-build", "Prevent image builds via the API", - "path", "starts_with", "/build", + "path", + "starts_with", + "/build", "Image building is not permitted via this proxy", )); } @@ -195,28 +218,40 @@ fn main() { } 4 => { rules.push(build_simple_deny( - "block-secrets", "Block access to secrets", - "path", "starts_with", "/secrets", + "block-secrets", + "Block access to secrets", + "path", + "starts_with", + "/secrets", "Secrets are not accessible", )); rules.push(build_simple_deny( - "block-configs", "Block access to configs", - "path", "starts_with", "/configs", + "block-configs", + "Block access to configs", + "path", + "starts_with", + "/configs", "Configs are not accessible", )); } 5 => { rules.push(Rule { name: "block-privileged-containers".to_string(), - description: Some("Prevent creating containers with --privileged".to_string()), + description: Some( + "Prevent creating containers with --privileged".to_string(), + ), action: "deny".to_string(), conditions: vec![ ConditionNode::Leaf(Condition { + compiled_regex: None, field: "path".to_string(), operator: "equals".to_string(), - value: Some(serde_yaml::Value::String("/containers/create".to_string())), + value: Some(serde_yaml::Value::String( + "/containers/create".to_string(), + )), }), ConditionNode::Leaf(Condition { + compiled_regex: None, field: "body.HostConfig.Privileged".to_string(), operator: "equals".to_string(), value: Some(serde_yaml::Value::Bool(true)), @@ -234,15 +269,31 @@ fn main() { action: "deny".to_string(), conditions: vec![ ConditionNode::Leaf(Condition { + compiled_regex: None, field: "path".to_string(), operator: "equals".to_string(), - value: Some(serde_yaml::Value::String("/containers/create".to_string())), - }), - ConditionNode::Leaf(Condition { - field: "body.HostConfig.Binds".to_string(), - operator: "exists".to_string(), - value: None, + value: Some(serde_yaml::Value::String( + "/containers/create".to_string(), + )), }), + ConditionNode::Or { + or: vec![ + ConditionNode::Leaf(Condition { + compiled_regex: None, + field: "body.HostConfig.Binds".to_string(), + operator: "exists".to_string(), + value: None, + }), + ConditionNode::Leaf(Condition { + compiled_regex: None, + field: "body.HostConfig.Mounts".to_string(), + operator: "matches".to_string(), + value: Some(serde_yaml::Value::String( + "\"Type\"\\s*:\\s*\"bind\"".to_string(), + )), + }), + ], + }, ], message: Some("Bind mounts are not allowed".to_string()), status: Some(403), @@ -252,15 +303,21 @@ fn main() { 7 => { rules.push(Rule { name: "block-host-network".to_string(), - description: Some("Prevent containers from using host networking".to_string()), + description: Some( + "Prevent containers from using host networking".to_string(), + ), action: "deny".to_string(), conditions: vec![ ConditionNode::Leaf(Condition { + compiled_regex: None, field: "path".to_string(), operator: "equals".to_string(), - value: Some(serde_yaml::Value::String("/containers/create".to_string())), + value: Some(serde_yaml::Value::String( + "/containers/create".to_string(), + )), }), ConditionNode::Leaf(Condition { + compiled_regex: None, field: "body.HostConfig.NetworkMode".to_string(), operator: "equals".to_string(), value: Some(serde_yaml::Value::String("host".to_string())), @@ -276,7 +333,7 @@ fn main() { name: "admin-only-container-lifecycle".to_string(), description: Some("Only admins can start/stop/restart/kill containers".to_string()), action: "require_role".to_string(), - conditions: vec![ConditionNode::Leaf(Condition { + conditions: vec![ConditionNode::Leaf(Condition { compiled_regex: None, field: "path".to_string(), operator: "matches".to_string(), value: Some(serde_yaml::Value::String( @@ -295,9 +352,12 @@ fn main() { description: Some("Only admins can create containers".to_string()), action: "require_role".to_string(), conditions: vec![ConditionNode::Leaf(Condition { + compiled_regex: None, field: "path".to_string(), operator: "equals".to_string(), - value: Some(serde_yaml::Value::String("/containers/create".to_string())), + value: Some(serde_yaml::Value::String( + "/containers/create".to_string(), + )), })], role: Some("admin".to_string()), message: Some("Admin role required to create containers".to_string()), @@ -311,6 +371,7 @@ fn main() { description: Some("Only admins can delete resources".to_string()), action: "require_role".to_string(), conditions: vec![ConditionNode::Leaf(Condition { + compiled_regex: None, field: "method".to_string(), operator: "equals".to_string(), value: Some(serde_yaml::Value::String("DELETE".to_string())), @@ -324,9 +385,12 @@ fn main() { 11 => { rules.push(Rule { name: "internal-network-only".to_string(), - description: Some("Restrict access to private network ranges and localhost".to_string()), + description: Some( + "Restrict access to private network ranges and localhost".to_string(), + ), action: "deny".to_string(), conditions: vec![ConditionNode::Leaf(Condition { + compiled_regex: None, field: "client_ip".to_string(), operator: "not_in".to_string(), value: Some(serde_yaml::Value::Sequence(vec![ @@ -344,9 +408,13 @@ fn main() { 12 => { rules.push(Rule { name: "redact-container-environment".to_string(), - description: Some("Redact environment variables and commands from container inspect".to_string()), + description: Some( + "Redact environment variables and commands from container inspect" + .to_string(), + ), action: "response_filter".to_string(), conditions: vec![ConditionNode::Leaf(Condition { + compiled_regex: None, field: "path".to_string(), operator: "matches".to_string(), value: Some(serde_yaml::Value::String( @@ -371,9 +439,13 @@ fn main() { 13 => { rules.push(Rule { name: "rate-limit-all".to_string(), - description: Some("Limit requests to 50 per 30 seconds, 30s penalty on exceed".to_string()), + description: Some( + "Limit requests to 50 per 30 seconds, 30s penalty on exceed" + .to_string(), + ), action: "rate_limit".to_string(), conditions: vec![ConditionNode::Leaf(Condition { + compiled_regex: None, field: "path".to_string(), operator: "matches".to_string(), value: Some(serde_yaml::Value::String("^/".to_string())), @@ -383,17 +455,17 @@ fn main() { period: 30, penalty: 30, }), - message: Some("Rate limit exceeded. You are blocked for 30 seconds.".to_string()), + message: Some( + "Rate limit exceeded. You are blocked for 30 seconds.".to_string(), + ), status: Some(429), ..Rule::default() }); } - 14 => { - match build_custom_rule(&theme) { - Some(rule) => rules.push(rule), - None => println!(" (custom rule cancelled)"), - } - } + 14 => match build_custom_rule(&theme) { + Some(rule) => rules.push(rule), + None => println!(" (custom rule cancelled)"), + }, _ => {} } } @@ -420,7 +492,10 @@ fn main() { println!(); println!(" config.yaml written successfully."); - println!(" {} rules configured.", config.rules.as_ref().map(|r| r.len()).unwrap_or(0)); + println!( + " {} rules configured.", + config.rules.as_ref().map(|r| r.len()).unwrap_or(0) + ); println!(); println!(" Run with: cargo run"); println!(" Or: DOCKER_PROXY_CONFIG=./config.yaml cargo run"); @@ -440,6 +515,7 @@ fn build_simple_deny( description: Some(description.to_string()), action: "deny".to_string(), conditions: vec![ConditionNode::Leaf(Condition { + compiled_regex: None, field: field.to_string(), operator: operator.to_string(), value: Some(serde_yaml::Value::String(value.to_string())), @@ -458,11 +534,13 @@ fn build_readonly(name: &str, prefix: &str) -> Rule { action: "deny".to_string(), conditions: vec![ ConditionNode::Leaf(Condition { + compiled_regex: None, field: "path".to_string(), operator: "starts_with".to_string(), value: Some(serde_yaml::Value::String(prefix.to_string())), }), ConditionNode::Leaf(Condition { + compiled_regex: None, field: "method".to_string(), operator: "not_equals".to_string(), value: Some(serde_yaml::Value::String("GET".to_string())), @@ -516,7 +594,13 @@ fn build_custom_rule(theme: &ColorfulTheme) -> Option { let mut conditions: Vec = Vec::new(); loop { println!(" --- Add Condition ---"); - let fields = vec!["path", "method", "client_ip", "header.", "body."]; + let fields = vec![ + "path", + "method", + "client_ip", + "header.", + "body.", + ]; let field_idx = Select::with_theme(theme) .with_prompt("Condition field") .items(&fields) @@ -543,9 +627,18 @@ fn build_custom_rule(theme: &ColorfulTheme) -> Option { } let operators = vec![ - "equals", "not_equals", "contains", "not_contains", - "starts_with", "ends_with", "matches", "not_matches", - "in", "not_in", "exists", "not_exists", + "equals", + "not_equals", + "contains", + "not_contains", + "starts_with", + "ends_with", + "matches", + "not_matches", + "in", + "not_in", + "exists", + "not_exists", ]; let op_idx = Select::with_theme(theme) .with_prompt("Operator") @@ -573,6 +666,7 @@ fn build_custom_rule(theme: &ColorfulTheme) -> Option { }; conditions.push(ConditionNode::Leaf(Condition { + compiled_regex: None, field: field.clone(), operator: operator.clone(), value: value.clone(), @@ -618,7 +712,11 @@ fn build_custom_rule(theme: &ColorfulTheme) -> Option { .default("Config.Env".to_string()) .interact_text() .unwrap(); - let filter_actions = vec!["redact -- replace with ***REDACTED***", "remove -- delete the field", "replace -- set to custom value"]; + let filter_actions = vec![ + "redact -- replace with ***REDACTED***", + "remove -- delete the field", + "replace -- set to custom value", + ]; let fa_idx = Select::with_theme(theme) .with_prompt("Filter action") .items(&filter_actions) @@ -632,11 +730,13 @@ fn build_custom_rule(theme: &ColorfulTheme) -> Option { _ => "redact", }; let replacement = if fa == "replace" { - Some(Input::::with_theme(theme) - .with_prompt("Replacement value") - .default("".to_string()) - .interact_text() - .unwrap()) + Some( + Input::::with_theme(theme) + .with_prompt("Replacement value") + .default("".to_string()) + .interact_text() + .unwrap(), + ) } else { None }; diff --git a/src/tls.rs b/src/tls.rs index a0b86e5..e1a78a2 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -23,7 +23,10 @@ pub fn build_server_config(tls: &TlsConfig) -> Result, String> let key = load_private_key(Path::new(&tls.key))?; let builder = ServerConfig::builder(); - let server_config = match (tls.client_ca.as_deref(), tls.require_client_cert.unwrap_or(false)) { + let server_config = match ( + tls.client_ca.as_deref(), + tls.require_client_cert.unwrap_or(false), + ) { (Some(ca_path), require) => { let mut roots = RootCertStore::empty(); for cert in load_certs(Path::new(ca_path))? { @@ -121,12 +124,6 @@ pub fn resolve_mtls_role(identity: &CertIdentity, mtls: Option<&MtlsConfig>) -> } } - if let Some(cn) = identity.common_name.clone() { - if !cn.is_empty() { - return Some(cn); - } - } - mtls.and_then(|m| m.default_role.clone()) } @@ -155,7 +152,10 @@ mod tests { #[test] fn test_cn_match_wildcard() { - assert!(cn_match("*.readonly.example.com", "host1.readonly.example.com")); + assert!(cn_match( + "*.readonly.example.com", + "host1.readonly.example.com" + )); assert!(!cn_match("*.readonly.example.com", "readonly.example.com")); assert!(!cn_match("*.readonly.example.com", "host1.example.com")); } @@ -173,11 +173,14 @@ mod tests { }]), default_role: None, }; - assert_eq!(resolve_mtls_role(&id, Some(&mtls)).as_deref(), Some("admin")); + assert_eq!( + resolve_mtls_role(&id, Some(&mtls)).as_deref(), + Some("admin") + ); } #[test] - fn test_resolve_role_falls_back_to_cn() { + fn test_resolve_role_does_not_fall_back_to_cn() { let id = CertIdentity { common_name: Some("dev.example.com".into()), sans: vec![], @@ -189,10 +192,7 @@ mod tests { }]), default_role: None, }; - assert_eq!( - resolve_mtls_role(&id, Some(&mtls)).as_deref(), - Some("dev.example.com") - ); + assert_eq!(resolve_mtls_role(&id, Some(&mtls)).as_deref(), None); } #[test] @@ -208,12 +208,18 @@ mod tests { }]), default_role: Some("user".into()), }; - assert_eq!(resolve_mtls_role(&id, Some(&mtls)).as_deref(), Some("readonly")); + assert_eq!( + resolve_mtls_role(&id, Some(&mtls)).as_deref(), + Some("readonly") + ); let id_empty = CertIdentity { common_name: None, sans: vec![], }; - assert_eq!(resolve_mtls_role(&id_empty, Some(&mtls)).as_deref(), Some("user")); + assert_eq!( + resolve_mtls_role(&id_empty, Some(&mtls)).as_deref(), + Some("user") + ); } } diff --git a/src/upgrade.rs b/src/upgrade.rs index 6a28977..c2814b4 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -42,7 +42,10 @@ mod tests { #[test] fn test_connection_with_multiple_tokens() { let mut h = HeaderMap::new(); - h.insert("connection", HeaderValue::from_static("keep-alive, Upgrade")); + h.insert( + "connection", + HeaderValue::from_static("keep-alive, Upgrade"), + ); h.insert("upgrade", HeaderValue::from_static("websocket")); assert!(is_upgrade_request(&h)); } diff --git a/tests/integration.rs b/tests/integration.rs index 7917898..953db84 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,4 +1,4 @@ -use docker_proxy::config::{Condition, ConditionNode, ProxyConfig, Rule}; +use docker_proxy::config::{yaml_value_to_string, Condition, ConditionNode, ProxyConfig, Rule}; use docker_proxy::metrics::Metrics; use docker_proxy::rules::{ evaluate_request, evaluate_request_detailed, AuthLimiter, EvaluationContext, RateLimiter, @@ -7,17 +7,27 @@ use docker_proxy::rules::{ use docker_proxy::tls; use docker_proxy::upgrade::is_upgrade_request; use hyper::header::{HeaderMap, HeaderValue}; +use regex::Regex; use std::collections::HashMap; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; fn make_condition(field: &str, operator: &str, value: Option) -> Condition { - Condition { + let mut condition = Condition { + compiled_regex: None, field: field.to_string(), operator: operator.to_string(), value, + }; + if matches!(condition.operator.as_str(), "matches" | "not_matches") { + if let Some(ref v) = condition.value { + if let Some(s) = yaml_value_to_string(v) { + condition.compiled_regex = Regex::new(&s).ok(); + } + } } + condition } fn make_ctx(path: &str, method: &str, ip: &str) -> EvaluationContext { @@ -33,27 +43,29 @@ fn make_ctx(path: &str, method: &str, ip: &str) -> EvaluationContext { #[test] fn integration_deny_secrets_allow_containers() { - let rules = vec![ - Rule { - name: "block-secrets".into(), - action: "deny".into(), - conditions: vec![ConditionNode::Leaf(make_condition( - "path", - "starts_with", - Some(serde_yaml::Value::String("/secrets".into())), - ))], - message: Some("secrets blocked".into()), - status: Some(403), - ..Default::default() - }, - ]; + let rules = vec![Rule { + name: "block-secrets".into(), + action: "deny".into(), + conditions: vec![ConditionNode::Leaf(make_condition( + "path", + "starts_with", + Some(serde_yaml::Value::String("/secrets".into())), + ))], + message: Some("secrets blocked".into()), + status: Some(403), + ..Default::default() + }]; let rl = RateLimiter::new(); let result = evaluate_request(&rules, &make_ctx("/secrets", "GET", "10.0.0.1"), &rl); assert!(matches!(result, RuleResult::Deny { status: 403, .. })); - let result = evaluate_request(&rules, &make_ctx("/containers/json", "GET", "10.0.0.1"), &rl); + let result = evaluate_request( + &rules, + &make_ctx("/containers/json", "GET", "10.0.0.1"), + &rl, + ); assert!(matches!(result, RuleResult::Allow)); } @@ -180,9 +192,15 @@ fn integration_parse_mtls_example() { assert_eq!(auth.auth_type.as_deref(), Some("mtls")); let mtls = auth.mtls.as_ref().unwrap(); let map = mtls.cert_role_map.as_ref().unwrap(); - assert!(map.iter().any(|m| m.cn == "admin.ops.example.com" && m.role == "admin")); + assert!(map + .iter() + .any(|m| m.cn == "admin.ops.example.com" && m.role == "admin")); assert_eq!(mtls.default_role.as_deref(), Some("user")); - let dry_run_rule = cfg.rules.as_ref().unwrap().iter() + let dry_run_rule = cfg + .rules + .as_ref() + .unwrap() + .iter() .find(|r| r.name == "watch-new-image-pulls") .expect("dry-run rule must exist"); assert_eq!(dry_run_rule.dry_run, Some(true)); @@ -252,6 +270,7 @@ fn integration_metrics_render_includes_all_counters() { m.requests_allowed.fetch_add(3, Ordering::Relaxed); m.auth_failures_total.fetch_add(1, Ordering::Relaxed); m.upgrade_total.fetch_add(1, Ordering::Relaxed); + m.request_timeouts_total.fetch_add(1, Ordering::Relaxed); m.observe_upstream_latency_ms(40); m.record_rule_deny("block-secrets", false); @@ -262,6 +281,7 @@ fn integration_metrics_render_includes_all_counters() { "docker_proxy_requests_allowed_total", "docker_proxy_auth_failures_total", "docker_proxy_upgrade_total", + "docker_proxy_request_timeouts_total", "docker_proxy_upstream_latency_ms_count", "rule=\"block-secrets\"", ] { diff --git a/update b/update index 035fbf0..22b39ae 100755 --- a/update +++ b/update @@ -109,7 +109,29 @@ main() { fi echo "" + if [ -n "${DOCKER_PROXY_SIGNING_KEY:-}" ]; then + if command -v gpg &>/dev/null; then + log "Signing SHA256SUMS with GPG key ${DOCKER_PROXY_SIGNING_KEY} ..." + gpg --armor --detach-sign --local-user "$DOCKER_PROXY_SIGNING_KEY" \ + -o "${RELEASE_DIR}/SHA256SUMS.asc" "${RELEASE_DIR}/SHA256SUMS" + else + err "DOCKER_PROXY_SIGNING_KEY is set but gpg is not installed" + exit 1 + fi + fi + log "Creating release ${version} ..." + local assets=( + "${RELEASE_DIR}/${BIN_NAME}-linux-x86_64" + "${RELEASE_DIR}/${BIN_NAME}-linux-aarch64" + "${RELEASE_DIR}/${BIN_NAME}-macos-x86_64" + "${RELEASE_DIR}/${BIN_NAME}-macos-arm64" + "${RELEASE_DIR}/SHA256SUMS" + ) + if [ -f "${RELEASE_DIR}/SHA256SUMS.asc" ]; then + assets+=("${RELEASE_DIR}/SHA256SUMS.asc") + fi + gh release create "$version" \ -R "$REPO" \ --title "$version" \ @@ -122,14 +144,17 @@ Linux builds are statically linked (musl). macOS builds: Install: \`\`\`bash -curl -fsSL https://raw.githubusercontent.com/${REPO}/main/setup | sudo bash +curl -fsSL -o setup https://raw.githubusercontent.com/${REPO}/main/setup +# inspect the script before running +sudo ./setup \`\`\` + +Verify downloaded binaries against \`SHA256SUMS\` from the release page. If a +\`SHA256SUMS.asc\` file is present, verify it with the published release signing +key before trusting the checksums. NOTES )" \ - "${RELEASE_DIR}/${BIN_NAME}-linux-x86_64" \ - "${RELEASE_DIR}/${BIN_NAME}-linux-aarch64" \ - "${RELEASE_DIR}/${BIN_NAME}-macos-x86_64" \ - "${RELEASE_DIR}/${BIN_NAME}-macos-arm64" + "${assets[@]}" log "Release ${version} published." echo ""