cptv

April 27, 2026 · View on GitHub

Self-hosted, nerdy network diagnostics. Shows visitors detailed real-time info about their connection — IP, geolocation, ASN, DNS, traceroute, clock skew, DNSSEC validation.

Served over plain HTTP on purpose so captive portals (hotel Wi-Fi, airport networks) can intercept and redirect it. The secure.<domain> prefix has TLS enforced; ipv4.<domain> and ipv6.<domain> are protocol-forcing endpoints reachable over both HTTP and HTTPS so the dual-stack probe works from either context. Hit https://secure.<domain>/protocol with curl --http1.1 / --http2 / --http3 to probe what your client can negotiate against an h1 + h2 + h3 capable server.

The application is domain-agnostic — nothing about cptv.cz is hardcoded. The base domain is read from the X-Base-Domain nginx header at request time. See PLAN.md for the full specification.

Status: early implementation. Scaffolding and CI are in place; many features from PLAN.md §4 are still pending. See CLAUDE.md for what works today vs. what's still to build.

Table of contents

Running the container

Images are published to ghcr.io/pdostal/cptv — container distribution only (no PyPI).

podman run --rm -p 8000:8000 \
  -e CPTV_QUICK_LINKS='[{"label":"Looking Glass","url":"https://lg.example.net","icon":"🔭"}]' \
  ghcr.io/pdostal/cptv:latest

The container expects Valkey to be reachable for traceroute caching and rate limiting. A Podman-native deployment groups the app and Valkey containers in a shared Pod Quadlet so they reach each other on localhost, fronted by nginx with Certbot handling TLS for secure.<domain>.

Required at deploy time

  • An nginx reverse proxy that injects X-Base-Domain, X-Forwarded-For, and X-Forwarded-Proto.
  • A Valkey instance reachable from the app container.
  • GeoLite2 MMDB files on the host, bind-mounted into the container (see GeoLite2 setup below).

Configuration (environment variables)

VariablePurposeDefault
CPTV_QUICK_LINKSJSON array of {label,url,icon?,description?} objects rendered as a "Quick Links" section. Empty/unset hides the section.unset
CPTV_GEOIP_CITY_DBPath to GeoLite2-City.mmdb./app/vendor/geolite2/GeoLite2-City.mmdb
CPTV_GEOIP_ASN_DBPath to GeoLite2-ASN.mmdb./app/vendor/geolite2/GeoLite2-ASN.mmdb
CPTV_VALKEY_HOSTValkey server hostname.localhost
CPTV_VALKEY_PORTValkey server port.6379
CPTV_TRACEROUTE_CACHE_TTLTraceroute result cache TTL in seconds.3600
CPTV_MTR_PATHPath to the mtr binary.mtr
CPTV_MTR_COUNTNumber of ICMP probes per hop.5

All behaviour is configurable via env vars — no hardcoded constants in code.


Production deployment with Podman Quadlet

The recommended production setup uses systemd Quadlet units to run the app and Valkey as rootless Podman containers grouped inside a single Pod. Pod members share a network namespace, so the app reaches Valkey at 127.0.0.1:6379 with no user-defined network and no container-name DNS to wire up. The pod is fronted by nginx with Certbot for TLS on secure.<domain>.

1. Quadlet unit files

Place these files in ~/.config/containers/systemd/ (rootless) or /etc/containers/systemd/ (rootful). Quadlet generates one systemd service per file.

There are two equally valid network setups. Pick one.

A purpose-built Podman network with both an IPv4 RFC 6598 (CGNAT) range and a globally-routable IPv6 /80 carved from the host's /64. Containers get real, public v6 addresses, so ping, mtr and traceroute reach the public IPv6 internet directly with no NAT66 jiggery-pokery.

cptv.network (Quadlet — drops in next to the .pod and .container files):

[Unit]
Description=CPTV network

[Network]
NetworkName=cptv
Subnet=100.64.5.0/24
Gateway=100.64.5.1
IPv6=true
# Replace 2001:db8:abcd:1234:: with an /80 carved from your host /64.
Subnet=2001:db8:abcd:1234::/80
Gateway=2001:db8:abcd:1234::1
IPAMDriver=host-local
InterfaceName=podman-cptv
Label=app=cptv

[Install]
WantedBy=multi-user.target

cptv.pod:

[Unit]
Description=cptv pod (app + valkey share localhost)
After=network-online.target

[Pod]
PodName=cptv
# Only the app port is published to the host loopback; nginx is the
# only thing that talks to it. Valkey stays internal to the pod.
PublishPort=127.0.0.1:8000:8000
# Attach to the cptv network with fixed v4/v6 addresses so nginx and
# DNS records can refer to them stably.
Network=cptv:ip=100.64.5.2,ip=2001:db8:abcd:1234::2
Label=app=cptv

[Install]
WantedBy=multi-user.target

Why the /80? Linux assigns one /128 from the configured subnet to each container. A /80 leaves 48 bits of host space — plenty for cptv plus future pods. Adjust the prefix length to taste; smaller (e.g. /112) is fine. Make sure your host's neighbour-discovery responds for the carved range — for most VPS setups this is automatic because the entire /64 is on-link.

Recipe B — pasta (simplest, no extra .network file)

Pasta is the rootless network helper Podman ships with. --ipv6 lets the pod reach IPv6 destinations using the host's /64 directly, without any sub-prefix delegation. Containers get a "fake" v6 address that pasta NATs through the host.

[Unit]
Description=cptv pod (app + valkey share localhost)
After=network-online.target

[Pod]
PodName=cptv
PublishPort=127.0.0.1:8000:8000
Network=pasta:--ipv6

[Install]
WantedBy=default.target

Verify either recipe works with:

podman exec cptv ping -c 1 -6 2606:4700:4700::1111

If that fails but the host can reach the same address, check that /proc/sys/net/ipv6/conf/all/forwarding is 1 (set in /etc/sysctl.d/) and that the host firewall isn't dropping outbound ICMPv6 from the container interface.

cptv-valkey.container

[Unit]
Description=Valkey cache for cptv
Requires=cptv-pod.service
After=cptv-pod.service

[Container]
Image=docker.io/valkey/valkey:8-alpine
ContainerName=cptv-valkey
Pod=cptv.pod
AutoUpdate=registry

[Install]
WantedBy=default.target

cptv.container

[Unit]
Description=cptv network diagnostics
Requires=cptv-pod.service cptv-valkey.service
After=cptv-pod.service cptv-valkey.service

[Container]
Image=ghcr.io/pdostal/cptv:latest
ContainerName=cptv
Pod=cptv.pod
Volume=%h/.local/share/cptv/geolite2:/app/vendor/geolite2:ro,z
# mtr-packet uses raw ICMP sockets. Rootless Podman does not honour the
# file capability set on the binary inside the image, so the cap must
# be granted to the container explicitly. Without this you'll see
# "mtr-packet: Failure to start mtr-packet: Invalid argument".
AddCapability=CAP_NET_RAW
# Pod members share the network namespace, so Valkey is on localhost.
Environment=CPTV_VALKEY_HOST=127.0.0.1
Environment=CPTV_VALKEY_PORT=6379
AutoUpdate=registry

# Optional: a Quick Links section on the home page. CPTV_QUICK_LINKS is
# JSON; embedding it inline as Environment=… requires escaping every "
# (systemd's quoted-string parser eats unescaped ones), so the cleanest
# pattern is to source it from a separate env file. EnvironmentFile=
# below points at ~/.config/cptv/quick-links.env which contains:
#
#   CPTV_QUICK_LINKS_TITLE=Operator tools
#   CPTV_QUICK_LINKS=[{"label":"Status page","url":"https://status.example.net","icon":"\ud83d\udfe2"},{"label":"Internal wiki","url":"https://wiki.example.net","icon":"\ud83d\udcd6"}]
#
# (one variable per line, no quoting needed — systemd reads env files
# the same way Docker does, line-by-line VAR=value).
EnvironmentFile=-%h/.config/cptv/quick-links.env

[Install]
WantedBy=default.target

Quick Links live on the .container, not the .pod. Quadlet doesn't forward [Pod] Environment= lines into individual container processes. After editing the env file:

systemctl --user restart cptv.service
curl -s http://cptv.example.com/?format=json | jq .quick_links

The leading - on EnvironmentFile=-... means "ignore if missing", so the unit still starts when no Quick Links are configured.

2. Load and start the units

systemctl --user daemon-reload
systemctl --user start cptv-pod.service cptv-valkey.service cptv.service
systemctl --user enable cptv-pod.service cptv-valkey.service cptv.service

3. Enable Podman auto-update

Podman auto-update pulls newer images from the registry and restarts containers that have AutoUpdate=registry set.

# Enable the systemd timer (rootless)
systemctl --user enable --now podman-auto-update.timer

# Or run a one-off update check
podman auto-update

The timer runs daily by default. To customise the schedule, override the timer:

systemctl --user edit podman-auto-update.timer
[Timer]
OnCalendar=*-*-* 04:00:00

nginx reverse proxy

nginx (or OpenResty) handles TLS termination, subdomain routing, and header injection. The base domain is passed to the app via the X-Base-Domain header so the app stays domain-agnostic.

Three files are involved (replace cptv.example.com with your domain throughout):

/etc/nginx/nginx.conf — Lua FFI for visitor TCP MSS (OpenResty only)

The Timing card's TCP MSS row needs getsockopt(IPPROTO_TCP, TCP_MAXSEG) on the visitor's socket. tcpi_advmss has no built-in nginx variable, so this requires OpenResty (a drop-in nginx replacement that bundles lua-nginx-module, lua-resty-core, and LuaJIT). On stock distro nginx, skip this section — RTT/RTTvar still populate, only MSS stays .

Add this once inside the http {} block of /etc/nginx/nginx.conf. Field order in the structs matches upstream nginx 1.21–1.27.

# /etc/nginx/nginx.conf — inside http { ... }
init_by_lua_block {
    local ffi = require "ffi"
    local ok, err = pcall(ffi.cdef, [[
        typedef intptr_t        ngx_int_t;
        typedef uintptr_t       ngx_uint_t;
        typedef struct ngx_connection_s ngx_connection_t;
        struct ngx_connection_s {
            void *data; void *read; void *write; int fd;
        };
        typedef struct {
            ngx_uint_t signature;
            ngx_connection_t *connection;
        } ngx_http_request_t_partial;
        int getsockopt(int sockfd, int level, int optname,
                       void *optval, unsigned int *optlen);
    ]])
    if not ok and not string.find(tostring(err), "redefined", 1, true) then
        ngx.log(ngx.ERR, "cptv: ffi.cdef failed: ", err)
    end
}

/etc/nginx/snippets/cptv-proxy.conf — shared upstream + timing headers

One snippet, included by every cptv vhost. Drop the header_filter_by_lua_block if you're on stock nginx without Lua.

# Shared upstream config for cptv. The connection-protocol headers are
# essential — uvicorn always sees HTTP/1.1 from the loopback hop, so
# the app reads them to populate /protocol.
proxy_pass         http://127.0.0.1:8000;
proxy_set_header   Host                       $host;
proxy_set_header   X-Base-Domain              $host_base_domain;
proxy_set_header   X-Forwarded-For            $remote_addr;
proxy_set_header   X-Forwarded-Proto          $scheme;
proxy_set_header   X-Forwarded-HTTP-Version   $server_protocol;
proxy_set_header   X-Forwarded-TLS-Version    $ssl_protocol;
proxy_set_header   X-Forwarded-TLS-Cipher     $ssl_cipher;
proxy_set_header   X-Forwarded-ALPN           $ssl_alpn_protocol;

# Visitor TCP RTT/RTTvar from kernel TCP_INFO. Both directions: the
# request side feeds /?format=json + curl plain text via Python; the
# response side feeds the home-page JS reading /timing/echo.
# `always` is required so 304 / 4xx still carry the headers.
proxy_set_header   X-Tcp-Rtt-Us               $tcpinfo_rtt;
proxy_set_header   X-Tcp-Rttvar-Us            $tcpinfo_rttvar;
add_header         X-Tcp-Rtt-Us               $tcpinfo_rtt    always;
add_header         X-Tcp-Rttvar-Us            $tcpinfo_rttvar always;

# Visitor TCP MSS via OpenResty Lua FFI (omit on stock nginx).
header_filter_by_lua_block {
    local ffi  = require "ffi"
    local base = require "resty.core.base"
    local r = base.get_request()
    if not r then return end
    local req = ffi.cast("ngx_http_request_t_partial*", r)
    if req == nil or req.connection == nil then return end
    local fd = req.connection.fd
    if fd <= 0 then return end
    local val = ffi.new("int[1]")
    local len = ffi.new("unsigned int[1]", ffi.sizeof("int"))
    if ffi.C.getsockopt(fd, 6, 2, val, len) == 0 then  -- IPPROTO_TCP=6, TCP_MAXSEG=2
        ngx.header["X-Tcp-Mss-Server"] = tostring(val[0])
    end
}

add_header inheritance gotcha: if any deeper location {} declares its own add_header, all parent add_header directives are silently dropped at that scope. If you have other add_header lines in the same vhost (e.g. Strict-Transport-Security), repeat the timing ones at that scope.

/etc/nginx/sites-available/cptv.conf — vhost config

Plain HTTP on apex (captive-portal-friendly), HTTP+HTTPS on ipv4. / ipv6. (the dual-stack probe needs both schemes), HTTPS-only on secure. (HTTP/2 + HTTP/3 for protocol probes).

map $host $host_base_domain {
    default         cptv.example.com;
    ~^www\.(.+)$    \$1;
}

# Apex + www: plain HTTP only. HTTPS on apex redirects down to HTTP
# so captive portals don't trip on cert errors.
server {
    listen 80;
    listen [::]:80;
    server_name cptv.example.com www.cptv.example.com;
    location / { include snippets/cptv-proxy.conf; }
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name cptv.example.com www.cptv.example.com;
    ssl_certificate     /etc/letsencrypt/live/cptv.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cptv.example.com/privkey.pem;
    return 301 http://$host$request_uri;
}

# ipv4. + ipv6.: HTTP and HTTPS, both serve the proxy directly. DNS A
# only on ipv4., AAAA only on ipv6. Do NOT redirect HTTP -> HTTPS.
server {
    listen 80;
    listen [::]:80;
    server_name ipv4.cptv.example.com ipv6.cptv.example.com;
    location / { include snippets/cptv-proxy.conf; }
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name ipv4.cptv.example.com ipv6.cptv.example.com;
    ssl_certificate     /etc/letsencrypt/live/cptv.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cptv.example.com/privkey.pem;
    location / { include snippets/cptv-proxy.conf; }
}

# secure.<domain>: HTTPS-only, advertises HTTP/2 + HTTP/3. Required
# for `curl --http3 https://secure.<domain>/protocol`. Needs nginx
# >=1.25 with --with-http_v3_module and UDP/443 open in the firewall.
# Drop the two `quic` listen lines and Alt-Svc header if not built.
server {
    listen 80;
    listen [::]:80;
    server_name secure.cptv.example.com;
    return 301 https://$host$request_uri;
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;
    http2 on;
    server_name secure.cptv.example.com;
    ssl_certificate     /etc/letsencrypt/live/cptv.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cptv.example.com/privkey.pem;
    add_header Alt-Svc 'h3=":443"; ma=86400' always;
    location / { include snippets/cptv-proxy.conf; }
}

Enable, validate, and reload:

ln -s /etc/nginx/sites-available/cptv.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx   # or `openresty`

Verifying timing headers

After reload, hit /timing/echo (use -D - -o /dev/null, not -sI — the route is GET-only and HEAD returns 405):

curl -s -D - -o /dev/null http://localhost/timing/echo \
  | grep -iE 'x-tcp-|x-response-time'
# X-Response-Time-Ms: 0.4
# X-Tcp-Rtt-Us: 24587
# X-Tcp-Rttvar-Us: 15820
# X-Tcp-Mss-Server: 1448

If RTT headers are missing from the response, the add_header lines weren't included or got shadowed by a deeper add_header. If only MSS is missing, the Lua FFI errored at startup — check /var/log/nginx/error.log for cptv: ffi.cdef failed.

Spoof protection

The app strips X-Tcp-Rtt-Us, X-Tcp-Rttvar-Us, X-Tcp-Mss, and X-Tcp-Mss-Server from any inbound request whose immediate peer is not loopback (127.0.0.1, ::1, localhost). nginx on the loopback hop is the only sender the app trusts.

About per-protocol probe subdomains

Earlier releases (v0.2.0 – v0.2.8) advertised http1.<domain>, http2.<domain>, http3.<domain> pinned to a single HTTP version each. They have been removed. nginx's HTTP module has no per-vhost ALPN-pinning directive (ssl_alpn exists only in the stream module), so a client could downgrade explicitly (curl --http1.1 https://http2.<domain>) and the probe would lie about what was actually negotiated. The current approach: one HTTPS host (secure.<domain>) speaking all three protocols, plus curl --http1.1 / --http2 / --http3 https://secure.<domain>/protocol for users who want to compare.


Certbot (nginx plugin)

Obtain and auto-renew TLS certificates for secure.<domain> using Certbot with the nginx plugin. The certificates are also used by the apex HTTPS-to-HTTP redirect block.

# Install certbot + nginx plugin
apt install certbot python3-certbot-nginx    # Debian/Ubuntu
# dnf install certbot python3-certbot-nginx  # Fedora

# Obtain certificate (covers all subdomains in one cert)
certbot --nginx \
  -d cptv.example.com \
  -d www.cptv.example.com \
  -d secure.cptv.example.com \
  -d ipv4.cptv.example.com \
  -d ipv6.cptv.example.com

# Verify auto-renewal timer is active
systemctl status certbot.timer

Certbot's nginx plugin will automatically update the ssl_certificate / ssl_certificate_key paths in your nginx config and set up a systemd timer for renewal.

Why the browser console may show "CORS request did not succeed"

When a visitor's network can reach only one IP family (very common — many residential ISPs are still v4-only), the dual-stack probe's fetch to the unreachable subdomain (ipv4.<domain> or ipv6.<domain>) fails at the network layer. Firefox and Chrome log this as CORS request did not succeed, status (null) even though the server's CORS configuration is fine — the wording is the browser's generic message for "no response received". The page handles this silently and the unreachable stack's row stays as . No fix needed.


Connection protocol probe

/protocol reports the negotiated HTTP version, TLS version, and ALPN token for the current connection. The home page renders a one-line summary (Connected via HTTP/2 over TLSv1.3 (ALPN: h2)) and curl users can compare protocols against the same secure.<domain> host:

curl --http1.1 https://secure.cptv.example.com/protocol
# HTTP/1.1  TLSv1.3  http/1.1  encrypted

curl --http2   https://secure.cptv.example.com/protocol
# HTTP/2  TLSv1.3  h2  encrypted

curl --http3   https://secure.cptv.example.com/protocol
# HTTP/3  TLSv1.3  h3  encrypted

Output is a single tab-separated line — cut -f1 pulls just the HTTP version. JSON (?format=json or Accept: application/json) and HTML formats are also available.

If --http3 reports h2 instead of h3, check (in order):

  1. nginx was built with --with-http_v3_module (nginx -V 2>&1 | tr ' ' '\n' | grep http_v3).
  2. UDP/443 is open through host and cloud firewalls — HTTP/3 rides QUIC over UDP.
  3. The Alt-Svc header is being advertised on prior responses (cold reload looks like h2 until the cache is warm).
  4. The browser actually supports HTTP/3 (Safari needs the experimental flag in some versions).

Building locally

Requires Python 3.12+, uv, Node 22+, and podman (or Docker).

# Python deps + test
uv sync --dev --frozen
uv run pytest

# JS/CSS assets
npm install
npm run build      # copies htmx + pico into cptv/static/vendor/

# Run dev server (no Valkey, no GeoIP — feature-dependent endpoints will degrade)
uv run uvicorn cptv.main:app --reload

Running Valkey locally for development

podman run --rm -d --name cptv-valkey -p 6379:6379 docker.io/valkey/valkey:8-alpine

The app defaults to localhost:6379 and will connect automatically. Stop it with podman stop cptv-valkey.

Building the container image

podman build -f Containerfile -t cptv:dev .

GeoLite2 databases are not included in the image — mount them at runtime (see below).

GeoLite2 setup

The MaxMind EULA prohibits redistributing GeoLite2 databases, so the container image ships without them. You download the databases to the host and bind-mount them into the container. A systemd timer keeps them fresh (MaxMind requires updates at least every 30 days).

1. Sign up and get a license key

  1. Sign up at https://www.maxmind.com/en/geolite2/signup.
  2. Generate a license key in the account portal.

2. Initial download

export MAXMIND_LICENSE_KEY=...     # your key
scripts/download-geolite2.sh       # writes ~/.local/share/cptv/geolite2/*.mmdb

Or download manually and place GeoLite2-City.mmdb and GeoLite2-ASN.mmdb in ~/.local/share/cptv/geolite2/.

3. Systemd timer for weekly refresh

Create ~/.config/systemd/user/cptv-geolite2-refresh.service:

[Unit]
Description=Refresh GeoLite2 databases for cptv

[Service]
Type=oneshot
Environment=MAXMIND_LICENSE_KEY=<your-key-here>
ExecStart=%h/.local/bin/cptv-download-geolite2.sh

Create ~/.config/systemd/user/cptv-geolite2-refresh.timer:

[Unit]
Description=Weekly GeoLite2 refresh for cptv

[Timer]
OnCalendar=Mon *-*-* 05:00:00
Persistent=true

[Install]
WantedBy=timers.target

Copy the download script and enable the timer:

mkdir -p ~/.local/bin
cp scripts/download-geolite2.sh ~/.local/bin/cptv-download-geolite2.sh
chmod +x ~/.local/bin/cptv-download-geolite2.sh

systemctl --user daemon-reload
systemctl --user enable --now cptv-geolite2-refresh.timer

After a GeoLite2 refresh, restart the app container to pick up the new databases:

systemctl --user restart cptv.service

Publishing

  • release.yml — fires on v* tag push (or manual workflow_dispatch). Builds linux/amd64 + linux/arm64 image, pushes latest / <version> tags to GHCR with provenance attestations and SBOM, then creates a GitHub Release with auto-generated notes.

GITHUB_TOKEN is scoped to contents: write + packages: write only. No MAXMIND_LICENSE_KEY is needed — GeoLite2 databases are not in the image.

Security + quality scanning

All run on every push and pull request (except OSSF Scorecard which runs weekly):

ToolWorkflowBlocks on
ruff (lint + format)lint.ymlany finding
pytesttest.ymlany failure
Lighthouse CItest.ymlaccessibility or best-practices < 0.90
pip-auditsecurity.ymlany HIGH/CRITICAL CVE
npm audit --audit-level=highsecurity.ymlany HIGH+ CVE
bandit -llsecurity.ymlany HIGH-severity finding
ESLint + eslint-plugin-securitysecurity.ymlany violation
trivy (container image)security.ymlany CRITICAL OS/library CVE
OSSF Scorecardscorecard.yml— (reports posture, uploads SARIF)

Scan jobs have no repository secrets and contents: read only — the principle of least privilege is enforced in the workflow permission blocks.

Dependabot watches pip, npm, github-actions, and docker ecosystems weekly.

Privacy

The server logs only the client IP — no user-agent, no cookies, no session IDs, no fingerprinting. Traceroute results are cached in Valkey for up to 1 hour keyed by IP (IPv4) or /64 prefix (IPv6), then expire. Nothing is sold, shared, or sent to third parties. Full statement in PLAN.md §13.

Repository

https://github.com/pdostal/cptv