From 48bc3399fcb265c4cd7bf6e85fa251df3c9f9cfa Mon Sep 17 00:00:00 2001 From: Derek Date: Fri, 13 Nov 2026 14:06:37 +1100 Subject: [PATCH 1/4] docs(readme): introduce Culvert to new readers The README starts with what Culvert is, the comparison table, the docker run command, the client commands and the opt-in table. It is about 1,100 words. Move the whole cryptography section to docs/cryptography.md. Move the working guide to CONTRIBUTING.md. Match the package description to the GitHub description. --- CONTRIBUTING.md | 61 ++++++- README.md | 380 +++++----------------------------------- docs/cryptography.md | 45 +++++ docs/use-case-travel.md | 2 +- pyproject.toml | 2 +- 5 files changed, 153 insertions(+), 337 deletions(-) create mode 100644 docs/cryptography.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7c90cf..150186c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -156,7 +156,7 @@ hyperi-ci check it once with `uv tool install hyperi-ci` (or `pipx install hyperi-ci`), or run it ad hoc with `uvx hyperi-ci check`. It runs the project's full local validation -- the same suite CI runs -- and reports what to fix. Run it before every push and before opening a -pull request; it is the single best way to give your change the best chance of +pull request. It is the single best way to give your change the best chance of surviving CI. ## CI/CD Workflow @@ -198,7 +198,7 @@ When adding a config field: Site-specific defaults ship as YAML files under `profiles/` and load opt-in via `CULVERT_PROFILE=/etc/vpn/profiles/.yaml`. The -shipped `example.yaml` profile is a placeholder template; copy it and +shipped `example.yaml` profile is a placeholder template. Copy it and edit the values for your own site profile. Env vars always override profile values. @@ -206,9 +206,64 @@ profile values. Config templates under `config/*.template` use envsubst-style `${VAR}` placeholders. Only keys rendered by `entrypoint.py` belong -here; do not introduce new template placeholders without wiring them +here. Do not introduce new template placeholders without wiring them through `scripts/lib/openvpn.py` or `scripts/lib/stunnel.py`. ## Questions If you have questions about contributing, please open an issue or contact us. + +## Context + +### What this is + +A VPN server shipped as one container image. Python inside, but not a Python +package -- the PyPI publish is opted out and a wheel is built only for pipeline +parity. Anyone deploys it, so defaults are generic and anything site-shaped +belongs in a profile or the deployment values. It replaced dfe-vpn, now +retired, so older references to that repo are stale. + +### Where things live + +| Path | What it holds | +|---|---| +| `scripts/entrypoint.py` | Thin orchestrator. Owns the startup ORDER | +| `scripts/lib/` | One module per concern -- config, network, pki, openvpn, wireguard, stunnel, wstunnel, oauth2, health, metrics, process, download | +| `config/*.template` | `${VAR}` templates rendered at runtime | +| `profiles/` | Opt-in site presets loaded by `CULVERT_PROFILE` | +| `deploy/` | Deployment contract and compose fragment, GENERATED by `scripts/generate-deploy-artefacts.py`. The Helm chart is assembled from the contract at release and committed nowhere | +| `docs/architecture.md` | Startup order, the two iptables chains, the PKI invariants | + +### Commands that prove a change + +`hyperi-ci check` locally, `tests/run-all.sh` for the tiers. + +Green in CI is the UNIT tier alone, with coverage of `scripts/` held to hyperi-ci's default 80% floor. `.hyperi-ci.yaml` disables integration, e2e and k8s and leaves `ty` non-blocking. Run the rest yourself: + +```bash +pytest tests/e2e/ -m e2e -v # needs Docker and TUN +pytest tests/k8s/ -m k8s -v # needs a kubeconfig and CULVERT_K8S_* +``` + +`addopts` carries no `-m "not e2e"`, so a bare `pytest` tries the e2e tier and +fails without Docker rather than skipping. + +### What tends to bite + +| Don't | Do | Why | +|---|---|---| +| Write the skip-CI marker literally in a commit message | Describe it | GitHub scans the whole message, body included. PRs #35 and #36 got zero CI runs that way and were abandoned for #37 | +| Hand-edit `deploy/deployment-contract.json` or `deploy/compose/culvert.yaml` | Re-run `scripts/generate-deploy-artefacts.py` | Both render from the scalo deployment contract in `scripts/lib/deployment.py`, and a unit test compares the committed output against a fresh render | +| Call `easyrsa init-pki` | Create the directories directly, as `lib/pki.py` does | On 3.2.x it removes the PKI directory unconditionally. That is a volume mount, so it fails outright, and where it succeeds it takes the CA ([issue #11](https://github.com/hyperi-io/culvert/issues/11)) | +| Check a renamed doc's links on macOS | Let `tests/unit/test_docs_links.py` do it | It compares against `git ls-files`, so case matters. A new doc also has to be linked from `docs/README.md` | +| Read a green PR as proof the image runs | Confirm the Container job built | The Ubuntu 26.04 bump and a broken `--proxy` config each passed every check and died at startup | + +### Where this sits + +Inbound: **hyperi-io/scalo-py**, the only suite edge. `pyproject.toml` declares +`scalo[metrics,secrets-vault,secrets-aws]` bounded below 3 at runtime, plus a +dev-only `scalo[deployment]` range the artefact generators need and the runtime +never imports. Granular extras on purpose. + +Outbound: nothing in the suite graph consumes culvert, and it is an optional +member. It reaches consumers as a public container image on GHCR. diff --git a/README.md b/README.md index 9cc6823..0b7d46c 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,30 @@ # Culvert icon Culvert -The VPN server that installs as a container: OpenVPN and WireGuard in one image, each optionally tunnelled over HTTPS. `docker run` it standalone or drop it into Kubernetes. In a DFE deployment it is the edge-fleet VPN, the door an appliance dials before it posts to dfe-receiver. +OpenVPN and WireGuard in one container, with OpenVPN reachable over HTTPS and OIDC sign-in. -## What it gives you +## What it does +- The VPN server that installs as a container: `docker run` it on one host, or install the Helm chart. In a DFE deployment it is the edge-fleet VPN, the door an appliance dials before it posts to dfe-receiver. - OpenVPN on UDP or TCP and WireGuard, on their own or both at once. -- Either protocol wrapped in TLS on a web port, stunnel for OpenVPN on TCP 443 and wstunnel for WireGuard on TCP 4443 by default. A network that lets out HTTPS on 443 alone passes the OpenVPN tunnel, and the WireGuard one once its port is moved or opened. -- OIDC sign-in on OpenVPN with Microsoft Entra ID, Google Workspace, Okta, Keycloak, Auth0 or any other OIDC provider. WireGuard uses keys. +- Either protocol wrapped in TLS on a web port: stunnel for OpenVPN on TCP 443, wstunnel for WireGuard on TCP 4443. A network that lets out HTTPS alone passes the tunnel. +- OIDC sign-in on OpenVPN with Microsoft Entra ID, Google Workspace, Okta, Keycloak, Auth0 or any other OIDC provider, through [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2). WireGuard uses keys. - PKI from file mounts, OpenBao or Vault, or AWS Secrets Manager, with a self-generated Easy-RSA CA when you bring none. -- TLS 1.3 only, AES-256-GCM, SHA-384 and P-384 on the OpenVPN path, the CNSA 2.0 classical suite ([details](#cryptography-and-cnsa-20)). -- Prometheus `/metrics` and OpenTelemetry OTLP export, `/livez` and `/readyz` probes, a drain on SIGTERM and cgroup-aware resource detection. +- TLS 1.3 only, AES-256-GCM, SHA-384 and P-384 on the OpenVPN path, the CNSA 2.0 classical suite ([docs/cryptography.md](docs/cryptography.md)). +- Prometheus `/metrics` and OTLP export, `/livez` and `/readyz`, a drain on SIGTERM and cgroup-aware resource detection. Images for `linux/amd64` and `linux/arm64`. -## Coming from another VPN server? - -Culvert is one image with no management-plane stack and no per-user -fees. If you already run one of these, here is what you gain by -switching - and what you give up: +Culvert is a classic hub VPN server you run yourself, one image with no management plane and no per-user fees. Coming from something else? | If you use | What you gain with Culvert | |------------|----------------------------| -| [AWS Client VPN](https://aws.amazon.com/vpn/) | Flat cost of a node instead of per-subnet-association plus per-connection charges. Adds WireGuard, any-IdP OIDC, and the HTTPS-tunnelled listeners; AWS Client VPN is OpenVPN-only with a 50 Mbps per-connection baseline. You lose the managed control plane. | -| [kylemanna/docker-openvpn](https://github.com/kylemanna/docker-openvpn) | Appears unmaintained since 2020 and its published image ships OpenVPN 2.4. Culvert a maintained alt: OpenVPN 2.7 with DCO, plus everything above. | -| [angristan/openvpn-install](https://github.com/angristan/openvpn-install) | Same result (plus the extras) without a host-mutating bash script - the server is an image, config is env vars, upgrades are a `docker pull`. | +| [AWS Client VPN](https://aws.amazon.com/vpn/) | Flat cost of a node instead of per-subnet-association plus per-connection charges. Adds WireGuard, any-IdP OIDC, and the HTTPS-tunnelled listeners. You lose the managed control plane. | +| [kylemanna/docker-openvpn](https://github.com/kylemanna/docker-openvpn) | Appears unmaintained since 2020 and its image ships OpenVPN 2.4. Culvert is a maintained alternative: OpenVPN 2.7 with DCO, plus everything above. | +| [angristan/openvpn-install](https://github.com/angristan/openvpn-install) | Same result without a host-mutating bash script: the server is an image, config is env vars, upgrades are a `docker pull`. | | [OpenVPN Access Server](https://openvpn.net/access-server/) | No per-connection licensing. Culvert adds WireGuard and provider-neutral OIDC. | -| [wg-easy](https://github.com/wg-easy/wg-easy) | Keep the WireGuard simplicity, add OpenVPN for the clients that need it, external PKI, and the option to run either protocol over HTTPS. wg-easy's OAuth (15.4+) signs users into its web UI; culvert's OIDC gates the VPN session itself. | - -Culvert is a classic hub VPN server you run yourself, for the -cases that need OpenVPN client compatibility, certificate-based -compliance, or a VPN that travels over HTTPS. - -## Architecture - -```mermaid -graph TD - ENV[Environment Variables] --> EP[entrypoint.py] - EP --> PROTO{CULVERT_PROTOCOL} - PROTO -->|openvpn| OV[OpenVPN Setup] - PROTO -->|wireguard| WG[WireGuard Setup] - PROTO -->|both| OV - PROTO -->|both| WG - - OV --> OVP[OpenVPN Processes
UDP/TCP/HTTPS listeners] - OV --> STUNNEL[stunnel
OpenVPN over HTTPS :443] - OV --> OAUTH[openvpn-auth-oauth2
OIDC SSO] - - WG --> WGP[WireGuard Interface
wg0 via wg-quick] - WG --> WST[wstunnel server
WireGuard over HTTPS :4443] - - EP --> OBS[Observability :9090
/livez + /readyz
/metrics when enabled
OTel push :4317/4318] -``` - -**Platforms:** `linux/amd64`, `linux/arm64` +| [wg-easy](https://github.com/wg-easy/wg-easy) | Keep the WireGuard simplicity, add OpenVPN for the clients that need it, external PKI, and either protocol over HTTPS. wg-easy's OAuth signs users into its web UI, and Culvert's OIDC gates the VPN session itself. | ## Quick start -The default is the simplest working server: OpenVPN over UDP, local PKI, -nothing else. Everything else is opt-in. +The default is the simplest working server: OpenVPN over UDP, local PKI, nothing else. ```bash docker run -d \ @@ -69,318 +38,65 @@ docker run -d \ ghcr.io/hyperi-io/culvert:latest ``` -Opt-ins, each with its own port publish: - -| Capability | Enable with | Port | -|------------|-------------|------| -| WireGuard (alongside or instead) | `CULVERT_PROTOCOL=both` (or `wireguard`) | `51820/udp` | -| TCP fallback listener | `CULVERT_TCP_ENABLED=true` | `1194/tcp` | -| OpenVPN over HTTPS (TLS on a web port) | `CULVERT_HTTPS_ENABLED=true` + stunnel certs | `443/tcp` | -| WireGuard over HTTPS (WebSocket/TLS) | `CULVERT_WG_HTTPS_TUNNEL_ENABLED=true` | `4443/tcp` | -| OIDC SSO | `CULVERT_OAUTH2_ENABLED=true` + IdP config | `9000-9002/tcp` | -| Prometheus metrics | `CULVERT_METRICS_ENABLED=true` | served on `9090/tcp` (already published) | - -Then generate a client config: +Then mint a client and hand it the file it writes into the `culvert-clients` volume, over a channel you trust, because the `.ovpn` embeds the private key: ```bash -docker exec -it generate-client --name zahpod +docker exec -it generate-client --name zaphod +docker exec -it generate-client --status # CURRENT, STALE or UNKNOWN per client +docker exec -it revoke-client zaphod # access ends here, not when the file goes ``` -That mints a real credential, so treat the output accordingly: - -- The `.ovpn` it writes into the `culvert-clients` volume EMBEDS Zaphod's - private key. Whoever holds that file can connect as Zaphod - move it to - the client over a channel you trust. -- Access ends when you revoke it, not when you delete the file: - `docker exec -it revoke-client zaphod`. -- A client name cannot end in `-proxy`. `generate-client --proxy` writes `-proxy-stunnel.conf`, the file a client of that name would overwrite. -- A config is a snapshot of the server's settings. `docker exec -it generate-client --status` lists every issued client as CURRENT, STALE with the settings that moved, or UNKNOWN when it predates the record. STALE and UNKNOWN exit non-zero, so monitoring can run it. -- The `culvert-pki` volume holds the CA that signs every client. Back it up - and keep it private; losing it invalidates every issued config, and - leaking it lets someone else issue their own. - -One identity covers Zaphod's devices concurrently, laptop and phone both -connected. OpenVPN shares the certificate across sessions; WireGuard cannot -share a key, so Zaphod gets one peer per device slot (`zaphod/wg-split.conf`, -`zaphod/wg2-split.conf`) - `CULVERT_SHARED_CLIENT_SLOTS` sets how many. Set -`CULVERT_ALLOW_SHARED_CLIENTS=false` where a credential must identify a single -device. +The `culvert-pki` volume holds the CA that signs every client. Back it up and keep it private. One identity covers Zaphod's devices at once: OpenVPN shares the certificate across sessions, and WireGuard gets one peer per device slot (`CULVERT_SHARED_CLIENT_SLOTS`). The client side, provider by provider, is [docs/vpn-client-setup.md](docs/vpn-client-setup.md). -See [docs/vpn-client-setup.md](docs/vpn-client-setup.md) for the full -client connection guide. +Everything else is an opt-in with its own port: -## Use cases - -The quick start above is the quickstart path. From there, culvert -ships opinionated profiles for the deploy types we actually run IRL with - each -usable as-is with one `CULVERT_PROFILE=` switch, or as a base to tweak. -Each has a full walkthrough (deploy, client, ops) under [docs/](docs/): - -- **Home / lab** ([docs/use-case-home.md](docs/use-case-home.md)) - - reach your home LAN or a throwaway VM. OpenVPN UDP, split tunnel, - local PKI. `CULVERT_PROFILE=home`. -- **Corporate** ([docs/use-case-corporate.md](docs/use-case-corporate.md)) - \- per-user certs plus OIDC SSO, group gating, TCP fallback, clean - offboarding. `CULVERT_PROFILE=corporate`. -- **Restricted networks** ([docs/use-case-travel.md](docs/use-case-travel.md)) - \- run the VPN over HTTPS so it works where only web traffic gets out: - hotel and conference wifi, captive portals, tight corporate egress, or a - network that blocks VPN protocols outright. `CULVERT_PROFILE=travel`. -- **Kubernetes** - ([docs/use-case-kubernetes.md](docs/use-case-kubernetes.md)) - the - published Helm chart, one replica: install values, the PKI volume, - external PKI, probes, metrics. -- **Edge fleet -> receiver** - ([docs/use-case-edge-fleet.md](docs/use-case-edge-fleet.md)) - an - appliance fleet streaming into a central receiver, with reverse - admin back down the tunnels and CGNAT addressing. - `CULVERT_PROFILE=edge-fleet`. - -Addressing (why the tunnels default to `10.8.0.0/22`, when to use the -CGNAT range) is its own note: [docs/addressing.md](docs/addressing.md). +| Capability | Enable with | Port | +|------------|-------------|------| +| WireGuard (alongside or instead) | `CULVERT_PROTOCOL=both` (or `wireguard`) | `51820/udp` | +| TCP fallback listener | `CULVERT_TCP_ENABLED=true` | `1194/tcp` | +| OpenVPN over HTTPS | `CULVERT_HTTPS_ENABLED=true` + stunnel certs | `443/tcp` | +| WireGuard over HTTPS | `CULVERT_WG_HTTPS_TUNNEL_ENABLED=true` | `4443/tcp` | +| OIDC SSO | `CULVERT_OAUTH2_ENABLED=true` + IdP config | `9000-9002/tcp` | +| Prometheus metrics | `CULVERT_METRICS_ENABLED=true` | on `9090/tcp` | ## Configuration -All runtime configuration is via `CULVERT_*` environment variables, -read through [scalo](https://pypi.org/project/scalo/)'s 7-layer cascade -(CLI -> env -> `.env` -> `settings.{env}.yaml` -> `settings.yaml` -> -`defaults.yaml` -> hard-coded), with opt-in profile YAML loaded as an -additional config source. - -See [.env.example](.env.example) for the full list of variables and -their defaults. +Everything is a `CULVERT_*` environment variable, read through [scalo](https://pypi.org/project/scalo/)'s cascade (CLI, env, `.env`, `settings.{env}.yaml`, `settings.yaml`, `defaults.yaml`, built-in). [.env.example](.env.example) lists every variable with its default. `CULVERT_ORG_NAME=Acme` names the self-generated CA `Acme VPN CA`. -### Deployment profiles - -Site-specific defaults ship as YAML files under `profiles/` and load -opt-in via `CULVERT_PROFILE`. Explicit env vars always override -profile values. - -Culvert ships opinionated presets for the common deploy types. Each is -usable as-is once you set `CULVERT_SERVER_CN` (and the secrets a -preset needs), or as a starting point to copy and tweak: +A profile is a YAML of site defaults under `profiles/`, loaded with `CULVERT_PROFILE=` (the shipped ones live at `/etc/vpn/profiles/` in the image, so the bare name works), and an explicit env var always wins over it. Each shipped profile has a walkthrough: | Profile | Shape | Walkthrough | |---------|-------|-------------| -| `home` | OpenVPN UDP, split tunnel, local PKI - reach your home LAN or a lab VM | [docs/use-case-home.md](docs/use-case-home.md) | -| `corporate` | Per-user certs + OIDC SSO + group gating + TCP fallback | [docs/use-case-corporate.md](docs/use-case-corporate.md) | -| `travel` | OpenVPN + WireGuard over HTTPS, for networks that only pass web traffic | [docs/use-case-travel.md](docs/use-case-travel.md) | -| `edge-fleet` | Appliance fleet -> central receiver, reverse admin, CGNAT addressing | [docs/use-case-edge-fleet.md](docs/use-case-edge-fleet.md) | -| `example` | Reference template with placeholder site defaults | - | - -Load a shipped profile (they live at `/etc/vpn/profiles/` in the -image, so the bare name works): - -```bash -CULVERT_PROFILE=home -``` - -A profile sets the CULVERT_* configuration; the deployment side -(published ports, Helm values) is documented in each walkthrough. Or -write your own YAML and point `CULVERT_PROFILE` at its path. Explicit -env vars always override profile values. - -### Site identity (`CULVERT_ORG_NAME`) - -Set `CULVERT_ORG_NAME=Acme` and the self-generated CA becomes -`Acme VPN CA` without any other configuration. Defaults to `VPN CA`. +| `home` | OpenVPN UDP, split tunnel, local PKI: reach your home LAN or a lab VM | [docs/use-case-home.md](docs/use-case-home.md) | +| `corporate` | Per-user certs, OIDC SSO, group gating, TCP fallback, clean offboarding | [docs/use-case-corporate.md](docs/use-case-corporate.md) | +| `travel` | OpenVPN and WireGuard over HTTPS, for networks that only pass web traffic | [docs/use-case-travel.md](docs/use-case-travel.md) | +| `edge-fleet` | An appliance fleet streaming to a central receiver, reverse admin down the tunnels, CGNAT addressing | [docs/use-case-edge-fleet.md](docs/use-case-edge-fleet.md) | -## OIDC providers +Kubernetes has no profile and its own walkthrough: [docs/use-case-kubernetes.md](docs/use-case-kubernetes.md), the chart at `oci://ghcr.io/hyperi-io/charts/culvert`, one replica, assembled from [deploy/deployment-contract.json](deploy/deployment-contract.json) on the scalo-service library chart. -culvert speaks generic OIDC via -[openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2). -Tested with: +External PKI keeps the CA key at your CA, so Culvert neither signs nor revokes OpenVPN client certificates. Issue one there, place it at `pki/issued/.crt` and `pki/private/.key`, and run `generate-client --config-only --name `. Revoke at the CA too: the refresh from `CULVERT_SECRETS_CRL_PATH` ends the session. -- Microsoft Entra ID (Azure AD) -- Google Workspace -- Okta -- Keycloak -- Auth0 +## Operating it -See [docs/vpn-client-setup.md](docs/vpn-client-setup.md) for -provider-specific setup snippets. +One listener (`CULVERT_METRICS_ADDR`, default `0.0.0.0:9090`) carries `/livez`, `/readyz` and, with `CULVERT_METRICS_ENABLED=true`, `/metrics`. OTLP push to `:4317` or `:4318` comes on with `CULVERT_OTEL_*`. Point a `startupProbe` at `/livez`: Kubernetes suspends liveness until it passes, so one path covers a generous boot and a tight liveness period. -## External PKI +Two metrics earn an alert. `vpn_crl_seconds_until_expiry` goes negative once the CRL expires, and OpenVPN then refuses every client, valid ones included, with `certificate expired`. The auto-refresh keeps it topped up, so it moves only when the refresh has been failing, which is also logged at error. `vpn_wireguard_endpoint_flaps_total{peer}` counts a peer's endpoint returning to an address it left within 10 minutes: a roaming phone moves once and stays, and a key loaded on two devices makes them take turns. -culvert supports three external PKI backends via -[scalo](https://pypi.org/project/scalo/) secrets: +## Documentation -- **File** - certs mounted from K8s Secret volumes or local paths -- **OpenBao** - HashiCorp Vault-compatible secrets engine -- **AWS Secrets Manager** +- [docs/architecture.md](docs/architecture.md): what is in the image, the entrypoint sequence, the two PKI modes, the iptables chains and the invariants. +- [docs/addressing.md](docs/addressing.md): why the tunnels default to `10.8.0.0/22`, and when to use the CGNAT range. +- [docs/cryptography.md](docs/cryptography.md): the CNSA 2.0 classical suite, and what is not there yet. +- [docs/performance.md](docs/performance.md): throughput, DCO and the cipher choice. +- [docs/supply-chain.md](docs/supply-chain.md): how the image is built and pinned. +- [CHANGELOG.md](CHANGELOG.md): releases follow SemVer, published to GHCR as multi-arch images. -Local PKI mode (self-generated Easy-RSA CA) is the default for quick -starts and dev environments. +## Help and contributing -External PKI keeps the CA key at your CA, so culvert neither signs nor revokes -OpenVPN client certificates. Issue one there, place it at `pki/issued/.crt` -and `pki/private/.key`, and run `generate-client --config-only --name -`. Revoke at the CA too: the refresh from `CULVERT_SECRETS_CRL_PATH` ends -the session. WireGuard peers work as in local mode. - -## Cryptography and CNSA 2.0 - -The OpenVPN path ships the CNSA 2.0 *classical* suite by default, aligned -to technical deployment reality vs policy here - no OSS VPN stack you can -commonly deploy today is fully CNSA 2.0 (DT hot tip: the gap everywhere is ML-DSA). - -What the defaults give you (OpenVPN, both the plain and the -HTTPS-tunnelled listeners): - -- TLS 1.3 only (`tls-version-min 1.3`), no downgrade -- Data channel AES-256-GCM, SHA-384 control-channel auth. DCO requires an - AEAD cipher rather than this one specifically - ChaCha20-Poly1305 also - qualifies. DCO itself needs the kernel module on the HOST (Linux 6.16+, or - ovpn-backports); without it OpenVPN encrypts in userspace and says so at - startup -- Key exchange P-384 (`secp384r1`) first, X25519 as a compatibility - fallback -- PKI defaults to EC P-384 certificates (`CULVERT_KEY_TYPE=ec`, - `secp384r1`); RSA-4096 available -- `tls-crypt-v2` wraps the TLS handshake in a pre-shared symmetric - layer (metadata protection and DoS mitigation) - -The limits, for those cryptanalysis inclined: - -- **ChaCha20-Poly1305 and X25519 are accepted as secondary options** - for client compatibility. Strict deployments can pin - `data-ciphers AES-256-GCM` and `tls-groups secp384r1` via a custom - server config. -- **WireGuard is outside CNSA by design.** Its suite is fixed - (Curve25519, ChaCha20-Poly1305, BLAKE2s) and not configurable - - that is a WireGuard protocol property, not a Culvert choice. Use the - OpenVPN path where more stringent CNSA alignment matters. -- **No post-quantum key exchange yet.** OpenVPN takes its TLS groups from - the crypto library, and the OpenSSL 3.0.13 in our Ubuntu 24.04 base has - no ML-KEM - so CNSA 2.0's headline items (ML-KEM-1024, ML-DSA-87) are - unavailable. The blocker is the base image, not OpenVPN: hybrid PQ groups - light up once we build against OpenSSL 3.5+, and that base bump is the - work. Meanwhile the `tls-crypt-v2` pre-shared wrap blunts - harvest-now-decrypt-later collection of handshake metadata, but session - keys are still classical ECDH. -- **On "fully CNSA 2.0"**: ML-KEM is starting to appear elsewhere - - strongSwan 6.0+ ships `mlkem1024` for IKEv2, for instance. What no - commonly deployable stack has yet is ML-DSA for authentication. - -## Deployment - -Culvert has two deliberate deployment targets - the same image serves -both: - -- **Docker deploy** - a single host, `docker run` or the - `docker-compose.yaml` in the repo (set `CULVERT_IMAGE`). Local Easy-RSA PKI, env-var - config, client configs on a volume. The five-minute path. -- **Enterprise Kubernetes** - Helm/ArgoCD-shaped: startup/liveness/ - readiness probes, SIGTERM connection drain, cgroup-aware capacity, - external PKI (OpenBao or AWS), OIDC SSO, Prometheus + OTel. Expose - the listeners via Gateway API TLS/UDP passthrough or a LoadBalancer. - Each release publishes a Helm chart at - `oci://ghcr.io/hyperi-io/charts/culvert`, assembled from - [`deploy/deployment-contract.json`](deploy/deployment-contract.json) on - the scalo-service library chart. It runs one replica and defaults to the - simplest working server - OpenVPN UDP, NET_ADMIN - with every other - listener an opt-in. Its WireGuard port opens only where `config.protocol` - is `wireguard` or `both`. The values for the tun device, `ip_forward`, - the config file and the opt-in listeners are in - [docs/use-case-kubernetes.md](docs/use-case-kubernetes.md). - -## Observability - -One listener (`CULVERT_METRICS_ADDR`, default `0.0.0.0:9090`) carries the -operator surface, separate from VPN traffic: - -- **Health:** `/livez`, `/readyz` - always served. These are the only two; - point a `startupProbe` at `/livez` (Kubernetes suspends liveness until the - startup probe passes, so one path covers both a generous boot budget and a - tight liveness period) -- **Prometheus:** `/metrics` on the same port when `CULVERT_METRICS_ENABLED=true` -- **OpenTelemetry:** OTLP push to gRPC `:4317` / HTTP `:4318` collectors - (when enabled via `CULVERT_OTEL_*`) - -Worth an alert: `vpn_crl_seconds_until_expiry` goes negative once the CRL -expires, and OpenVPN then refuses every client - including valid ones, with -`certificate expired`. The CRL auto-refresh keeps it topped up, so this only -moves if the refresh has been failing, which it also logs at error. - -Also worth one: `vpn_wireguard_endpoint_flaps_total{peer}` counts a peer's -endpoint returning to an address it left within 10 minutes. A roaming phone -moves once and stays. A WireGuard key loaded on two devices makes them take -turns, and culvert logs a warning naming the peer and both addresses. - -## Versioning - -Releases follow [SemVer](https://semver.org/) and are published to -GHCR as multi-arch images. Site-generic defaults can be overridden per -deployment via the profile system. See [CHANGELOG.md](CHANGELOG.md). - -## Issues +How to work in this repo, the commands that prove a change and what tends to bite are in [CONTRIBUTING.md](CONTRIBUTING.md). Security issues go to the [security policy](https://github.com/hyperi-io/culvert/security/policy), and community expectations are the [code of conduct](https://github.com/hyperi-io/.github/blob/main/.github/CODE_OF_CONDUCT.md). Open bugs: [this repo](https://github.com/hyperi-io/culvert/issues?q=is%3Aopen+is%3Aissue+type%3ABug+-label%3Abacklog+-label%3Anext-minor) | [whole DFE suite](https://github.com/search?type=issues&q=is%3Aopen+is%3Aissue+type%3ABug+-label%3Abacklog+-label%3Anext-minor+repo%3Ahyperi-io%2Fdfe-infra+repo%3Ahyperi-io%2Fdfe-docker+repo%3Ahyperi-io%2Fdfe-deploy+repo%3Ahyperi-io%2Fdfe-engine+repo%3Ahyperi-io%2Fdfe-loader+repo%3Ahyperi-io%2Fdfe-receiver+repo%3Ahyperi-io%2Fdfe-fetcher+repo%3Ahyperi-io%2Fdfe-archiver+repo%3Ahyperi-io%2Fdfe-transform-vrl+repo%3Ahyperi-io%2Fdfe-transform-vector+repo%3Ahyperi-io%2Fdfe-transform-elastic+repo%3Ahyperi-io%2Fdfe-ui+repo%3Ahyperi-io%2Fdfe-hyperdx+repo%3Ahyperi-io%2Fscalo-rs+repo%3Ahyperi-io%2Fscalo-py+repo%3Ahyperi-io%2Flogreducer+repo%3Ahyperi-io%2Fdfe-schemas+repo%3Ahyperi-io%2Fvector-vrl+repo%3Ahyperi-io%2Fculvert+repo%3Ahyperi-io%2Fdfe-clickhouse-rs+repo%3Ahyperi-io%2Ffactbook-rs) ## License -[Apache-2.0](LICENSE). Third-party components bundled in the container -image are listed in [NOTICE](NOTICE) with their own licences. - -## Contributing - -Pull requests welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the -workflow, commit-message convention, and config conventions. Security -issues: see the [security policy](https://github.com/hyperi-io/culvert/security/policy). -Community expectations: the -[code of conduct](https://github.com/hyperi-io/.github/blob/main/.github/CODE_OF_CONDUCT.md). - -## Context - -### What this is - -A VPN server shipped as one container image. Python inside, but not a Python -package -- the PyPI publish is opted out and a wheel is built only for pipeline -parity. Anyone deploys it, so defaults are generic and anything site-shaped -belongs in a profile or the deployment values. It replaced dfe-vpn, now -retired, so older references to that repo are stale. - -### Where things live - -| Path | What it holds | -|---|---| -| `scripts/entrypoint.py` | Thin orchestrator. Owns the startup ORDER | -| `scripts/lib/` | One module per concern -- config, network, pki, openvpn, wireguard, stunnel, wstunnel, oauth2, health, metrics, process, download | -| `config/*.template` | `${VAR}` templates rendered at runtime | -| `profiles/` | Opt-in site presets loaded by `CULVERT_PROFILE` | -| `deploy/` | Deployment contract and compose fragment, GENERATED by `scripts/generate-deploy-artefacts.py`. The Helm chart is assembled from the contract at release and committed nowhere | -| `docs/architecture.md` | Startup order, the two iptables chains, the PKI invariants | - -### Commands that prove a change - -`hyperi-ci check` locally, `tests/run-all.sh` for the tiers. - -Green in CI is the UNIT tier alone, with coverage of `scripts/` held to hyperi-ci's default 80% floor. `.hyperi-ci.yaml` disables integration, e2e and k8s and leaves `ty` non-blocking. Run the rest yourself: - -```bash -pytest tests/e2e/ -m e2e -v # needs Docker and TUN -pytest tests/k8s/ -m k8s -v # needs a kubeconfig and CULVERT_K8S_* -``` - -`addopts` carries no `-m "not e2e"`, so a bare `pytest` tries the e2e tier and -fails without Docker rather than skipping. - -### What tends to bite - -| Don't | Do | Why | -|---|---|---| -| Write the skip-CI marker literally in a commit message | Describe it | GitHub scans the whole message, body included. PRs #35 and #36 got zero CI runs that way and were abandoned for #37 | -| Hand-edit `deploy/deployment-contract.json` or `deploy/compose/culvert.yaml` | Re-run `scripts/generate-deploy-artefacts.py` | Both render from the scalo deployment contract in `scripts/lib/deployment.py`, and a unit test compares the committed output against a fresh render | -| Call `easyrsa init-pki` | Create the directories directly, as `lib/pki.py` does | On 3.2.x it removes the PKI directory unconditionally. That is a volume mount, so it fails outright, and where it succeeds it takes the CA ([issue #11](https://github.com/hyperi-io/culvert/issues/11)) | -| Check a renamed doc's links on macOS | Let `tests/unit/test_docs_links.py` do it | It compares against `git ls-files`, so case matters. A new doc also has to be linked from `docs/README.md` | -| Read a green PR as proof the image runs | Confirm the Container job built | The Ubuntu 26.04 bump and a broken `--proxy` config each passed every check and died at startup | - -### Where this sits - -Inbound: **hyperi-io/scalo-py**, the only suite edge. `pyproject.toml` declares -`scalo[metrics,secrets-vault,secrets-aws]` bounded below 3 at runtime, plus a -dev-only `scalo[deployment]` range the artefact generators need and the runtime -never imports. Granular extras on purpose. - -Outbound: nothing in the suite graph consumes culvert, and it is an optional -member. It reaches consumers as a public container image on GHCR. +[Apache-2.0](LICENSE). Third-party components bundled in the container image are listed in [NOTICE](NOTICE) with their own licences. diff --git a/docs/cryptography.md b/docs/cryptography.md new file mode 100644 index 0000000..aa64d1e --- /dev/null +++ b/docs/cryptography.md @@ -0,0 +1,45 @@ +# Cryptography and CNSA 2.0 + +The OpenVPN path ships the CNSA 2.0 *classical* suite by default, aligned +to technical deployment reality vs policy here - no OSS VPN stack you can +commonly deploy today is fully CNSA 2.0 (DT hot tip: the gap everywhere is ML-DSA). + +What the defaults give you (OpenVPN, both the plain and the +HTTPS-tunnelled listeners): + +- TLS 1.3 only (`tls-version-min 1.3`), no downgrade +- Data channel AES-256-GCM, SHA-384 control-channel auth. DCO requires an + AEAD cipher rather than this one specifically - ChaCha20-Poly1305 also + qualifies. DCO itself needs the kernel module on the HOST (Linux 6.16+, or + ovpn-backports). Without it OpenVPN encrypts in userspace and says so at + startup +- Key exchange P-384 (`secp384r1`) first, X25519 as a compatibility + fallback +- PKI defaults to EC P-384 certificates (`CULVERT_KEY_TYPE=ec`, + `secp384r1`), and RSA-4096 is available +- `tls-crypt-v2` wraps the TLS handshake in a pre-shared symmetric + layer (metadata protection and DoS mitigation) + +The limits, for those cryptanalysis inclined: + +- **ChaCha20-Poly1305 and X25519 are accepted as secondary options** + for client compatibility. Strict deployments can pin + `data-ciphers AES-256-GCM` and `tls-groups secp384r1` via a custom + server config. +- **WireGuard is outside CNSA by design.** Its suite is fixed + (Curve25519, ChaCha20-Poly1305, BLAKE2s) and not configurable - + that is a WireGuard protocol property, not a Culvert choice. Use the + OpenVPN path where more stringent CNSA alignment matters. +- **No post-quantum key exchange yet.** OpenVPN takes its TLS groups from + the crypto library, and the OpenSSL 3.0.13 in our Ubuntu 24.04 base has + no ML-KEM - so CNSA 2.0's headline items (ML-KEM-1024, ML-DSA-87) are + unavailable. The blocker is the base image, not OpenVPN: hybrid PQ groups + light up once we build against OpenSSL 3.5+, and that base bump is the + work. Meanwhile the `tls-crypt-v2` pre-shared wrap blunts + harvest-now-decrypt-later collection of handshake metadata, but session + keys are still classical ECDH. +- **On "fully CNSA 2.0"**: ML-KEM is starting to appear elsewhere - + strongSwan 6.0+ ships `mlkem1024` for IKEv2, for instance. What no + commonly deployable stack has yet is ML-DSA for authentication. + +The cipher choice against throughput is in [performance.md](performance.md). diff --git a/docs/use-case-travel.md b/docs/use-case-travel.md index d0a89b6..24080b7 100644 --- a/docs/use-case-travel.md +++ b/docs/use-case-travel.md @@ -118,7 +118,7 @@ attention to you specifically. - [vpn-client-setup.md](vpn-client-setup.md#running-the-vpn-over-https) - installing stunnel / wstunnel and connecting on each platform -- [Cryptography and CNSA 2.0 in the README](../README.md#cryptography-and-cnsa-20) +- [Cryptography and CNSA 2.0](cryptography.md) - what the crypto does and does not give you - [use-case-corporate.md](use-case-corporate.md) - the same HTTPS listener as a managed corporate fallback with SSO diff --git a/pyproject.toml b/pyproject.toml index 3828a85..378e498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ [project] name = "culvert" version = "0.0.0" -description = "Culvert - OpenVPN + WireGuard, optionally tunnelled over HTTPS, with OIDC SSO and external PKI" +description = "OpenVPN and WireGuard in one container, with OpenVPN reachable over HTTPS and OIDC sign-in." requires-python = ">=3.14" license = "Apache-2.0" From 355076f394e5a2e2245cb675810d46425fc896e7 Mon Sep 17 00:00:00 2001 From: Derek Date: Fri, 13 Nov 2026 14:12:24 +1100 Subject: [PATCH 2/4] docs(readme): describe .env.example and the example profile .env.example documents the common variables with example values, not every default. The profile table lists the four profiles with walkthroughs. The example template is named beside them. --- README.md | 4 ++-- docs/use-case-travel.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0b7d46c..91fa8d8 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,9 @@ Everything else is an opt-in with its own port: ## Configuration -Everything is a `CULVERT_*` environment variable, read through [scalo](https://pypi.org/project/scalo/)'s cascade (CLI, env, `.env`, `settings.{env}.yaml`, `settings.yaml`, `defaults.yaml`, built-in). [.env.example](.env.example) lists every variable with its default. `CULVERT_ORG_NAME=Acme` names the self-generated CA `Acme VPN CA`. +Everything is a `CULVERT_*` environment variable, read through [scalo](https://pypi.org/project/scalo/)'s cascade (CLI, env, `.env`, `settings.{env}.yaml`, `settings.yaml`, `defaults.yaml`, built-in). [.env.example](.env.example) documents the common variables with example values. `CULVERT_ORG_NAME=Acme` names the self-generated CA `Acme VPN CA`. -A profile is a YAML of site defaults under `profiles/`, loaded with `CULVERT_PROFILE=` (the shipped ones live at `/etc/vpn/profiles/` in the image, so the bare name works), and an explicit env var always wins over it. Each shipped profile has a walkthrough: +A profile is a YAML of site defaults under `profiles/`, loaded with `CULVERT_PROFILE=` (the shipped ones live at `/etc/vpn/profiles/` in the image, so the bare name works), and an explicit env var always wins over it. Four shipped profiles have a walkthrough, and `example` is a placeholder template to copy: | Profile | Shape | Walkthrough | |---------|-------|-------------| diff --git a/docs/use-case-travel.md b/docs/use-case-travel.md index 24080b7..d96a6ce 100644 --- a/docs/use-case-travel.md +++ b/docs/use-case-travel.md @@ -100,7 +100,7 @@ Be straight about the threat model: analysis (timing, volume, connection duration), is playing a different game. This buys you "indistinguishable from HTTPS at a glance", not invisibility. -- **A full tunnel routes your traffic; it does not by itself settle DNS.** +- **A full tunnel routes your traffic, but it does not by itself settle DNS.** The server pushes its DNS servers, and whether your client uses them to the exclusion of the network's own resolver is up to the client. On **Windows** you must uncomment `block-outside-dns` in the `.ovpn` - From 6a2672f61ec9ff1a4abd0327349163d306747d85 Mon Sep 17 00:00:00 2001 From: Derek Date: Fri, 13 Nov 2026 14:13:33 +1100 Subject: [PATCH 3/4] docs(index): link the cryptography page from the docs landing A test checks that the docs landing links every page under docs/. --- docs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/README.md b/docs/README.md index ce9de06..98eabd0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,8 @@ covers what it changes. - [performance.md](performance.md) - the cipher stance (AES-256-GCM the compliance default, ChaCha20 offered), what the tuning profiles change, and the host-side throughput levers (DCO, sysctl). +- [cryptography.md](cryptography.md) - the CNSA 2.0 classical suite the + OpenVPN path ships, and what is not there yet (ML-KEM, ML-DSA). ## For developers From f23a48b7cfee743bec8a5ec2abbed75037e5f79a Mon Sep 17 00:00:00 2001 From: Derek Date: Fri, 13 Nov 2026 14:18:58 +1100 Subject: [PATCH 4/4] docs(contributing): state skip-CI and green-PR rules The skip-CI and green-PR rows state the rules. PR numbers and the Ubuntu bump are gone. --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 150186c..793049a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -252,11 +252,11 @@ fails without Docker rather than skipping. | Don't | Do | Why | |---|---|---| -| Write the skip-CI marker literally in a commit message | Describe it | GitHub scans the whole message, body included. PRs #35 and #36 got zero CI runs that way and were abandoned for #37 | +| Write the skip-CI marker literally in a commit message | Describe it | GitHub scans the whole message, body included, so a literal marker anywhere in it gives the PR no CI run at all | | Hand-edit `deploy/deployment-contract.json` or `deploy/compose/culvert.yaml` | Re-run `scripts/generate-deploy-artefacts.py` | Both render from the scalo deployment contract in `scripts/lib/deployment.py`, and a unit test compares the committed output against a fresh render | | Call `easyrsa init-pki` | Create the directories directly, as `lib/pki.py` does | On 3.2.x it removes the PKI directory unconditionally. That is a volume mount, so it fails outright, and where it succeeds it takes the CA ([issue #11](https://github.com/hyperi-io/culvert/issues/11)) | | Check a renamed doc's links on macOS | Let `tests/unit/test_docs_links.py` do it | It compares against `git ls-files`, so case matters. A new doc also has to be linked from `docs/README.md` | -| Read a green PR as proof the image runs | Confirm the Container job built | The Ubuntu 26.04 bump and a broken `--proxy` config each passed every check and died at startup | +| Read a green PR as proof the image runs | Confirm the Container job built | Only the Container job starts the image, so a base-image bump or a broken config can pass every other check and die at startup | ### Where this sits