Ctrl K
ComunidadGuías…

compose.gateway-sso.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# compose.gateway-sso.yml — put the OmniRoute dashboard behind Cerulean Authentik.
#
# WHY THIS EXISTS
# ---------------
# The gateway dashboard manages provider credentials and can read them, so it
# should not be reachable on a shared password. OmniRoute has OIDC support of its
# own, but it cannot validate an Authentik ID token: its callback strips the
# trailing slash from the configured issuer and then requires an exact match,
# and Authentik's `iss` always ends with one. `docs/gateway-sso.md` carries the
# evidence. So the dashboard is fronted by a conformant client instead, and
# OmniRoute's own `oidcEnabled` stays off.
#
#   browser ──https──▶ edge (NPM, this host)
#                          │  gateway.studio.innotel.us      (and the older
#                          │  gateway.olympus.innotel.us, same door, same proxy)
#                          ▼  http://192.168.1.71:20128
#                    gateway-sso (oauth2-proxy) ──▶ Authentik OIDC + group check
#                          │  http://127.0.0.1:20128
#                          ▼
#                       omniroute (dashboard, loopback + docker0 only)
#
# USAGE
# -----
#   make gateway-sso-up      # or, by hand:
#   docker compose -f docker-compose.yml -f compose.gateway-sso.yml up -d gateway-sso
#
# EVERY NAME IN GATEWAY_SSO_WHITELIST_DOMAIN IS SERVED, each with its own derived
# callback — see the note at `--whitelist-domain` below. The proxy and the gateway
# must share a host, because the gateway is loopback-published and a bridge cannot
# reach that: a proxy on another machine has no door to it at all.
#
# HOST NETWORKING IS NOT OPTIONAL HERE. The gateway is published on the host's
# loopback (`127.0.0.1:20128`, plus `172.17.0.1:20128` for containers on this host),
# and Docker publishes a loopback binding to
# the loopback interface alone — a bridge container cannot reach it at all. This
# is the case `docker-compose.yml` calls "loopback-published on host", whose
# sanctioned answer is host networking; `compose.host-gateway.yml` already makes
# that trade for Studio. The same namespace then gives the proxy both the
# upstream and a LAN-facing listener, so `ports` is cleared rather than left to
# be silently discarded.

services:
  gateway-sso:
    # Pinned by tag, not digest: unlike the autoheal container this one holds no
    # Docker socket, so a bad upstream release cannot restart anything — it can
    # only fail closed behind the edge.
    #
    # The `-alpine` variant specifically: the default tag is distroless and has no
    # shell, which makes the healthcheck below impossible to express (a `wget`
    # healthcheck on it reports the container unhealthy forever — measured). A
    # shell in the image is the price of a liveness signal; the container is the
    # only thing listening on the door and it never runs the shell itself.
    image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1-alpine
    container_name: gateway-sso
    restart: unless-stopped
    network_mode: host
    ports: !override []
    # The gateway's own network is not this stack's, so there is nothing to join
    # and nothing to add it to.
    networks: !override []
    env_file:
      # Optional, because a render has to work without one: `make check-compose`
      # validates this file against `.env.example` in a checkout that has no `.env`,
      # and a missing env_file is an error rather than a warning. A deployment has
      # one, and it is loaded exactly as before.
      - path: .env
        required: false
    environment:
      # oauth2-proxy reads OAUTH2_PROXY_* from the environment. The repo-idiomatic
      # GATEWAY_* names stay in .env; this block is the only place the two
      # vocabularies meet, so the credentials are never repeated in `command`
      # where `docker inspect` would show them alongside everything else.
      OAUTH2_PROXY_CLIENT_ID: ${GATEWAY_OIDC_CLIENT_ID}
      OAUTH2_PROXY_CLIENT_SECRET: ${GATEWAY_OIDC_CLIENT_SECRET}
      OAUTH2_PROXY_COOKIE_SECRET: ${GATEWAY_SSO_COOKIE_SECRET}
      # The session store's address and password in one value, in the environment
      # rather than in `command` for the same reason as the three above it. See
      # the `--session-store-type` note further down for why this is not a cookie.
      OAUTH2_PROXY_REDIS_CONNECTION_URL: redis://:${GATEWAY_SSO_REDIS_PASSWORD:-}@127.0.0.1:${GATEWAY_SSO_REDIS_PORT:-16379}/0
    command:
      - --provider=oidc
      # Trailing slash included, because that is the issuer Authentik publishes
      # in the discovery document. (This is exactly the value OmniRoute mishandles.)
      - --oidc-issuer-url=${GATEWAY_OIDC_ISSUER_URL}
      - --oidc-groups-claim=groups
      - --scope=openid email profile
      # Authentik advertises S256; oauth2-proxy leaves PKCE off unless asked, and
      # it says so on every start. A public-Redirect-URI code flow should use it.
      - --code-challenge-method=S256
      - --upstream=${GATEWAY_SSO_UPSTREAM:-http://127.0.0.1:20128}
      # THE LAN ADDRESS, NOT 0.0.0.0, and since the door moved onto the gateway's
      # own default port (20128) that is load-bearing rather than tidiness: on
      # this host `omniroute` already holds 127.0.0.1:20128 (host-mode callers)
      # and 172.17.0.1:20128 (containers on this host, via `host.docker.internal`).
      # A wildcard bind overlaps both and fails with EADDRINUSE, so the proxy
      # would not start at all. Binding this host's LAN address leaves those two
      # answers untouched and makes the LAN address the door — one listener, two
      # roles, told apart by address rather than by port number.
      - --http-address=${GATEWAY_SSO_BIND:-0.0.0.0}:${GATEWAY_SSO_PORT:-20128}
      # THE LAN PATH FOR API CLIENTS, and the reason this listener is on 0.0.0.0
      # rather than loopback. The gateway itself stays on 127.0.0.1, so the only
      # way in from another machine is through here — which means one door, and it
      # has to be the right one for each kind of caller:
      #
      #   /v1/*      inference, and FOR LAN CLIENTS ONLY. Putting an interactive
      #              OIDC login in front of an API would not add a check, it would
      #              break every client — Codex, the CLIs, a browser fetch — because
      #              they send a key, not a session cookie. So it is exempted here,
      #              and this listener's LAN address is the door for it.
      #
      #              NOT THE PUBLIC NAME, and that distinction is load-bearing. The
      #              comment above used to say the bearer key authenticated this
      #              route. Measured on this deployment, nothing authenticates it:
      #              `make gateway-auth-mode` leaves the gateway with no gate of its
      #              own (requireLogin=false — see its docstring: the loopback
      #              binding is then the entire control) and this OmniRoute build
      #              answers 200 to /v1 with no key, a bogus key and the real one, and
      #              returns a completion to an unauthenticated POST. With this
      #              exemption, publishing the name therefore published the provider
      #              credentials behind it.
      #
      #              `make gateway-edge --deny-path /v1` closes it at the edge, where
      #              the public name and the LAN door can be told apart (they are the
      #              same listener, so nothing here can). `make gateway-edge-check`
      #              asserts both halves. Do not remove this line to "close /v1":
      #              that would break the LAN clients and change nothing about the
      #              public name's exposure — the rule that matters is at the edge.
      #   /healthz   liveness. Answers 200 with no body and no secrets.
      #   everything else, INCLUDING the dashboard, still requires the Authentik
      #   session and the group. The dashboard is the thing worth protecting here:
      #   it can read and write provider credentials.
      #
      # So "reachable on the LAN" and "the dashboard is not exposed" are both true,
      # and neither is achieved by widening the gateway's own binding.
      #
      # NOT skipped, and this one is deliberate: `/api/auth/login` is the dashboard's
      # OWN login. It is a no-op today — `make gateway-auth-mode` sets
      # `requireLogin=false`, so the gateway does not gate itself and this route has
      # nothing to hand out; there is exactly one gate, and it is Authentik. It stays
      # unexempted because it costs nothing and because a future release that
      # reintroduces a password check must not find it sitting outside the SSO door.
      # Verified rather than assumed: with a session, `/dashboard` answers 200 and
      # never reaches OmniRoute's `/login`; without one, the public name is a 302 to
      # Authentik.
      - --skip-auth-route=^/v1/
      # THE BARE PREFIX TOO, because `^/v1/` is not the same route. oauth2-proxy
      # matches the path it was given, so `^/v1/` exempts `/v1/chat/completions`
      # and `/v1/models` but sends `/v1` itself to Authentik. That asymmetry is
      # invisible to a client that only ever POSTs a subpath, and breaks the one
      # that validates its base URL first: `OMNIROUTE_BASE_URL` is documented one
      # section up as `http://192.168.1.71:20128/v1` — the bare prefix — and the
      # Asterisk AI voice engine probes exactly that before accepting a config.
      # It got a login page back, read the 400 as an API error, and reported its
      # whole pipeline unhealthy while every runtime call to the same gateway
      # worked — a failure that reads as "the model is broken".
      #
      # Nothing new is exposed. `GET /v1` returns the same model list as
      # `/v1/models`, which is already on this list, and the public name refuses
      # both at the edge (`location ^~ /v1/` + `location = /v1` -> 403), which
      # scripts/gateway-edge-check.py asserts. This route only makes the
      # LAN exemption cover the prefix it was always documented to cover.
      # `(\?.*)?$` for the same reason as the `/api/providers` route below: the
      # match is against the request URI, query string included.
      - '--skip-auth-route=^/v1(\?.*)?$'
      - --skip-auth-route=^/healthz$
      # THE READ-ONLY DOOR FOR STUDIO'S PROVIDER SET, and it is deliberately this
      # narrow. Studio owns no provider credentials and no model list of its own:
      # which models a build may use is "whatever the gateway has *connected*", and
      # the gateway answers that at `/api/providers` — the connections table, not
      # `/v1/models`, which also advertises the anonymous no-auth providers (Felo,
      # DuckDuckGo, Auggie, the Codex app-server) that are built into the gateway
      # but that nobody has linked a credential for. Reading the model list alone
      # cannot tell those apart, so without this route Studio either offers models
      # that cannot answer or pins a default the gateway has no credentials for —
      # the `503 No credentials for …` failure docs/stack.md measures.
      #
      # Exactly `^/api/providers$`, not `^/api/`: the rest of the management API
      # (settings, connection writes, the dashboard's own routes) stays behind the
      # Authentik session and the allowed group, because it can write. This one
      # route is a GET of provider *names* and masked keys; it is the smallest
      # thing that answers "what is connected".
      #
      # THE SAME LISTENER CARRIES THE PUBLIC NAME, so this is a LAN-and-public
      # exemption, not a LAN-only one — oauth2-proxy cannot tell the two apart (it
      # is one `--http-address`). `/v1` has the same shape and is closed at the edge
      # by `make gateway-edge --deny-path /v1`; this route's public exposure is the
      # model list and provider names, not inference, so it is left open for the
      # dashboard to keep working. Close it at the edge the same way if that trade
      # is not wanted: `make gateway-edge ARGS="--deny-path /api/providers"`.
      #
      # `(\?.*)?$` and not a bare `$`: oauth2-proxy matches these routes against
      # the *request URI*, query string included, so `^/api/providers$` exempts the
      # bare path and 302s `/api/providers?limit=5000` to Authentik — measured on
      # this deployment, and exactly the kind of half-open door that reads as "the
      # filter silently did nothing".
      - '--skip-auth-route=^/api/providers(\?.*)?$'
      # NO --redirect-url, AND THAT IS THE FEATURE. With it pinned, this proxy
      # completes every login on ONE name: a request that arrives on a second name
      # is sent to Authentik with the first name's `redirect_uri`, so the callback
      # lands there and the second name has no session — which is why
      # `gateway.olympus.innotel.us` had to be a 301 instead of a door. Left unset,
      # oauth2-proxy derives the callback from the request's Host (`--reverse-proxy`
      # is what makes that Host the public one), so each served name logs in on
      # itself. Measured on this deployment before changing it: a probe on a spare
      # port with these flags and no `--redirect-url` answered
      # `redirect_uri=https://gateway.studio.innotel.us/oauth2/callback` for a
      # studio Host and `…gateway.olympus.innotel.us…` for an olympus Host.
      #
      # A DERIVED CALLBACK IS ONLY AS GOOD AS THIS LIST — it is what turns
      # "whatever Host header arrived" into a decided set, so a name left out here
      # gets `redirect_uri` refused by Authentik (whose matching is strict per
      # provider) rather than silently redirected somewhere unexpected.
      - --whitelist-domain=${GATEWAY_SSO_WHITELIST_DOMAIN:-.innotel.us}
      # The edge terminates TLS, so the session cookie is only ever sent over
      # HTTPS even though this listener speaks plain HTTP.
      #
      # Each served name gets its OWN host-scoped `_oauth2_proxy` cookie, so the
      # two names log in independently. Adding --cookie-domain=${SSO_COOKIE_DOMAIN:-.innotel.us}
      # here (the line the other SSO gateways in this estate carry) makes one login
      # cover every served name — trade a second login for a session cookie sent to
      # every *.innotel.us host. Deliberately not the default: it widens the blast
      # radius of an XSS anywhere in the domain to a dashboard that can read every
      # provider credential, and neither name is worse off without it.
      - --cookie-secure=true
      - --cookie-samesite=lax
      # Honours X-Forwarded-Proto/Host, which is how the session cookie stays
      # `Secure` and the OAuth redirects come back on the public name instead of
      # on the LAN address.
      #
      # `--trusted-ip` was tried here and REMOVED: oauth2-proxy warns on every
      # request that mixing it with `--reverse-proxy` is unsafe, and it is right —
      # with reverse-proxy on, the client IP is read from X-Forwarded-For, so a
      # caller who can reach 20128 spoofs the trusted address and is believed.
      # It hardens nothing. The real control is who can reach the port: the
      # listener is on 0.0.0.0 so the edge can, which means the port should be
      # firewalled to the edge's address — see docs/gateway-sso.md.
      - --reverse-proxy=true
      # Any verified email may pass the OIDC step; the group below is the actual
      # door. OmniRoute's own allow-list cannot express this at all — it takes
      # subjects and emails, so "anyone in cerulean-platform" is not a sentence it
      # can say.
      - --email-domain=*
      - --allowed-group=${GATEWAY_SSO_ALLOWED_GROUP:-cerulean-platform}
      - --skip-provider-button=true
      # Authentik's OWN `email` scope mapping sets `email_verified: false` — a
      # deliberate change in Authentik 2025.10, because it has no authoritative
      # source for whether an address is verified. oauth2-proxy refuses such a
      # token by default ("email in id_token … isn't verified"), so this flag is
      # what makes the handshake finish.
      #
      # It is not a weak spot here: the door is `--allowed-group`, and
      # `--email-domain=*` means the address grants nothing on its own. The
      # alternative — a custom scope mapping asserting `email_verified: true` for
      # this provider — was rejected because that asserts something Authentik
      # explicitly decided it cannot know, on the platform's behalf.
      - --insecure-oidc-allow-unverified-email
      # SERVER-SIDE SESSIONS, AND THIS IS LOAD-BEARING. The cookie store puts the
      # whole session in the browser: the email, the ID token, and every group the
      # identity claims. An account in a few dozen Authentik groups — `dhunter` is
      # in thirty — exceeds the 4KB a cookie can hold, so oauth2-proxy splits the
      # session across several cookies and says so on every login:
      #
      #   WARNING: Multiple cookies are required for this session as it exceeds
      #   the 4kb cookie limit. Please use server side session storage (eg. Redis).
      #
      # Those several `Set-Cookie` headers make the callback's response header
      # larger than the edge's `proxy_buffer_size`, and nginx answers the request
      # that would have completed the login with `502 Bad Gateway` —
      # "upstream sent too big header while reading response header from upstream".
      # The login fails, and it fails in the one place that looks like the gateway
      # being down rather than like a cookie being large. Measured on the edge
      # (proxy host #198) before this flag changed; see docs/gateway-sso.md.
      #
      # Moving the session out of the cookie also removes the ceiling entirely:
      # it does not matter how many groups are added to it later.
      - --session-store-type=redis
      # That is the whole configuration here: `OAUTH2_PROXY_REDIS_CONNECTION_URL`
      # in `environment` above carries the address and the password, so there is
      # no second copy of either to drift from it.
      # Pass the identity on to the dashboard so its logs name a person rather
      # than an anonymous session.
      - --set-xauthrequest=true
      - --pass-user-headers=true
      # Trim what a dashboard never needs to see.
      - --pass-access-token=false
      - --pass-authorization-header=false
      - --silence-ping-logging=true
    healthcheck:
      # /ping is oauth2-proxy's own unauthenticated liveness route. Probing the
      # dashboard instead would make this container's health depend on the
      # gateway being up, which is how a healthy proxy gets restarted for
      # somebody else's outage.
      # Probes the BIND, not loopback: on this host 127.0.0.1:20128 answers
      # `omniroute`, so a loopback probe would silently test the gateway's
      # dashboard (and report unhealthy) instead of this proxy's liveness.
      test: ["CMD", "wget", "-qO-", "http://${GATEWAY_SSO_BIND:-127.0.0.1}:${GATEWAY_SSO_PORT:-20128}/ping"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    # Deliberately NOT labelled `autoheal`. Autoheal acts on a failing
    # healthcheck, and this container's job is to refuse traffic — a restart
    # loop would turn every closed door into something that looks like a crash.

  # The session store. Neither the browser nor the edge ever sees this: the proxy
  # writes the session here and hands the browser a single opaque id, which is what
  # keeps the callback's response header small enough for the edge to pass — the
  # thing `--session-store-type=redis` above exists to fix. Sessions are the only
  # thing in it, and losing them costs a re-login and nothing else, so there is no
  # volume and no persistence: `--save "" --appendonly no`.
  #
  # Loopback only. The proxy is host-networked and reaches it at 127.0.0.1; nothing
  # on the LAN can, which is the same posture as the gateway itself.
  gateway-sso-sessions:
    image: redis:7-alpine
    container_name: gateway-sso-sessions
    restart: unless-stopped
    labels:
      # Unlike the proxy, this one CAN be restarted usefully: it holds no state a
      # restart would discard, and a wedged store fails every login.
      - "autoheal=true"
    environment:
      GATEWAY_SSO_REDIS_PASSWORD: ${GATEWAY_SSO_REDIS_PASSWORD:-}
    # The password reaches redis through this container's environment rather than
    # through its command line, so `docker inspect` does not carry it. Verified
    # rather than assumed: the shell inside expands it, and there is no
    # `${VAR:?}` here because `make check-compose` renders every overlay against
    # `.env.example` — where this is empty — and a required-variable syntax would
    # turn that into a failure of the repository's own validation instead of a
    # safeguard. The refusal belongs at start-up, which is what this does.
    command:
      - sh
      - -c
      - |
        if [ -z "$$GATEWAY_SSO_REDIS_PASSWORD" ]; then
          echo "gateway-sso-sessions: GATEWAY_SSO_REDIS_PASSWORD is empty; refusing to start a session store with no password." >&2
          echo "Add one with:  printf 'GATEWAY_SSO_REDIS_PASSWORD=%s\n' \$$(openssl rand -hex 32) >> .env" >&2
          exit 2
        fi
        exec redis-server \
          --requirepass "$$GATEWAY_SSO_REDIS_PASSWORD" \
          --save "" \
          --appendonly no \
          --maxmemory-policy noeviction
    # `--maxmemory-policy noeviction` on purpose: an LRU policy on a session store
    # would silently log people out under memory pressure, which is worse than
    # failing to write at all.
    ports:
      - "127.0.0.1:${GATEWAY_SSO_REDIS_PORT:-16379}:6379"
    healthcheck:
      # `$$` so compose passes the literal `$` through and the container's shell
      # expands GATEWAY_SSO_REDIS_PASSWORD from its own environment. `-a` with a
      # value from the environment rather than inlined here, so the healthcheck
      # command itself carries no secret.
      test: ["CMD-SHELL", "redis-cli -a \"$$GATEWAY_SSO_REDIS_PASSWORD\" --no-auth-warning ping | grep -q PONG"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s