Skip to content

Scan gateway

The Scan Gateway lets you register and scan private infrastructure without exposing any host to the internet. You run one per network (VPC, data center, cluster); it holds a single outbound mutual-TLS tunnel to RingZero and does two things:

  1. Relays registrationgateway-mode sidecars in the network talk only to the gateway; their attestation, heartbeats, and challenge ride the tunnel out to RingZero.
  2. Runs scans from inside — the AI pentest agents’ probe traffic egresses from the gateway, inside your network, so it can reach private hosts that RingZero could never reach directly.

Read Connectivity & network topologies first if you haven’t — it explains when to use gateway mode and how the trust model works.

  • Outbound 443 to gateway.ringzero.tech and api.ringzero.tech. No inbound from the internet.
  • A private network scope (RFC1918 / CGNAT / IPv6 ULA CIDRs) the gateway will serve — public ranges are rejected.
  • An Admin or Owner to issue the enrollment token and approve the gateway’s scope.
  1. Issue an enrollment token. In the app, Settings → Gateways → Enroll gateway. You get a single-use, short-lived token (rz_gw_…) and choose the network scope this gateway will serve.

  2. Get the gateway.

    Terminal window
    docker pull ghcr.io/ringzero/gateway:1

    Multi-arch (linux/amd64, linux/arm64), distroless, non-root UID 65532.

  3. Write the config (/etc/ringzero/gateway.yaml):

    /etc/ringzero/gateway.yaml
    tunnel_url: https://gateway.ringzero.tech/v1/gateway/tunnel
    api_url: https://api.ringzero.tech/v1
    enrollment_token_file: /run/secrets/ringzero-gateway-token
    # Private CIDRs this gateway may serve. Admin-approved in the app.
    # Public ranges are rejected — public hosts use direct mode.
    network_scope:
    - 10.0.0.0/8
    - fc00::/7
    name: acme-prod-vpc
    # Internal listener that gateway-mode sidecars dial (mTLS). Bind to the
    # internal interface only — never 0.0.0.0 on an internet-facing host.
    sidecar_ingest:
    port: 7300
    bind: 10.0.0.5
    health:
    port: 7402
    bind: 127.0.0.1
    metrics: true

    Lint it before first run:

    Terminal window
    ringzero-gateway validate --config /etc/ringzero/gateway.yaml --check-network

    --check-network resolves DNS, probes outbound 443, and rejects any publicly-routable CIDR in network_scope.

  4. Provide the enrollment token securely — mount it as a file (0400, owned by the gateway user):

    Terminal window
    sudo install -m 0400 -o ringzero /dev/stdin /run/secrets/ringzero-gateway-token <<< "$RINGZERO_ENROLLMENT_TOKEN"
  5. Run it.

    Terminal window
    docker run -d --name ringzero-gateway \
    -v /etc/ringzero/gateway.yaml:/etc/ringzero/gateway.yaml:ro \
    -v /run/secrets/ringzero-gateway-token:/run/secrets/ringzero-gateway-token:ro \
    --health-cmd "ringzero-gateway status" \
    ghcr.io/ringzero/gateway:1
  6. Approve it. The gateway appears in Settings → Gateways as pending. An Admin/Owner reviews the requested network scope and approves — it moves to active and starts serving hosts. This approval step is the guardrail on which private ranges a gateway is trusted to reach.

Hosts in the gateway’s network run their sidecar in gateway mode — no inbound port, no internet egress, they only reach the gateway:

/etc/ringzero/sidecar.yaml
mode: gateway
gateway_endpoint: 10.0.0.5:7300
registration_token_file: /run/secrets/ringzero-token
labels:
env: production
service: acme-web

Everything else about the sidecar — the registration token, heartbeats, host lifecycle — works exactly as in direct mode, just carried over the gateway instead of a direct connection.

Run the gateway as a StatefulSet in the cluster; sidecars reach it by Service DNS. Because sidecars dial out to the gateway, this needs no ingress and works with ClusterIP-only services — the case where direct mode struggles.

gateway.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: ringzero-gateway
spec:
serviceName: ringzero-gateway
replicas: 1 # one identity per StatefulSet — see HA below
selector:
matchLabels: { app: ringzero-gateway }
template:
metadata:
labels: { app: ringzero-gateway }
spec:
containers:
- name: ringzero-gateway
image: ghcr.io/ringzero/gateway:1
ports:
- containerPort: 7300 # sidecar-ingest (cluster-internal)
volumeMounts:
- name: ringzero-gateway-config
mountPath: /etc/ringzero
readOnly: true
- name: ringzero-state # persistent gateway credential
mountPath: /var/lib/ringzero
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 256Mi } # sized by scan concurrency — tune
volumes:
- name: ringzero-gateway-config
secret:
secretName: ringzero-gateway # carries gateway.yaml + token
volumeClaimTemplates:
- metadata: { name: ringzero-state }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 64Mi } }
---
apiVersion: v1
kind: Service
metadata:
name: ringzero-gateway
spec:
selector: { app: ringzero-gateway }
ports:
- port: 7300
targetPort: 7300

A StatefulSet (not a Deployment) because the gateway derives a persistent mTLS credential at enrollment and must keep it across restarts — the volumeClaimTemplate gives it a stable /var/lib/ringzero. The bundled gateway.yaml in the Secret should point enrollment_token_file at /etc/ringzero/token (the Secret carries both keys, mounted together), matching the sidecar bundle convention.

Gateway-mode sidecars then set gateway_endpoint: ringzero-gateway.default.svc:7300. A Service round-robins fine here — sidecars dial out, so a long-lived sidecar→gateway connection simply pins to whichever gateway pod it reaches (unlike the direct-mode inbound challenge, which can’t be load-balanced).

A gateway isn’t fully stateless — it holds a persistent enrolled identity plus a soft-state (tunnel-synced) authorized-target table — but it is restart-tolerant and horizontally redundant. Run several separately-enrolled gateways per network: each needs its own enrollment token and its own persistent state (a second StatefulSet, or replicas: N where each pod gets a distinct token — a single-use token can’t be shared across pods). Any active gateway whose network_scope covers a host’s address can register and scan it; RingZero load-balances scan routing across healthy gateways and fails over automatically. Removing a gateway (Settings → Gateways → Revoke, or scale its StatefulSet down) simply shifts its hosts to the others.

GET :7402/healthz (bound to localhost) reports tunnel and enrollment state:

{ "status": "ok", "gateway": "gw_1a2b", "enrolled": true, "tunnel": "connected", "activeStreams": 3 }

GET :7402/metrics exposes Prometheus metrics prefixed ringzero_gateway_ (bind health.bind to the pod/host network — not the default 127.0.0.1 — if Prometheus scrapes it from another pod):

MetricMeaning
ringzero_gateway_tunnel_up1 when the outbound tunnel is connected
ringzero_gateway_scan_streams_activeIn-flight scan probe streams
ringzero_gateway_probe_bytes_totalBytes proxied to targets
ringzero_gateway_egress_refused_total{reason}Probes refused (out_of_scope, host_inactive) — the key abuse-signal metric
ringzero_gateway_build_info{version}Running version

Alert on ringzero_gateway_tunnel_up == 0 (the gateway can’t reach RingZero) and on any sustained egress_refused_total (something is trying to scan outside scope).

CommandWhat it does
ringzero-gateway (default: run)Enroll on first start, then hold the tunnel.
ringzero-gateway validateLint the config; --check-network adds DNS + outbound 443 probes and rejects publicly-routable CIDRs. Never prints the token.
ringzero-gateway statusPrint enrollment and tunnel state.
ringzero-gateway versionVersion, commit, architecture.
HostWhen
gateway.ringzero.tech:443Runtime — the tunnel
api.ringzero.tech:443Runtime — enrollment and status
get.ringzero.tech:443Install-time — binaries + checksums
ghcr.io:443, pkg-containers.githubusercontent.com:443Install-time — image pulls

Note the inversion from direct mode: there is no inbound allowlist and no /v1/meta/ip-ranges to configure — nothing from RingZero ever dials into your network.