Compare commits

...

6 Commits

5 changed files with 44 additions and 20 deletions

View File

@@ -246,7 +246,7 @@ Checklist:
publishes the plugin zip is the obvious first pipeline publishes the plugin zip is the obvious first pipeline
- risk: the runner's Docker socket is root on the host for anyone who can push a workflow - risk: the runner's Docker socket is root on the host for anyone who can push a workflow
### Phase 10 — Gitea login via Authentik (OIDC) (compose done; Authentik + Gitea UI steps pending) ### Phase 10 — Gitea login via Authentik (OIDC) ✅ live, verified
Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it. Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it.
- [ ] Authentik → Applications → Create with provider → OAuth2/OpenID: name `gitea`, redirect - [ ] Authentik → Applications → Create with provider → OAuth2/OpenID: name `gitea`, redirect
`https://git.HOST/user/oauth2/authentik/callback`, authorization flow implicit-consent, scopes `https://git.HOST/user/oauth2/authentik/callback`, authorization flow implicit-consent, scopes
@@ -258,7 +258,8 @@ Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it.
- [x] Gitea env: `DISABLE_REGISTRATION=false` + `ALLOW_ONLY_EXTERNAL_REGISTRATION=true` (local self-signup stays - [x] Gitea env: `DISABLE_REGISTRATION=false` + `ALLOW_ONLY_EXTERNAL_REGISTRATION=true` (local self-signup stays
off), `ENABLE_AUTO_REGISTRATION`, `ACCOUNT_LINKING=auto`, `USERNAME=nickname`, `UPDATE_AVATAR` off), `ENABLE_AUTO_REGISTRATION`, `ACCOUNT_LINKING=auto`, `USERNAME=nickname`, `UPDATE_AVATAR`
- [ ] optional: hide the password form on the login page (custom template) so Discord is the only visible way in - [ ] optional: hide the password form on the login page (custom template) so Discord is the only visible way in
- [ ] Authentik group `git-users` (from a Discord role) bound on the `Gitea` application (+ `admins`); only they can log in - [ ] Authentik groups `git-users` and `git-admins` bound on the `Gitea` application; Gitea admin claim = `git-admins`;
nesting `authentik Admins` → `admins` → `git-admins` so admins inherit it
- [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group - [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group
### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) ✅ live ### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) ✅ live

View File

@@ -80,8 +80,14 @@ Everything else is dropped and a trickle is logged (`journalctl -k | grep vg-dro
Jellyfin (8096), copyparty, the Syncthing UI and dumpd bind to 127.0.0.1 only and are reached via Caddy Jellyfin (8096), copyparty, the Syncthing UI and dumpd bind to 127.0.0.1 only and are reached via Caddy
or an ssh tunnel, so they are deliberately not opened. MariaDB runs on the host network (not a Docker or an ssh tunnel, so they are deliberately not opened. MariaDB runs on the host network (not a Docker
published port) precisely so the firewall can govern it. published port) precisely so the firewall can govern it.
Syncthing's admin UI is proxied at `https://HOST/sync/` behind Syncthing's own GUI login (set it in Syncthing's admin UI is proxied at `https://HOST/sync/` behind the Authentik gate + group `media-admins`; its
Actions → Settings → GUI before relying on this). The ssh tunnel to 8384 still works as a fallback. own GUI login is disabled (no user/password, `insecureAdminAccess=true`) so Discord is the only login:
docker exec syncthing syncthing cli --home /var/syncthing/config config gui insecure-admin-access set true
docker exec syncthing syncthing cli --home /var/syncthing/config config gui user set ""
docker exec syncthing syncthing cli --home /var/syncthing/config config gui password set ""
The ssh tunnel to 8384 still works as an admin fallback (no login at all — ssh is the gate there).
To change rules: edit `/etc/nftables.conf`, then `sudo nft -f /etc/nftables.conf` or To change rules: edit `/etc/nftables.conf`, then `sudo nft -f /etc/nftables.conf` or
`sudo systemctl reload nftables`. **Never `systemctl stop|restart nftables`** — Debian's unit flushes `sudo systemctl reload nftables`. **Never `systemctl stop|restart nftables`** — Debian's unit flushes
@@ -212,11 +218,13 @@ Keep repo write access to trusted people.
2. Gitea → Site Administration → Identity & Access → Authentication Sources → Add: type **OAuth2**, name 2. Gitea → Site Administration → Identity & Access → Authentication Sources → Add: type **OAuth2**, name
**`authentik`** (must match the callback path), provider **OpenID Connect**, client ID/secret, OpenID Connect **`authentik`** (must match the callback path), provider **OpenID Connect**, client ID/secret, OpenID Connect
Auto Discovery URL `https://auth.HOST/application/o/gitea/.well-known/openid-configuration`, tick "Skip local Auto Discovery URL `https://auth.HOST/application/o/gitea/.well-known/openid-configuration`, tick "Skip local
2FA", claim name for groups `groups`, administrator group claim value `admins`. Save. 2FA", claim name for groups `groups`, administrator group claim value **`git-admins`**. Save.
Authentik group nesting makes the admins inherit it: `authentik Admins` → parent `admins` → parent `git-admins`
(members of a child group count as members of the parent, in the `groups` claim and in Caddy's header checks).
3. compose (already set): registration only via external sources, auto-registration + account linking on, 3. compose (already set): registration only via external sources, auto-registration + account linking on,
username from `preferred_username`. A first Discord login creates the Gitea user with no form. username from `preferred_username`. A first Discord login creates the Gitea user with no form.
4. Access = Authentik group **`git-users`**: Directory → Groups → create it (add a Discord role to it in the 4. Access = Authentik group **`git-users`**: Directory → Groups → create it (add a Discord role to it in the
roles→groups table); Applications → `Gitea` → Policy / Group / User Bindings → bind `git-users` (and `admins`). roles→groups table); Applications → `Gitea` → Policy / Group / User Bindings → bind `git-users` and `git-admins`.
Non-members are refused by Authentik before Gitea sees them. Non-members are refused by Authentik before Gitea sees them.
5. Test in a private window: https://git.HOST/user/login → "Sign in with authentik" → Discord → logged in. 5. Test in a private window: https://git.HOST/user/login → "Sign in with authentik" → Discord → logged in.
Members of `admins` become Gitea site admins on login. The local admin account stays as a fallback. Members of `admins` become Gitea site admins on login. The local admin account stays as a fallback.
@@ -262,11 +270,13 @@ Design: **Authentik authenticates once for the whole domain; Caddy authorizes pe
`discord-roles-to-groups` from `authentik/discord-roles-to-groups.py` (fill in the server ID and the `discord-roles-to-groups` from `authentik/discord-roles-to-groups.py` (fill in the server ID and the
role-id → group table). Bind it to `default-source-enrollment` **and** `default-source-authentication` role-id → group table). Bind it to `default-source-enrollment` **and** `default-source-authentication`
(engine mode `all`, after `discord-guild-member`). From then on giving someone a Discord role gives them (engine mode `all`, after `discord-guild-member`). From then on giving someone a Discord role gives them
the matching access on next login; removing it removes it. the matching access on next login; removing it removes it. Memberships added by hand in Authentik are left
alone: the policy only revokes what it granted itself (tracked in the user attribute `discord_role_groups`).
copyparty additionally requires a header with a secret name that only Caddy adds (`idp-h-key`, copyparty additionally requires a header with a secret name that only Caddy adds (`idp-h-key`,
`COPYPARTY_IDP_KEY` in `.env`), so identity headers cannot be forged even if copyparty were reached directly. `COPYPARTY_IDP_KEY` in `.env`), so identity headers cannot be forged even if copyparty were reached directly.
Logout: copyparty's logout link, the live portal's "Sign out" and the dock's "sign out" go through Logout: copyparty's logout link, the live portal's "Sign out" and the dock's "sign out" go through
`/outpost.goauthentik.io/sign_out`. Set the provider's **Invalidation flow** to `/outpost.goauthentik.io/sign_out`; `https://<any gated host>/logout` does the same from anywhere
(e.g. from the Syncthing UI, which has no logout of its own). Set the provider's **Invalidation flow** to
`default-provider-invalidation-flow` so that also ends the Authentik login: every logout then forces a full `default-provider-invalidation-flow` so that also ends the Authentik login: every logout then forces a full
Discord re-login on the next visit (and picks up group changes). Discord itself may approve silently if Discord re-login on the next visit (and picks up group changes). Discord itself may approve silently if
you are still logged into Discord in that browser. you are still logged into Discord in that browser.

View File

@@ -33,10 +33,10 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) | | Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
| Anyone in the Discord server | Watch live: all active streams in dockable windows, audio follows focus, per-window mute/volume | Live portal `https://live.…/` | Discord (Authentik, domain-level) — **live, shows a real stream** | | Anyone in the Discord server | Watch live: all active streams in dockable windows, audio follows focus, per-window mute/volume | Live portal `https://live.…/` | Discord (Authentik, domain-level) — **live, shows a real stream** |
| Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **verified end to end** (publish → `.live` → sweep → title folder) | | Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **verified end to end** (publish → `.live` → sweep → title folder) |
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) | | Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | Discord (Authentik), group `git-users`; `git-admins` = site admin |
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists | | Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
| Admin | Hostnames, bot token/guild, webhook; streamers/editors are Discord roles → Authentik groups | `./configure.py` | ssh | | Admin | Hostnames, bot token/guild, webhook; streamers/editors are Discord roles → Authentik groups | `./configure.py` | ssh |
| Admin | Pair a gamer's device, approve their folder | Syncthing UI `https://ovh1…/sync/` | Discord + group `media-admins`, then Syncthing's GUI password | | Admin | Pair a gamer's device, approve their folder | Syncthing UI `https://ovh1…/sync/` | Discord + group `media-admins` (Syncthing's own login disabled) |
| Admin | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin | | Admin | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin |
| Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh | | Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh |
| Admin | Register the CI runner | `./registerGiteaRunner.sh` | ssh | | Admin | Register the CI runner | `./registerGiteaRunner.sh` | ssh |
@@ -49,7 +49,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| copyparty | browse/scrub/download/finals upload | behind Authentik, group permissions (`editors`, `media-admins`), Discord-only, no WebDAV | live | | copyparty | browse/scrub/download/finals upload | behind Authentik, group permissions (`editors`, `media-admins`), Discord-only, no WebDAV | live |
| dumpd + dock | dump, stream tokens, MediaMTX auth hook, `/time`; OBS dock with timecode-band setup | identity from Authentik headers | live | | dumpd + dock | dump, stream tokens, MediaMTX auth hook, `/time`; OBS dock with timecode-band setup | identity from Authentik headers | live |
| Timecode | `bin/vgstrip.py` decoder, `tmcd` + sidecar in the sweep, `/dock/clock/` strip page, Go Live band composite, portal crop | server clock is the reference | **live, verified** (OBS: frame-accurate, 0 gaps) | | Timecode | `bin/vgstrip.py` decoder, `tmcd` + sidecar in the sweep, `/dock/clock/` strip page, Go Live band composite, portal crop | server clock is the reference | **live, verified** (OBS: frame-accurate, 0 gaps) |
| Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins` | live | | Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins`, own GUI auth off | live |
| normalize sweep | editing standard, one copy per clip | live | | normalize sweep | editing standard, one copy per clip | live |
| nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live | | nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live |
| Caddy | TLS + routing for all hostnames | live | | Caddy | TLS + routing for all hostnames | live |
@@ -57,7 +57,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Jellyfin | watch page, SSO via Authentik, direct play only | live | | Jellyfin | watch page, SSO via Authentik, direct play only | live |
| Vanguard Metadata plugin | seasons by tag, Discord names/avatars/events, feed, playback policy | **0.1.3 built** (flat `<Title>__<ts>` names, policy task, feed wording) — install pending; server has 0.1.0 or 0.1.2 | | Vanguard Metadata plugin | seasons by tag, Discord names/avatars/events, feed, playback policy | **0.1.3 built** (flat `<Title>__<ts>` names, policy task, feed wording) — install pending; server has 0.1.0 or 0.1.2 |
| MariaDB | databases for the bot and Gitea; public :3306, root local-only | live | | MariaDB | databases for the bot and Gitea; public :3306, root local-only | live |
| Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin; Discord login via Authentik OIDC **compose ready, UI steps pending** | | Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live; Discord login via Authentik OIDC **verified**; access = `git-users`/`git-admins` groups; local admin as fallback |
| act_runner | CI jobs in Docker | **not registered** | | act_runner | CI jobs in Docker | **not registered** |
| discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet | | discord-bot | Python runtime, idles until `bot/app/main.py` exists | live, no code yet |
| `bot.` host | bot web UI behind Authentik forward-auth; Caddy requires group `bot-users` (from a Discord role) | Caddy block live; **`bot` CNAME still points at the wrong IP; roles→groups policy pending** | | `bot.` host | bot web UI behind Authentik forward-auth; Caddy requires group `bot-users` (from a Discord role) | Caddy block live; **`bot` CNAME still points at the wrong IP; roles→groups policy pending** |
@@ -65,11 +65,9 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
## Unfinished, in rough priority ## Unfinished, in rough priority
0. `docker compose restart mediamtx` at a quiet moment so recording filenames gain microseconds (the arrival-time fallback); everything else in phase 12 is live. 1. Roles→groups policy: bound and updated (revokes only its own grants); will be proven by the next natural Discord logins (`/whoami` should show `bot-users`, `git-admins`). `akadmin` remains the recovery login if a policy misfires.
1. Authentik: `discord-roles-to-groups` policy so `streamers`/`editors`/`media-admins`/`bot-users` follow Discord roles (`bot-users` group needed for the bot host). Set the provider's Invalidation flow to `default-provider-invalidation-flow` so every logout forces a full re-login.
2. Confirm plugin 0.1.3 under Jellyfin → Plugins and the Gitea runner registered. 2. Confirm plugin 0.1.3 under Jellyfin → Plugins and the Gitea runner registered.
3. Portal viewer during a real stream: corner chip `webrtc`; Windows Go Live with WarDogs audio (first Windows streamer). 3. Portal viewer during a real stream: corner chip `webrtc`; Windows Go Live with WarDogs audio (first Windows streamer).
4. Gitea login via Authentik: deploy the compose change, then the Authentik provider + Gitea auth source (README 5e).
5. Housekeeping: pin `AUTHENTIK_TAG`, rotate the Discord client secret seen in a screenshot, nightly Postgres dump, Caddyfile formatting warning. 5. Housekeeping: pin `AUTHENTIK_TAG`, rotate the Discord client secret seen in a screenshot, nightly Postgres dump, Caddyfile formatting warning.
6. Medal filename pattern in the plugin (need a sample filename). 6. Medal filename pattern in the plugin (need a sample filename).
7. Home archive puller on the home box. 7. Home archive puller on the home box.

View File

@@ -5,6 +5,7 @@
# guilds guilds.members.read ). Existing users re-consent once at Discord. # guilds guilds.members.read ). Existing users re-consent once at Discord.
# The Authentik groups must already exist (Directory -> Groups). Role IDs: Discord Developer Mode -> # The Authentik groups must already exist (Directory -> Groups). Role IDs: Discord Developer Mode ->
# Server Settings -> Roles -> right-click a role -> Copy Role ID. # Server Settings -> Roles -> right-click a role -> Copy Role ID.
# Manual memberships are safe: the policy only removes groups it granted itself (user attribute discord_role_groups).
GUILD_ID = "1387203044084551690" # <- your Discord server ID GUILD_ID = "1387203044084551690" # <- your Discord server ID
ROLE_TO_GROUP = { # <- Discord role id : Authentik group name ROLE_TO_GROUP = { # <- Discord role id : Authentik group name
@@ -40,17 +41,29 @@ wanted = {g for r, g in ROLE_TO_GROUP.items() if r in roles}
managed = set(ROLE_TO_GROUP.values()) managed = set(ROLE_TO_GROUP.values())
# Authentication flow: the user exists -> edit memberships directly. # Authentication flow: the user exists -> edit memberships directly.
# Only memberships THIS policy granted earlier are ever removed (tracked in user.attributes["discord_role_groups"]),
# so a group someone was added to by hand in Authentik survives role changes and re-logins.
user = context.get("pending_user") or (request.user if request.user.is_authenticated else None) user = context.get("pending_user") or (request.user if request.user.is_authenticated else None)
if user and user.pk: if user and user.pk:
attrs = user.attributes or {}
granted = set(attrs.get("discord_role_groups", []))
for group in Group.objects.filter(name__in=managed): for group in Group.objects.filter(name__in=managed):
if group.name in wanted: if group.name in wanted:
group.users.add(user) group.users.add(user)
else: granted.add(group.name)
group.users.remove(user) elif group.name in granted:
group.users.remove(user) # role gone -> take back what the role had given
granted.discard(group.name)
attrs["discord_role_groups"] = sorted(granted)
user.attributes = attrs
user.save()
return True return True
# Enrollment flow: the user is created later by the user-write stage; hand it the groups to attach. # Enrollment flow: the user is created later by the user-write stage; hand it the groups to attach and
# record them as role-granted so a later role removal can revoke exactly these.
plan = context.get("flow_plan") plan = context.get("flow_plan")
if plan is not None: if plan is not None:
plan.context["groups"] = list(Group.objects.filter(name__in=wanted)) plan.context["groups"] = list(Group.objects.filter(name__in=wanted))
prompt = plan.context.setdefault("prompt_data", {})
prompt.setdefault("attributes", {})["discord_role_groups"] = sorted(wanted)
return True return True

View File

@@ -127,7 +127,9 @@ def ask_name(kind, taken):
return n return n
# ------------------------------------------------------------------ writers # ------------------------------------------------------------------ writers
AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs a Proxy Provider in Authentik — domain-level for the AUTHENTIK_FORWARD_AUTH = """ # /logout on every gated host = sign out of the Discord login (domain-wide session)
redir /logout /outpost.goauthentik.io/sign_out 302
# Authentik forward-auth (embedded outpost). Needs a Proxy Provider in Authentik — domain-level for the
# whole domain (recommended) or single-application for this host — assigned to the embedded outpost. # whole domain (recommended) or single-application for this host — assigned to the embedded outpost.
# Until then: 4xx, never open. Per-host group requirements are enforced below from X-Authentik-Groups. # Until then: 4xx, never open. Per-host group requirements are enforced below from X-Authentik-Groups.
reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000 reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000
@@ -263,7 +265,7 @@ name: {{http.request.header.X-Authentik-Name}}"
reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip
}} }}
# Syncthing admin UI: Authentik + group {sync_group or 'any'}; Syncthing's own GUI login stays as a second layer # Syncthing admin UI: Authentik + group {sync_group or 'any'} (Syncthing's own GUI login is disabled)
{sync_gate} redir /sync /sync/ 308 {sync_gate} redir /sync /sync/ 308
handle_path /sync/* {{ handle_path /sync/* {{
reverse_proxy 127.0.0.1:8384 {{ reverse_proxy 127.0.0.1:8384 {{