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
| Role | Public landing page and reverse proxy / TLS termination for every published service (list) |
| Public URL | https://calypso.hevs.ch (HTTP redirects to HTTPS) |
| Guest | srv-web01 — unprivileged LXC, CT 100 on rumba |
| Address | 192.168.88.150 (static), also srv-web01 / srv-web01.calypso in DNS |
| Web server | Caddy 2.6.2 (Debian 13), automatic Let's Encrypt certificate |
| Content | Version-controlled in provisioning/web/ in this repository |
| Resources | 2 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.
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.
| Protects | sso.isc-vs.ch/admin*, /realms/master* (Keycloak) and vault.isc-vs.ch/admin* (Vaultwarden) |
| Sign-in / callback | always on sso.isc-vs.ch/oauth2/* — one registered redirect URI for every protected host |
| Session | cookie on .isc-vs.ch, 8 h: signing in at one console covers the others |
| Managed from | provisioning/oauth2-proxy/ (client + group, binary, config, unit) |
| Adding a protected path | import 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.
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.
- Do not let oauth2-proxy request a
groupsscope. Itskeycloak-oidcprovider adds one by default, this realm has no client scope of that name, and Keycloak rejects the entire authorization request withInvalid scopes: openid email profile groups.scopeis pinned in the config; the claim comes from the client's own group-membership mapper. cookie_secretmust base64url-decode to 16/24/32 bytes.openssl rand -base64 32emits+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
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 caddyfails on this host. TheCaddyfilesetsadmin off, and reload works through Caddy's admin API. Usesystemctl restart caddy.- Do not stage files in
/tmpon the Proxmox host.fs.protected_regularprevents 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-deployinstead.
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
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 name | Backend | Container | State |
|---|---|---|---|
calypso.hevs.ch | local files in srv-web01 | CT 100 | live, own certificate |
test.isc-vs.ch | 192.168.88.151 | CT 101 (srv-test) | live, own certificate |
test1.isc-vs.ch | 192.168.88.152 | CT 102 (srv-test1) | live, own certificate |
test2.isc-vs.ch | 192.168.88.153 | CT 103 (srv-test2) | live, own certificate |
test3.isc-vs.ch | 192.168.88.154 | CT 104 (srv-test3) | live, own certificate |
vpn.isc-vs.ch | 192.168.88.159 | VM 109 (srv-netbird) | live, own certificate — NetBird dashboard, API, gRPC and relay |
sso.isc-vs.ch | 192.168.88.160 | CT 110 (srv-keycloak) | live, own certificate — Keycloak; /admin* behind the admin gate |
vault.isc-vs.ch | 192.168.88.162 | CT 111 (srv-vaultwarden) | live, own certificate — Vaultwarden; /admin* behind the admin gate |
wiki.isc-vs.ch | none — 301 only | CT 100 | live, 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.ch→153.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 atmanager.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.