Skip to content

HTTPS proxy for app services - #440

Merged
krokicki merged 24 commits into
mainfrom
app-service-https-proxy
Sep 4, 2026
Merged

krokicki merged 24 commits into
mainfrom
app-service-https-proxy

Conversation

@krokicki

@krokicki krokicki commented Aug 31, 2026 •

Copy link
Copy Markdown
Member

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_domain unset (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-server has 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_url caches the URL a service publishes, written through where the job detail endpoint already reads it. Migration c3e9b7f41a28, one nullable column.
  • GET /api/apps/resolve maps a job-<id>.<zone> hostname to a validated host: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 it internal.
  • fileglancer/apps/serviceproxy.py holds the URL translation and the upstream validation.
  • Successful resolutions sit in a bounded TTL cache and the endpoint is excluded from per-request access logging, reporting aggregate totals once a minute instead.

Frontend: no changes. JobDetail.tsx renders whatever service_url contains.

New settings, all empty by default

apps:
  service_proxy_domain: services.int.janelia.org    # public zone browsers hit
  service_proxy_upstream_zone: int.janelia.org      # confines name upstreams
  service_proxy_upstream_networks:                  # confines address upstreams
    - 10.20.0.0/16

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_url is a file the user's own job writes, and an auto_url: false app writes it deliberately. Review found that a user could plant http://127.0.0.1:8989/ and aim nginx at the app server's own loopback, or 169.254.169.254 for 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/8 in service_proxy_upstream_networks still will not make loopback dialable.

internal; and the main-block return 404 are load-bearing. The resolve endpoint discloses a running service's host:port for any job id, and job ids are sequential. Both nginx directives keep it off the network; docs/ServiceProxy.md says 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_domain is set, and that needs a wildcard DNS record, a *.services.int.janelia.org certificate 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:

  • A fileglancer release containing this PR has to be cut and pinned in the hub's pixi.toml (currently ==3.1.0, which has neither the endpoint nor the settings). The nginx block 502s until then.
  • The existing *.int.janelia.org certificate does not cover job-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

  • An IPv6 literal can never be an upstream: the authority pattern forbids colons in the host, so IPv6 entries in service_proxy_upstream_networks parse but match nothing.
  • A service that manages its own URL (auto_url unset) 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.
  • The upstream cache's TTL is the window in which a just-stopped job can still be proxied. Kept to seconds deliberately, since compute-node ports get recycled.

@StephanPreibisch @JaneliaSciComp/fileglancer

krokicki and others added 17 commits August 31, 2026 11:59
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.
@krokicki

Copy link
Copy Markdown
Member Author

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.
Base automatically changed from json-ecs-logging to main September 2, 2026 16:14
@mkitti

mkitti commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

The last hop is still cleartext — and two existing apps break on it

This encrypts browser→nginx. The nginx→node hop stays plain HTTP, so ?tkn= still crosses the cluster network in the clear, and docs/ServiceProxy.md reads as though the whole path is TLS.

This is a prerequisite for setting apps.service_proxy_domain, not a follow-up. Two Fileglancer apps already serve TLS with self-signed certs behind Caddy, and both already write https:// to $SERVICE_URL_PATH:

  • JaneliaSciComp/marimo_ai_sandbox — container/caddy-lib.sh generates an openssl self-signed leaf (SANs: node FQDN, short hostname, localhost, host IPs); container/https-wrap.sh:262 publishes https://<host>:<port>/?access_token=…. It bypasses auto_url specifically so Fileglancer doesn't publish the backend's plain-HTTP port instead of Caddy's TLS one.
  • JaneliaSciComp/mhs-spec-review#1 — Caddy tls internal on a fixed port 8443, publishes https://$(hostname -f):8443/.

Since upstream_from_service_url keeps only host:port, enabling the proxy makes nginx do proxy_pass http://<node>:8443 at a TLS listener, and Caddy answers a plaintext request to an HTTPS port with a 400. Both apps break the day the proxy is turned on. The auto_url-unset bullet in the residual-risks section treats self-managed URLs as a staleness footnote; the dropped scheme is the more immediate consequence and should be named there.

Certificates are a separate discussion (issue to follow). The scheme plumbing itself is small and needs no PKI decisions:

  1. Stop discarding the scheme. upstream_from_service_url returns bare netloc. Apps already declare it (AuthoringApps.md:602, and the two above), so it's a free per-job signal. Return it too, validated against a hard {http, https} enumeration. Per-job matters: TensorBoard has no TLS option, so no global switch works.

  2. A second header, not a wider regex. X-Fg-Upstream-Scheme: https alongside the existing header. _UPSTREAM_RE is the injection gate for proxy_pass interpolation — an enumerated scheme in its own header keeps it as strict as it is today.

    Watch the cache: cached_upstream stores a bare str and the hit path returns it directly. Add the scheme on the miss path only and every cached hit downgrades to http, i.e. an HTTPS service flaps between encrypted and cleartext on a 10s cycle. The cached value has to carry both.

  3. Count plaintext upstreams; don't add a policy setting. No new config — the behavior should simply be "use the scheme the app published." An operator still wants to know how much of the traffic is cleartext, and record_resolve already aggregates outcomes into one log line a minute, so splitting hits into hit / hit_plaintext answers that at zero risk of breaking a launch.

    I floated an apps.service_proxy_upstream_tls: allow | require setting here earlier. Withdrawn: require would 403 every non-HTTPS upstream, which is exactly what we don't want given TensorBoard has no TLS option at all, and with require gone the setting has one legal value and stops earning its place in a config surface this PR already grows by three.

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: mhs-spec-review pins its OAuth callback to a fixed host and port, so republishing it at a job subdomain likely breaks the callback regardless of the scheme. There's currently no way for a manifest to say "don't proxy me." Probably its own issue.

@krokicki

krokicki commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@mkitti Per our discussion, we'll treat support for self-signed certs as a separate nice to have.

allison-truhlar and others added 4 commits September 2, 2026 14:13
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>
krokicki and others added 2 commits September 4, 2026 09:19
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>
@krokicki
krokicki merged commit abc1adf into main Sep 4, 2026
4 checks passed
@krokicki
krokicki deleted the app-service-https-proxy branch September 4, 2026 13:55
mkitti added a commit to JaneliaSciComp/janelia-mojo-sandbox that referenced this pull request Sep 7, 2026
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.
mkitti added a commit to JaneliaSciComp/janelia-mojo-sandbox that referenced this pull request Sep 7, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants