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
| 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.isc3 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
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 gate | User gate | |
|---|---|---|
| Question | membership of role-rack-admins — explicit and hand-managed, because isc is every member of the school | membership of isc — derived from edu-ID claims at every login, so there is no list to maintain — or guests, the handful of local accounts |
| Port | 127.0.0.1:4180 | 127.0.0.1:4181 |
| Protects | sso.isc-vs.ch/admin* and /realms/master* (Keycloak) | pdf.isc-vs.ch (Stirling-PDF) |
| Sign-in / callback | always on sso.isc-vs.ch/oauth2/* — one registered redirect URI for every protected host | on the gated host itself, so the sign-in redirect is relative |
| Session | cookie on .isc-vs.ch, 8 h, identity only | cookie on .isc-vs.ch, 12 h, identity only |
| Caddy snippets | import admin_gate_endpoint once per vhost, then import admin_gate inside the handle block for the paths to protect | import 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.
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.
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.
- 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 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:8080 | VM 105 (srv-docker01) | live, own certificate — Docker host behind the user gate |
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 |
wiki.isc-vs.ch | none — 301 only | CT 100 | live, own certificate — the retired DokuWiki |
pdf.isc-vs.ch | 192.168.88.168 | CT 117 (srv-stirling) | live, own certificate — Stirling-PDF behind the user gate |
tools.isc-vs.ch | 192.168.88.169 | CT 118 (srv-it-tools) | live, own certificate — IT-Tools, open |
cyberchef.isc-vs.ch | 192.168.88.170 | CT 119 (srv-cyberchef) | live, own certificate — CyberChef, open |
stats.isc-vs.ch | 192.168.88.174:3000 | VM 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.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.
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.