feat(appgate): apps with their own login can skip the node login
Demo images / Build & push demo images (push) Successful in 3m33s

Some apps carry a complete account system and are broken by an upstream
challenge: git clients speak basic-auth (not browser cookies), and a
BTCPay checkout link handed to a customer must open for that customer.
Both were behind the gate's login page — the "non-browser clients need an
access token" gap disclosed in five consecutive releases.

- New manifest port policy `auth: open`: the daemon still fronts the port
  exactly like `gated` (loopback pin, external binds, frame-header fixes,
  app-down retry page, Tor upstream) but serves it without the login
  challenge. Requires auth_rationale, same burden of proof as `none`.
  Gitea 3001 and BTCPay 23000 declare it.
- Runtime operator override per app (security.set-app-gate → app-configs/
  <id>.json "gateEnabled"), surfaced as Settings → app → Access control.
  Wins over the manifest in both directions and applies on the next
  request — no restart, and it works today on catalog-covered apps whose
  signed manifest still says `gated`.
- The gate resolves policy per-request from the live port map, so a
  toggle takes effect without waiting for the 60s rebind sweep. "Off"
  never releases the port: gated apps are loopback-pinned, so releasing
  would strand them, not open them.
- security.app-gate-status now reports gate_enabled + any override.
- New guard test pins the `auth: open` set (both entries reviewed); the
  `auth: none` count moves 25 → 26, absorbing pre-existing drift from the
  phoenixd onboarding (loopback JSON API with its own generated password).
- Docs: the manifest spec's ports row documented only host/container/
  protocol — bind, auth, auth_rationale and session_passthrough were
  undocumented. Added a full "Ports & the app gate" section plus a
  developer-guide entry telling app authors to enforce their own auth
  regardless, since the operator can flip the gate either way.

Verified live on archi-dev-box from an external address: gated → 401 gate
page; override off → Gitea 200 own page, BTCPay 302 to its own login,
git-over-HTTP info/refs 200; override on → 401 again; clear → default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-16 11:40:07 -04:00
co-authored by Claude Fable 5
parent 9b789a64ad
commit 58cdea5e79
15 changed files with 537 additions and 7 deletions
+15
View File
@@ -152,6 +152,21 @@ know the mechanics:
policy, only its framing policy. You do not need a bespoke reverse proxy,
header patches, or app config to be embeddable.
### Apps with their own login: `auth: open`
If your app carries a complete account system of its own — and especially if
non-browser clients must reach it (git over HTTP, mobile apps, payment
webhooks) — declare its gated port `auth: open` with an `auth_rationale`
instead of `auth: gated`. The daemon still fronts the port exactly like a
gated one (loopback pin, external binds, frame-header fixes, retry page,
Tor onion), but serves it without the dashboard-login challenge, so your
app's own authentication is the one users and API clients meet. Gitea and
BTCPay Server ship this way. The node operator can override your default in
either direction at runtime (Settings → app → Access control), so never
treat the gate as your app's authorization layer — enforce your own auth on
every sensitive route regardless. See “Ports & the app gate” in
[`app-manifest-spec.md`](app-manifest-spec.md).
Set `metadata.launch.open_in_new_tab: true` only when embedding is broken by
things headers can't fix — the app frame-busts in JavaScript, requires being
the top-level origin (OAuth redirect flows, WebAuthn), or sets
+52 -1
View File
@@ -29,7 +29,7 @@ reusable manifest primitive.
| `dependencies` | list | — | `- storage: "10GB"`, `- { app_id: bitcoin, version: … }`, or a bare string. |
| `resources` | ResourceLimits | — | `cpu_limit` (int), `memory_limit` (e.g. `"512m"`), `disk_limit`. |
| `security` | SecurityPolicy | — | See [Security](#security). |
| `ports` | list of PortMapping | — | `- { host: 8080, container: 80, protocol: tcp }`. |
| `ports` | list of PortMapping | — | See [Ports & the app gate](#ports--the-app-gate). |
| `volumes` | list of Volume | — | See [Volumes](#volumes). |
| `files` | list of GeneratedFile | — | Config files written before create: `{ path, content, overwrite }`. `path` must sit under a declared bind mount. |
| `environment` | list of string | — | `- KEY=value` pairs (static). |
@@ -82,6 +82,57 @@ Validation (enforced at `AppManifest::validate()`):
`secret_env`/`generated_secrets` names must be bare filenames.
- Hook steps are validated against the hook allow-list (below).
## Ports & the app gate
```yaml
ports:
- host: 3001 # host port your app is reachable on
container: 3000
protocol: tcp # default tcp
bind: 127.0.0.1 # empty = all interfaces
auth: gated # session | none | local | gated | open
auth_rationale: "…" # REQUIRED for auth: none and auth: open
session_passthrough: false
```
| Field | Notes |
|-------|-------|
| `bind` | Host address for the publish. Empty = all interfaces. List the same host/container pair twice with different binds to serve several addresses. |
| `auth` | The port's authentication policy — see below. **Absent means "no instruction"**: the daemon reports on the port but never changes how it is published. |
| `auth_rationale` | Why the port is safe without the gate's login. Required for `none` and `open`, rejected elsewhere. |
| `session_passthrough` | Forward the node session cookie to the app on authorised requests. First-party companion UIs only; the gate otherwise strips its own credential. Only meaningful on `auth: gated`. |
### `auth` policies
- **`session`** (default when declared): the app gate authenticates every
connection — node session cookie or an app-scoped bearer token.
- **`gated`**: the app publishes on loopback **only** (`bind: 127.0.0.1`) and
the daemon owns the external addresses: it binds them, authenticates every
connection, fixes frame-blocking headers so the app embeds in the
dashboard, serves a retrying page while the app is down, and fronts the
Tor onion for the port. This is the migrated end state for most apps.
- **`open`**: same daemon takeover as `gated` — loopback pin, external
binds, header fixes, retry page, Tor — but **no dashboard login
challenge**. For apps that carry a complete login of their own and are
broken by an upstream challenge: Gitea (git clients speak basic-auth, not
cookies), BTCPay (checkout pages must be reachable by anonymous payers).
Requires `auth_rationale`.
- **`none`**: the gate does not touch the port at all. Only for protocols
that authenticate themselves (LND macaroons, TLS client certs) or where a
login page is meaningless (p2p gossip). Requires `auth_rationale`.
- **`local`**: host-local by intent — the gate must never bind or expose
this port anywhere (e.g. Bitcoin RPC).
### Runtime override
The manifest sets the **default**. The node operator can flip any
gate-fronted app between `gated` and `open` behaviour at runtime from
**Settings → app → Access control** (RPC `security.set-app-gate`), stored
per-app on the node (`app-configs/<id>.json`, key `gateEnabled`). The
override wins over the manifest in both directions and applies on the next
request — your app cannot assume the gate is or isn't in front of it, so it
must always enforce its own authorization for sensitive operations.
## Volumes
```yaml