Repository navigation
HTTPS proxy for app services - #440
Conversation
Per-job wildcard subdomains resolved by nginx auth_request against a new /api/apps/resolve endpoint, so no proxied bytes pass through Python. Gated on an empty-by-default apps.service_proxy_domain setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The zone is services.int.janelia.org, an alias for the same host, covered by a new *.services wildcard cert. A wildcard DNS record can sit at any level (RFC 4592), so no sub-zone delegation is needed -- one record in the parent zone is enough. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The proxy-resolve endpoint runs per proxied HTTP request, which rules out the per-user worker RPC and NFS read that currently fetch service_url. Write it through where it is already read, on the job detail endpoint. Also adds the (currently inert) apps.service_proxy_domain setting that a later task will use to decide whether to publish a proxied subdomain URL instead of the service's raw http://host:port address.
Pure translation between a job's published http://node:port URL and its https://job-<id>.<domain> proxy form, plus the strict host:port validation that guards what gets interpolated into the proxy's proxy_pass target. Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com>
The case-sensitive rename left 'Apps.Example.Org' in the mixed-case job_id_from_host test, so the host no longer matched the domain constant.
Maps a job-<id>.<zone> hostname to the service's host:port upstream for a reverse proxy's auth_request. Refuses anything that is not a RUNNING service job with a well-formed cached upstream.
When apps.service_proxy_domain is set, the job detail endpoint returns https://job-<id>.<zone> with the path and query carried over verbatim, so the service's own token still authenticates. Unset, nothing changes.
Final-review fixes for the app service HTTPS proxy: job_id_from_host bounded to 9 digits so an absurd digit run 403s instead of overflowing int()'s conversion limit into a 500, and upstream_from_service_url now rejects loopback/link-local/reserved address literals and supports an optional apps.service_proxy_upstream_suffix allowlist, since the upstream comes from a file the user's own job wrote and would otherwise let it aim the proxy at the web host itself. Also logs the previously-swallowed service-URL cache-write failure at debug, and adds a publish-then-resolve round-trip test closing the seam between the two halves of that invariant.
Documents why internal; on /_fg_resolve matters, corrects the design doc and docstring claim that the netloc regex is "the SSRF and header-injection gate" (it only constrains shape; the destination is now bounded by the loopback/link-local rejection and the upstream-suffix allowlist), narrows the "unchanged byte for byte" claim to the published URL rather than database traffic, and adds a Residual risks section for deployers.
A plain endswith let a sibling zone impersonate the allowed one: with service_proxy_upstream_suffix set to nodes.example.org, the host evil-nodes.example.org satisfied the check. An operator who omitted the leading dot the docs showed silently got a prefix-guessing game instead of an allowlist, weakening the guard that confines the proxy to cluster nodes. Match on label boundaries instead, so both spellings of the zone mean the same thing, the zone's own name is allowed, and a trailing root dot -- which is insignificant in DNS -- no longer fails a legitimate FQDN.
Private, CGNAT and public addresses are now allowed. Some clusters publish a node's IP rather than its hostname, and an address bound to a routable interface is already reachable directly by any cluster user, so proxying to it grants no privilege the user did not already have. Blocking RFC 1918 would have broken those deployments to prevent nothing. What must stay refused is narrower: loopback and the unspecified address, which cover anything bound to localhost (the app server itself listens on 127.0.0.1:8989), and link-local, which covers instance metadata. Multicast and reserved ranges go too, not as a threat but because they are never a service. The upstream zone now applies to hostnames only, so setting it cannot break a service that publishes an address instead of a name.
It was added to AppsSettings during review follow-up and never reached the template, so the only place an operator looks for settings did not mention the one control that confines where the proxy may dial.
service_proxy_upstream_suffix becomes service_proxy_upstream_zone: after the label-boundary fix it is not a suffix match, and calling it one invited the raw endswith it replaced. Adds service_proxy_upstream_networks as its companion. The zone can only govern upstreams published as hostnames, since an address has no zone to match, which left address-publishing deployments with no way to confine the proxy. The two settings divide that space, and both are empty by default. Bad CIDR entries are refused at startup: the failure mode is that every address upstream stops resolving, which is much easier to diagnose from a refusal to boot than from services that silently never connect. Records one limitation rather than papering over it: an IPv6 literal can never be an upstream, because the authority pattern forbids colons in the host, so IPv6 network entries parse but match nothing.
The reverse proxy resolves once per proxied HTTP request, so a JupyterLab page load was dozens of database reads and dozens of access-log lines that carried no user, no useful path and no useful duration -- and skewed the duration percentiles for endpoints doing real work. Successful resolutions now sit in a bounded TTL cache (10s, 1024 entries), which collapses a page load's burst to about one read per service per TTL per worker. Refusals are deliberately not cached, so a service starts resolving the moment it publishes its URL rather than after a delay. The TTL is short on purpose: it is the window in which a job that has just stopped can still be proxied, and the RUNNING check it bypasses exists because compute-node ports get recycled. A test asserts that window rather than leaving it implicit. The endpoint is excluded from per-request access logging, checked before the session-cookie lookup so a proxied request skips that signature work too -- on a cookie an app subdomain never sends. It logs running totals once a minute instead, counting hits, misses and refusals by reason.
|
Deployment half: JaneliaSciComp/fileglancer-hub#15 — the nginx vhost, with the same deployment checklist. Neither PR does anything without the other. |
The fixture created a SQLAlchemy engine per settings build and never disposed it. Windows keeps a lock on an open sqlite file, so the temp_dir fixture's rmtree failed with WinError 32 and every test in the file errored at teardown -- 21 errors on windows-latest while all 918 tests passed. Track the engines and dispose them in the fixture's own teardown, which reverse dependency order places before that rmtree.
The last hop is still cleartext — and two existing apps break on itThis encrypts browser→nginx. The nginx→node hop stays plain HTTP, so This is a prerequisite for setting
Since Certificates are a separate discussion (issue to follow). The scheme plumbing itself is small and needs no PKI decisions:
nginx half: JaneliaSciComp/fileglancer-hub#15. Its per-app verification checklist lists only the five shipped service apps — both Caddy apps should be on it, since they're the only ones that exercise the TLS path at all. One gap this exposes beyond TLS: |
|
@mkitti Per our discussion, we'll treat support for self-signed certs as a separate nice to have. |
Was getting this error when trying to upload to Pypi: Invalid distribution metadata: '2.5' is not a valid metadata version. This was caused by setuptools/hatch writing the package metadata with version 2.5, but the old twine version could only handle up to version 2.3. Needed to update to the next major release via twine = ">=7.0.0,<8", and then sync the pypi versions.
Regenerated pixi.lock (only conflict) against the merged pyproject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Job ids are a small global sequence, so a bare job-<id> label let anyone who could reach the proxy sweep job-1..job-500 and find every running service on the instance -- and reach any of them that does not enforce its own token, which is opt-in per manifest and absent entirely from apps like TensorBoard. The label now carries a MAC over the id, keyed by session_secret_key: job-12-k7m2q9xr.services.example.org. Signing rather than storing a random key keeps the id in the hostname, so resolution stays one indexed read with no new column, and the MAC is verified before the database is touched at all. Eight base32 characters is 40 bits; guessing is online-only against a proxy that answers 403, so a sweep takes decades at 10k requests/second. session_secret_key becomes required when service_proxy_domain is set. An unset key is generated at random per process, so under `uvicorn --workers N` each worker would sign with a different key and most proxied requests would be refused -- better to fail at startup where the cause is obvious. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Following a link to a service whose job has stopped is the common way to reach the proxy's refusal, not an error case, and nginx's stock 403 page names neither the service nor why it is gone. The page ships with Fileglancer at /api/apps/service-unavailable and the reverse proxy maps its 403 there with `error_page`, so every deployment gets it without writing HTML into an nginx html directory. It answers 503, which is what actually happened: a refusal means the job is gone, still starting, or was never reachable, and none of that is worth distinguishing to a browser. Nothing is fetched from the page -- the proxy routes everything through auth_request, so a stylesheet referenced from it would be refused by the same check that produced it. Verified against nginx rather than by reading its documentation, which caught the first version outright: `proxy_pass` may not carry a URI part inside a named location, so the page location is a prefix location marked `internal`. A live request through the real vhost against a stub resolver returns the page with a 503, an unsigned hostname does not match the block at all, and /_fg_unavailable is not reachable directly. The example MAC in the docs and docstrings was not a valid base32 string, which matters because an operator copies it into a server_name regex. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
terminal-http.sh assumed Fileglancer's own "auto_url" mechanism would detect the listening port and publish an externally-reachable URL on its own, so it deliberately never wrote $SERVICE_URL_PATH. Per JaneliaSciComp/fileglancer#440 (docs/ServiceProxy.md), that's not how it works: Fileglancer's HTTPS proxy only ever republishes over HTTPS whatever raw URL a job writes to that file itself -- it never detects a port on its own. So under Fileglancer this script published nothing, at all, ever. Now writes the same classroom:<token>@host:port URL scheme terminal-wrap.sh already uses successfully, using Fileglancer's own minted FG_HOSTNAME when present (matches what its proxy validates a published upstream against, unlike our own `hostname -f`). Found while live-diagnosing a stuck fg-311 mojo-terminal-http job with no service_url ever appearing despite ttyd confirmed listening.
Fileglancer's HTTPS proxy (JaneliaSciComp/fileglancer#440) hard-refuses any published upstream whose netloc isn't a bare `host:port` (fileglancer/apps/serviceproxy.py's _UPSTREAM_RE) -- userinfo (like our embedded classroom:<token>@) is deliberately rejected rather than stripped, since the value gets interpolated into an nginx proxy_pass directive. A userinfo-bearing service_url got a 403 from the resolve endpoint, which nginx's `error_page 403 = /_fg_unavailable` turns into a 503 -- even though the backend itself was healthy the whole time. ttyd has no query-string-token auth, only HTTP Basic Auth, so the fix is to publish a clean http://host:port/ and keep printing the classroom/ token credentials separately in the job log for the student to enter when the browser's Basic Auth prompt appears -- the same "prompted for Basic auth without the embedded credential" behavior terminal-wrap.sh's HTTPS variant already documents. Found by inspecting fileglancer/apps/serviceproxy.py directly after a live fg-313 job's published URL showed a 503 despite ttyd confirmed listening and reachable.
Publishes each running app service at a per-job HTTPS subdomain —
https://job-123.services.int.janelia.org/lab?token=abc— which nginx proxies to the compute node. Stacked on #437.Deployment half: JaneliaSciComp/fileglancer-hub#15 (the nginx vhost). Both must land; each is inert without the other.
This ships dark. With
apps.service_proxy_domainunset (the default), the published URL is byte-for-byte what it is today. Nothing changes until the DNS and certificate work below lands.Why
A service job publishes
http://<node>:<port>/...?tkn=<token>and we render that as the "Open Service" link. Two problems: Fileglancer is served over HTTPS, so the link is mixed content with the token in cleartext, and the hostname and port change on every launch, so the link is neither memorable nor shareable.Reachability is explicitly not a problem being solved — users can already reach compute nodes directly, and still can.
How
nginx terminates TLS for a wildcard zone and asks Fileglancer which upstream a hostname maps to, via
auth_request. Fileglancer's whole contribution is one endpoint, so no proxied bytes pass through Python — which matters because one of the shipped apps is a noVNC remote desktop, i.e. a video stream.Subdomains rather than a path prefix because a prefix would need every app told its base path, and
openvscode-serverhas no documented subpath configuration. That trades a one-time DNS and certificate request for an open-ended per-app debugging tail.Design doc:
docs/superpowers/specs/2026-08-28-app-service-https-proxy-design.md. Operator guide:docs/ServiceProxy.md.What's in it
JobDB.service_urlcaches the URL a service publishes, written through where the job detail endpoint already reads it. Migrationc3e9b7f41a28, one nullable column.GET /api/apps/resolvemaps ajob-<id>.<zone>hostname to a validatedhost:port, or 403s. Unauthenticated by design: the session cookie is scoped to the Fileglancer hostname and never reaches an app subdomain, so there is no user to check. nginx marks itinternal.fileglancer/apps/serviceproxy.pyholds the URL translation and the upstream validation.Frontend: no changes.
JobDetail.tsxrenders whateverservice_urlcontains.New settings, all empty by default
The two upstream settings divide the space rather than overlapping: a hostname has no address to check without a DNS lookup (which this path must never do — it runs once per proxied request), and a bare address has no zone to match.
Security notes worth a reviewer's attention
The upstream is user-controlled.
service_urlis a file the user's own job writes, and anauto_url: falseapp writes it deliberately. Review found that a user could planthttp://127.0.0.1:8989/and aim nginx at the app server's own loopback, or169.254.169.254for instance metadata. Fixed by refusing hosts that reach the Fileglancer host itself — loopback, unspecified, link-local, multicast, reserved — plus the two optional allowlists above.Private and public node addresses are deliberately allowed: an address bound to a routable interface is already reachable directly by any cluster user, so proxying to it grants nothing new. Blocking RFC 1918 would break real deployments to prevent nothing.
The always-refused set is not an allowlist opt-out. Naming
127.0.0.0/8inservice_proxy_upstream_networksstill will not make loopback dialable.internal;and the main-blockreturn 404are load-bearing. The resolve endpoint discloses a running service'shost:portfor any job id, and job ids are sequential. Both nginx directives keep it off the network;docs/ServiceProxy.mdsays not to remove either.Per-job subdomains make a running service cheaper to find —
https://job-<small number>.<zone>/instead of port-scanning nodes — and the vhost is reachable from wherever the Fileglancer HTTPS host is. The service's own token is still the only credential, unchanged from today, but worth weighing before exposing the vhost widely.Turning it on
Nothing changes until
apps.service_proxy_domainis set, and that needs a wildcard DNS record, a*.services.int.janelia.orgcertificate and the nginx vhost first — none of which live in this repo.The deployment checklist lives in JaneliaSciComp/fileglancer-hub#15, kept in one place so it does not drift, and because that PR stays open until the deploy is finished while this one merges as soon as review passes.
Two ordering constraints worth knowing here, since they are about this PR rather than about nginx:
fileglancerrelease containing this PR has to be cut and pinned in the hub'spixi.toml(currently==3.1.0, which has neither the endpoint nor the settings). The nginx block 502s until then.*.int.janelia.orgcertificate does not coverjob-1.services.int.janelia.org: a wildcard matches exactly one label, and browsers enforce that strictly. A new certificate is required, not just a DNS record.Testing
1011 backend tests pass. The suite covers the resolve endpoint's refusal paths, the raw-vs-proxied URL invariant end to end, adversarial upstream inputs (CRLF, userinfo, bracketed IPv6, out-of-range ports, homograph hosts, loopback and metadata targets), the cache's staleness window, and the access-log exclusion.
Known limitations, recorded rather than hidden
service_proxy_upstream_networksparse but match nothing.auto_urlunset) should write its URL file exactly once. The cached upstream refreshes only while someone has the job detail page open, so a URL that changes mid-run can go stale.@StephanPreibisch @JaneliaSciComp/fileglancer