Skip to content

Web & config generators

Beyond systemd units, a stack can declare typed config for the services it fronts: nginx vhosts, a Prometheus scrape config, a Litestream replication config. Each is rendered deterministically and merged into the deploy's config-file set. These generators are backend-independent — nginx runs as a systemd service in the target topology, but its config does not depend on the supervisor.

Every value that lands in a path, an nginx directive, or a YAML scalar is validated at compose and again at render (defense-in-depth), through one shared validator module. See the injection-guard discipline below.

nginx vhosts

All HTTP vhosts render to /etc/nginx/conf.d/<serverName>.conf. A duplicate serverName (case-folded — nginx matches case-insensitively) across any vhost type is a compose error, so two generators can never clobber one file.

Static sites

Serve a built package's store-path tree (not a bin/). root names a package module; compose resolves it to the content-addressed store path, pins it in the closure (so it is pushed and GC-rooted), and renders root <store-path>;.

staticSites = [
  { serverName = "docs.example.com"  root = "Docs"  listen = 80 }
]

Reverse proxies

Forward to a validated http(s)://host[:port][/path] upstream with the standard X-Forwarded-* headers. tls = true renders a 443 ssl server referencing the predictable cert paths plus an 80→443 redirect; websocket = true adds the Upgrade/Connection headers.

reverseProxies = [
  { serverName = "app.example.com"
    proxyPass  = "http://127.0.0.1:8080"
    tls        = true
    websocket  = true }
]

Reverse proxies also take access controls (all optional, default unrestricted):

{ serverName = "admin.example.com"
  proxyPass  = "http://127.0.0.1:9000"
  allowMethods    = ["GET", "POST"]          # any other method → 405
  allowIps        = ["10.0.0.0/8"]           # allow these, then deny all
  denyIps         = ["192.0.2.13"]           # checked first
  blockUserAgents = ["BadBot", "scraper"] }  # 403 via an nginx map

allowMethods is a fixed-set allowlist; allowIps/denyIps are validated through the stdlib ipaddress (so a normalised value can carry no injection); blockUserAgents patterns are a quantifier-free substring set (no catastrophic-backtracking ReDoS — nginx evaluates them against every request's User-Agent).

L4 stream (TCP) proxies

A layer-4 TCP proxy (e.g. SSH :22 → a backend host). Because nginx permits only one top-level stream {} block, all stream proxies render into a single /etc/nginx/stream.conf. The operator must include /etc/nginx/stream.conf; at the top level of nginx.conf (Punix does not own the main config). Listen ports must be unique.

streamProxies = [
  { listen = 22  proxyPass = "knot.internal:22" }
]

Redirect-only vhosts

redirects = [
  { serverName = "old.example.com"  to = "https://new.example.com"
    preservePath = true  permanent = true }   # 301 …$request_uri
]

preservePath appends $request_uri; permanent picks 301 vs 302. to is a validated http(s) URL.

Prometheus

A fixed-shape prometheus.yml, hand-rendered (no YAML dependency):

prometheus = {
  scrapeInterval = "15s"
  scrapeConfigs  = [
    { jobName = "node"  targets = ["10.0.0.1:9100", "10.0.0.2:9100"] }
  ]
}

/etc/prometheus/prometheus.yml. Job names, targets, and the interval are validated and emitted as quoted scalars.

Litestream

Replicate SQLite databases to s3 or file targets → /etc/litestream.yml:

litestream = {
  dbs = [
    { path = "/var/lib/app/app.db"
      replicas = [ { type = "s3"  bucket = "my-backups"  path = "app" } ] }
  ]
}

Credentials are not in the file. S3 keys are environment variables on the Litestream service (LITESTREAM_ACCESS_KEY_ID, …) delivered via the secret oracle, so the rendered YAML carries nothing sensitive.

Why everything is validated twice

Configuration is data, but these generators turn it into things a host parses — paths, nginx directives, YAML. A semi-trusted PCL string that reaches a directive unguarded is an injection. Every validator:

  • uses an allowlist character set, never a denylist;
  • anchors with .fullmatch (Python's $ also matches before a trailing newline — a real bypass we've fixed; \Z / fullmatch does not);
  • runs at compose (fail-fast, located) and at render (so a stack built another way is still safe).

Where in the code

  • src/punix/deploy/generators/nginx.py, prometheus.py, litestream.py.
  • src/punix/deploy/validate.py — the single audit home for the injection guards.