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.

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.isc3 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

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.isc3) 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 gates

Protected paths behind this proxy are guarded by a login, not by the client's address: oauth2-proxy 7.15.3 runs here and Caddy asks it about every request to a protected path (forward_auth). Both instances authenticate against the Keycloak realm isc.

There are two, because oauth2-proxy has one global authorisation rule per instance and the two gates ask different questions. They share only the binary — separate ports, clients, cookies and allowed groups, run as oauth2-proxy@admin and oauth2-proxy@users from one systemd template unit.

Admin gateUser gate
Questionmembership of role-rack-admins — explicit and hand-managed, because isc is every member of the schoolmembership of isc — derived from edu-ID claims at every login, so there is no list to maintain — or guests, the handful of local accounts
Port127.0.0.1:4180127.0.0.1:4181
Protectssso.isc-vs.ch/admin* and /realms/master* (Keycloak)pdf.isc-vs.ch (Stirling-PDF)
Sign-in / callbackalways on sso.isc-vs.ch/oauth2/* — one registered redirect URI for every protected hoston the gated host itself, so the sign-in redirect is relative
Sessioncookie on .isc-vs.ch, 8 h, identity onlycookie on .isc-vs.ch, 12 h, identity only
Caddy snippetsimport admin_gate_endpoint once per vhost, then import admin_gate inside the handle block for the paths to protectimport user_gate_endpoint, then import user_gate

Managed from provisioning/oauth2-proxy/ (clients, binary, configs, unit) — ./deploy-oauth2-proxy.sh for the admin gate, INSTANCE=users ./deploy-oauth2-proxy.sh for the user gate.

The user gate

Added 2026-08-21 with the utility tools. It exists because a service that does work on a visitor's behalf — receiving files, spending CPU — needs to know who asked, while a page that computes nothing in the container needs no login at all.

isc is filled from edu-ID claims at every login, so the gate admits ISC staff and students with no list to maintain, and someone who loses their affiliation loses access at their next login with no action here. The roster is not the criterion: it governs machine access, and there are far more ISC students than there are roster lines. It is, however, how an exception is granted — a person added to roster.csv gets staff, which carries the same realm role (September 2026).

hevs was dropped 2026-09-06. The gate admitted the whole of HES-SO Valais until then; the rest of the HEI now counts as external for the published tools, the future GitLab being the case that decided it. The admission gate in the realm enforces the same rule one step earlier, so a HEI login is refused with a sentence before this gate ever sees it. The two lists say the same thing on purpose — change both or neither.

People with no edu-ID are covered by the group guests, also listed in this gate's allowed_groups. They are ordinary users of realm isc (local accounts), so they arrive at the same Keycloak page as everyone else and the group opens nothing beyond the vhosts on this gate.

Until 2026-08-28 those accounts lived in an htpasswd file read by oauth2-proxy itself, tagged pdf-local. That route authenticated before Keycloak, so it had no password policy, no brute-force protection and no trace in the realm's logs — and it forced skip_provider_button = false, which put a second sign-in page in front of the themed Keycloak one. Both gates now have skip_provider_button = true and one page.

The session cookie must stay small — it is not a place for tokens

Both instances run with session_cookie_minimal, so the cookie carries the session identity and not the OAuth tokens. These gates proxy nothing (the upstream is a static 202) and set no Authorization header, so the tokens were dead weight — dead weight that grows every time the realm gains a role or a group. Adding the isc-access role on 2026-09-06 pushed the chunked cookie past the 8 KB header limit of Stirling-PDF's Jetty, which answered 431 Request Header Fields Too Large to a user the gate had just admitted with a 202 in its own log. Read the gate's log before suspecting the gate: the verdict is there, and a 431 means the request got past it.

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. The sso.isc-vs.ch vhost therefore keeps a branch that lets requests arriving from the NetBird overlay (100.65.0.0/16) through without the gate. The LAN 192.168.88.0/24 was part of that branch until 2026-08-18, when it was dropped on a security-audit finding: the CCR src-NATs all legacy WireGuard traffic to 192.168.88.1, so the /24 admitted every legacy VPN peer unauthenticated. NetBird addresses stay because each is 1:1 to a user and device.

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' # NetBird 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

The VPN has to be NetBird — the legacy WireGuard arrives src-NATed as 192.168.88.1 and no longer passes (see above). If NetBird itself is down too, the last resort is Keycloak direct: from rumba, pct exec 110 … or an SSH tunnel to 192.168.88.160:8080.

The step is manual on purpose: distributing that answer automatically is the split-horizon DNS that was retired above, which broke repeatedly and silently. A hosts line takes ten seconds and is used perhaps once a year.

Break-glass is not a substitute for a second admin

It covers Keycloak being broken, not you being unable to log in — a lost edu-ID second factor, a disabled account, a holiday. role-rack-admins is what the gate checks; giving it a second member is an open item.

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 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.154:8080VM 105 (srv-docker01)live, own certificate — Docker host behind the user gate
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
wiki.isc-vs.chnone — 301 onlyCT 100live, own certificate — the retired DokuWiki
pdf.isc-vs.ch192.168.88.168CT 117 (srv-stirling)live, own certificate — Stirling-PDF behind the user gate
tools.isc-vs.ch192.168.88.169CT 118 (srv-it-tools)live, own certificate — IT-Tools, open
cyberchef.isc-vs.ch192.168.88.170CT 119 (srv-cyberchef)live, own certificate — CyberChef, open
stats.isc-vs.ch192.168.88.174:3000VM 121 (srv-stats)live, own certificate — Umami; /isc.js and /api/pulse open, the rest behind the admin gate

The test, test1 and test2 pages are noindex, nofollow, carry no data and state plainly that they are demonstrations. test3 is no longer one of them — it is the Docker host.

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.
A name probed before it existed stays absent for an hour

The zone's SOA minimum is 3600, so any resolver that answered NXDOMAIN for a new name keeps that answer for up to an hour after the record goes live — including the resolver a laptop or a campus network happens to use, which no action here can flush. Measured on pdf.isc-vs.ch (2026-08-21): the same resolver answered correctly for sso and rumba in the same zone while still refusing the new name. Verify a record with DNS-over-HTTPS (curl -s -H 'accept: application/dns-json' \ "https://dns.google/resolve?name=foo.isc-vs.ch&type=A"), not with the local resolver, and reach the service in the meantime by its internal name or with curl --resolve.

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.