Skip to main content

Landing page & reverse proxy

srv-web01 is two things at once: the landing page served at https://calypso.hevs.ch, and the Caddy reverse proxy through which every public ISC³ service is published — it terminates TLS for all the current sites (NetBird, Keycloak, the test sites). Stopping or rebuilding this container takes down every public service, not just the landing page.

Historically it was the first service running on the Proxmox migration, and the working proof of the inbound path from the Internet down to a container on rumba.

At a glance

RolePublic landing page and reverse proxy / TLS termination for every published service (list)
Public URLhttps://calypso.hevs.ch (HTTP redirects to HTTPS)
Guestsrv-web01 — unprivileged LXC, CT 100 on rumba
Address192.168.88.150 (static), also srv-web01 / srv-web01.calypso in DNS
Web serverCaddy 2.6.2 (Debian 13), automatic Let's Encrypt certificate
ContentVersion-controlled in provisioning/web/ in this repository
Resources2 cores, 1 GB RAM, 512 MB swap, 8 GB on local-lvm, starts on boot

Request path

Nothing on this path is new except the last two hops: the two dst-nat rules already existed, pointing at the reverse-proxy container of the pre-migration Rumba (192.168.94.13), and were simply retargeted.

Never disable the 80/443 dst-nat rules

Those two rules do double duty: besides publishing this page, they shadow the MikroTik's own web interface. If they are disabled (rather than retargeted), WebFig becomes reachable from the Internet on calypso.hevs.ch. Their comments on the router say so. Retarget, never disable.

TLS

Caddy obtains and renews the certificate for calypso.hevs.ch on its own — no certbot, no cron, no renewal hook. The first issuance was validated with tls-alpn-01 (Let's Encrypt connects back on 443, which the dst-nat forwards). Renewal happens automatically about a third of the way before expiry, and needs port 80 or 443 to stay reachable from the Internet.

The internal names (192.168.88.150, srv-web01, srv-web01.calypso) are served over plain HTTP only and deliberately have no certificate: they are not public names, so no CA can vouch for them. Use them for monitoring probes.

The admin gate

Admin panels behind this proxy are protected by a login, not by the client's address: oauth2-proxy 7.15.3 runs here on 127.0.0.1:4180 and Caddy asks it about every request to a protected path (forward_auth). It authenticates against the Keycloak realm isc and requires membership of the group rack-admins — an explicit, hand-managed group, not one of the institution groups, since isc is every member of the school.

Protectssso.isc-vs.ch/admin*, /realms/master* (Keycloak) and vault.isc-vs.ch/admin* (Vaultwarden)
Sign-in / callbackalways on sso.isc-vs.ch/oauth2/* — one registered redirect URI for every protected host
Sessioncookie on .isc-vs.ch, 8 h: signing in at one console covers the others
Managed fromprovisioning/oauth2-proxy/ (client + group, binary, config, unit)
Adding a protected pathimport admin_gate_endpoint once per vhost, then import admin_gate inside the handle block for the paths to protect

Why it exists. Source-IP protection forced split-horizon DNS on every public name, which broke repeatedly for reasons that had nothing to do with the service being protected (RouterOS caching, macOS AAAA queries, CNAME poisoning). A login removes that layer: the admin paths work from anywhere, over the ordinary public path, and no client needs a special DNS answer any more (retired 2026-08-04). One CCR record still points sso.isc-vs.ch at this container, but only because the rack's own services resolve the issuer for OIDC discovery and the router does not hairpin — details on the Keycloak page.

Break-glass

The gate authenticates against Keycloak, so a broken or misconfigured Keycloak would lock its own admin console. Each protected vhost therefore keeps a branch that lets requests arriving from 192.168.88.0/24 or the NetBird overlay through without the gate.

Getting a request to arrive from inside needs one manual step, and here is why. The public name resolves to the public address, so even with the VPN up your browser goes out to the Internet and comes back to Caddy as an outside client — the inside branch never matches. You have to send the request straight to the proxy while still using the name, because the name is what selects the vhost and matches the certificate. On a laptop that is one line in /etc/hosts:

sudo sh -c 'echo "192.168.88.150 sso.isc-vs.ch" >> /etc/hosts' # VPN must be connected
# … fix Keycloak at https://sso.isc-vs.ch/admin/master/console/ …
sudo sed -i '' '/192.168.88.150 sso.isc-vs.ch/d' /etc/hosts # remove it again

Manual on purpose. Distributing that answer automatically is exactly the split-horizon DNS that was retired above: it broke repeatedly, silently, and always at the moment it was needed. A hosts line takes ten seconds, is used perhaps once a year, and cannot misbehave behind your back.

This is not the reason to have a second admin

Break-glass covers Keycloak being broken. It does not cover you being unable to log in — a lost edu-ID second factor, a disabled account, a holiday. For that, rack-admins needs a second member; the group is what the gate checks, and it currently has one.

Two traps, both cost a deploy cycle
  1. Do not let oauth2-proxy request a groups scope. Its keycloak-oidc provider adds one by default, this realm has no client scope of that name, and Keycloak rejects the entire authorization request with Invalid scopes: openid email profile groups. scope is pinned in the config; the claim comes from the client's own group-membership mapper.
  2. cookie_secret must base64url-decode to 16/24/32 bytes. openssl rand -base64 32 emits + and /, which do not decode, so the 44-character string is taken literally and rejected ("must be 16, 24, or 32 bytes … but is 44 bytes"). Generate with the URL-safe alphabet.

Editing the page

Two ways in, depending on whether you want the change to survive.

The durable way — edit the repo, then deploy. provisioning/web/index.html is the source of truth, so this is what to use for anything you want to keep:

$EDITOR provisioning/web/index.html
provisioning/web/deploy.sh # pushes and restarts Caddy, then checks both URLs

The quick way — edit in place. Handy for experimenting; the file is /var/www/isc/index.html inside the container:

ssh root@srv-web01 # key-based, no password needed
vi /var/www/isc/index.html # served immediately, no restart required
In-place edits are overwritten by the next deploy

deploy.sh copies the repo's index.html over whatever is in the container. If you edit live and like the result, copy it back into provisioning/web/index.html and commit — otherwise the next deploy silently reverts it.

Access, in short: ssh root@srv-web01 uses your SSH key (password auth for root is refused, by design). The root password, in the secretzone, is only for the console — the Proxmox web UI under CT 100 → Console, or pct enter 100 from rumba. Static files need no restart; only Caddyfile changes do, and then it must be systemctl restart caddy, not reload.

Deploying and updating

Everything lives in provisioning/web/: index.html, the Caddyfile, and an idempotent deploy.sh that creates the container if it is missing and otherwise just refreshes the content.

provisioning/web/deploy.sh # defaults: PVE_HOST=rumba CTID=100
PVE_HOST=rumba CTID=100 provisioning/web/deploy.sh

The script ends by checking both the internal and the public URL, so a broken deploy is visible immediately. The ISC logo is taken from the local isc-logos checkout when present, and downloaded from the official repository otherwise.

Two traps worth remembering

  • systemctl reload caddy fails on this host. The Caddyfile sets admin off, and reload works through Caddy's admin API. Use systemctl restart caddy.
  • Do not stage files in /tmp on the Proxmox host. fs.protected_regular prevents even root from truncating a file left there by a different uid, so a second deploy fails with a confusing "Permission denied". The script stages in /root/.cache/isc-web-deploy instead.

Content

Intentionally minimal: the ISC logo, the filière name, the five majors as accent bars in their official colours, and a link to hevs.ch/isc. Because the page is publicly reachable it carries no internal hostnames, addresses or infrastructure detail — keep it that way when editing.

Adding another site

srv-web01 is the reverse proxy for everything, so a new site means a new backend container plus a vhost here — never a router change. Each site gets its own container so they restart, break and get rebuilt independently.

# 1. backend container (own IP, plain HTTP, no certificate)
provisioning/web/deploy-static-site.sh 105 srv-foo 192.168.88.155/24 foo.isc-vs.ch "foo" "rgb(168, 144, 192)"

# 2. add the vhost to provisioning/web/Caddyfile
# foo.isc-vs.ch { reverse_proxy 192.168.88.155:80 }

# 3. push the proxy config; Caddy fetches the certificate by itself
provisioning/web/deploy.sh
Only enable a vhost once its public DNS record exists

Caddy tries to obtain a certificate the moment a vhost loads. For a name that does not resolve, the attempt fails and retries, which burns Let's Encrypt's failed validation rate limit (5 per hostname per hour). Keep such vhosts commented out until DNS is in place. All four test names now resolve, so all four vhosts are enabled.

Beware of one measurement trap when checking whether a record is live: some networks (the HES-SO one included) intercept port 53, so dig @some.server is silently answered by the local resolver from cache — including negative cache entries for names probed before they existed. Verify with DNS-over-HTTPS instead, which cannot be intercepted:

curl -s -H 'accept: application/dns-json' \
"https://cloudflare-dns.com/dns-query?name=foo.isc-vs.ch&type=A"

Current exposed sites (on Internet)

Public nameBackendContainerState
calypso.hevs.chlocal files in srv-web01CT 100live, own certificate
test.isc-vs.ch192.168.88.151CT 101 (srv-test)live, own certificate
test1.isc-vs.ch192.168.88.152CT 102 (srv-test1)live, own certificate
test2.isc-vs.ch192.168.88.153CT 103 (srv-test2)live, own certificate
test3.isc-vs.ch192.168.88.154CT 104 (srv-test3)live, own certificate
vpn.isc-vs.ch192.168.88.159VM 109 (srv-netbird)live, own certificate — NetBird dashboard, API, gRPC and relay
sso.isc-vs.ch192.168.88.160CT 110 (srv-keycloak)live, own certificate — Keycloak; /admin* behind the admin gate
vault.isc-vs.ch192.168.88.162CT 111 (srv-vaultwarden)live, own certificate — Vaultwarden; /admin* behind the admin gate
wiki.isc-vs.chnone — 301 onlyCT 100live, own certificate — the retired DokuWiki

The test pages are noindex, nofollow, carry no data and state plainly that they are demonstrations.

The retired wiki

wiki.isc-vs.ch served a DokuWiki on Hannibal until August 2026; its content is this documentation now. The name kept its audience, so it is a vhost here that does nothing but redir … permanent to docs.isc-vs.ch.

Every old URL was /doku.php?id=<namespace>:<page>userewrite was never turned on — so a map {query.id} block translates the ~28 ids that had a successor into their new path, and everything else (the secretzone namespace, DokuWiki's own manual, media fetches) lands on the home page. That mapping is the whole vhost, in provisioning/web/Caddyfile; deploy it the usual way with provisioning/web/deploy.sh.

DNS for isc-vs.ch

isc-vs.ch is ISC's own zone at Infomaniak (unlike hevs.ch — see the DNS prerequisite), so service names can be created without involving SInf. Two ways to make new names self-service:

  • A wildcard record *.isc-vs.ch153.109.29.7. Then any new vhost works immediately with a per-name Let's Encrypt certificate over HTTP-01, and no DNS work is ever needed again. Existing specific records (learn, rumba, …) keep precedence, so Moodle is unaffected.
  • An API token (dns:read, dns:write, created at manager.infomaniak.com/v3/ng/accounts/token/list) if records should be scripted, or if a wildcard certificate is wanted — that needs the DNS-01 challenge, and therefore API access.

Role in the target architecture

This container is deliberately positioned to become the srv-* reverse proxy of the target architecture: Caddy already terminates TLS for the only public name, so publishing further services means adding a vhost and a reverse_proxy line here rather than opening new ports on the router.