Compare commits
4 Commits
82df0b1583
...
50d2f1c21d
| Author | SHA1 | Date | |
|---|---|---|---|
| 50d2f1c21d | |||
| 384a7ed552 | |||
| bee53f2718 | |||
| 4b4053a407 |
@@ -16,3 +16,5 @@ BOT_HOST=bot.example.com
|
|||||||
LIVE_HOST=live.example.com
|
LIVE_HOST=live.example.com
|
||||||
MAIN_HOST=media.example.com
|
MAIN_HOST=media.example.com
|
||||||
LIVE_WEBHOOK_URL=
|
LIVE_WEBHOOK_URL=
|
||||||
|
BOT_REQUIRE_GROUP=bot-users
|
||||||
|
LIVE_REQUIRE_GROUP=
|
||||||
|
|||||||
@@ -228,7 +228,8 @@ Checklist:
|
|||||||
- `bot/AGENT.md` is the build brief for the agent writing the bot; `bot/README.md` the ops cheat-sheet. The clip-submissions feature (Option below) is a natural home
|
- `bot/AGENT.md` is the build brief for the agent writing the bot; `bot/README.md` the ops cheat-sheet. The clip-submissions feature (Option below) is a natural home
|
||||||
for this bot: write `clips/<user>/<name>.mp4` + `<name>.json` sidecar and the Jellyfin plugin picks it up.
|
for this bot: write `clips/<user>/<name>.mp4` + `<name>.json` sidecar and the Jellyfin plugin picks it up.
|
||||||
- [ ] CNAME `bot` → main host; `configure.py --full` (asks the hostname, bot token + guild) → `docker compose up -d --build`
|
- [ ] CNAME `bot` → main host; `configure.py --full` (asks the hostname, bot token + guild) → `docker compose up -d --build`
|
||||||
- [ ] Authentik: Proxy Provider (forward auth, single app, external host `https://bot.HOST`) → embedded outpost → group policy
|
- [ ] Authentik: domain-level Proxy Provider (covers live/bot/main); Caddy requires group `bot-users` on the bot host (`BOT_REQUIRE_GROUP`)
|
||||||
|
- [ ] Discord roles → Authentik groups: `authentik/discord-roles-to-groups.py` on both source flows (scope `guilds.members.read`)
|
||||||
- [ ] dev drops `main.py` into `bot/app/` → `docker compose restart discord-bot`
|
- [ ] dev drops `main.py` into `bot/app/` → `docker compose restart discord-bot`
|
||||||
|
|
||||||
### Phase 9 — Gitea + Actions runner
|
### Phase 9 — Gitea + Actions runner
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -206,6 +206,30 @@ streamer's Authentik username must equal their MediaMTX name.
|
|||||||
Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on
|
Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on
|
||||||
the 500 Mbps line. For a crowd, restream to Twitch from OBS.
|
the 500 Mbps line. For a crowd, restream to Twitch from OBS.
|
||||||
|
|
||||||
|
## 5g. Authentik forward-auth for the Caddy-gated hosts (`live.`, `bot.`; later the main host)
|
||||||
|
|
||||||
|
Design: **Authentik authenticates once for the whole domain; Caddy authorizes per host** from the
|
||||||
|
`X-Authentik-Groups` header the outpost returns; **Discord roles fill the Authentik groups** at login.
|
||||||
|
|
||||||
|
1. Applications → Outposts → edit "authentik Embedded Outpost" → config `authentik_host: https://auth.HOST`.
|
||||||
|
2. Applications → Create with provider → Application `Vanguard sites`; **Proxy Provider**, authorization flow
|
||||||
|
implicit-consent, mode **Forward auth (domain level)**, authentication URL `https://auth.HOST`,
|
||||||
|
cookie domain = the parent domain (e.g. `dumpstervigilantes.rip`). Edit the Embedded Outpost →
|
||||||
|
Applications → add it → Update. One login now covers every gated host.
|
||||||
|
3. Per-host group requirement = `.env`: `BOT_REQUIRE_GROUP=bot-users` (default), `LIVE_REQUIRE_GROUP=`
|
||||||
|
(empty = any logged-in member). `./configure.py` apply regenerates the Caddy blocks; a user outside the
|
||||||
|
group gets a 403 from Caddy before the app sees the request.
|
||||||
|
4. Discord roles → groups: Directory → Groups: create `bot-users` (and keep `streamers`, `editors`, `admins`).
|
||||||
|
Discord source → Scopes: `guilds guilds.members.read`. Customization → Policies → Expression Policy
|
||||||
|
`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`
|
||||||
|
(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.
|
||||||
|
5. Test in a private window: `https://live.HOST/` → Discord login → portal; `https://bot.HOST/` → 403 unless
|
||||||
|
you hold the bot role; `https://live.HOST/me/stream-key` shows your username.
|
||||||
|
|
||||||
|
Jellyfin and Gitea use OIDC providers instead, not this.
|
||||||
|
|
||||||
## 6. Editors
|
## 6. Editors
|
||||||
|
|
||||||
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want.
|
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
| Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin |
|
| Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin |
|
||||||
| 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 block ready; **CNAME + Authentik proxy provider pending** |
|
| `bot.` host | bot web UI behind Authentik forward-auth; Caddy requires group `bot-users` (from a Discord role) | Caddy block ready; **CNAME + Authentik domain-level provider + roles→groups policy pending** |
|
||||||
| Live portal (`live/`, `live.` host) | React + dockview multi-stream viewer + browser Go Live (WHIP); MediaMTX HLS/WebRTC/API re-enabled on loopback; dumpd `/me/stream-key` | **built; CNAME, Authentik proxy provider, `./deployLive.sh`, configure apply pending** |
|
| Live portal (`live/`, `live.` host) | React + dockview multi-stream viewer + browser Go Live (WHIP); MediaMTX HLS/WebRTC/API re-enabled on loopback; dumpd `/me/stream-key` | **built; CNAME, Authentik proxy provider, `./deployLive.sh`, configure apply pending** |
|
||||||
|
|
||||||
## Unfinished, in rough priority
|
## Unfinished, in rough priority
|
||||||
|
|||||||
54
authentik/discord-roles-to-groups.py
Normal file
54
authentik/discord-roles-to-groups.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Authentik expression policy: sync a member's Discord ROLES into Authentik GROUPS at every login.
|
||||||
|
# Paste into: Customization -> Policies -> Create -> Expression Policy (name: discord-roles-to-groups)
|
||||||
|
# Bind to BOTH flows (after discord-guild-member): default-source-enrollment and default-source-authentication.
|
||||||
|
# Requires the "guilds.members.read" scope on the Discord source (source -> Protocol settings -> Scopes:
|
||||||
|
# guilds guilds.members.read ). Existing users re-consent once at Discord.
|
||||||
|
# The Authentik groups must already exist (Directory -> Groups). Role IDs: Discord Developer Mode ->
|
||||||
|
# Server Settings -> Roles -> right-click a role -> Copy Role ID.
|
||||||
|
|
||||||
|
GUILD_ID = "000000000000000000" # <- your Discord server ID
|
||||||
|
ROLE_TO_GROUP = { # <- Discord role id : Authentik group name
|
||||||
|
"111111111111111111": "bot-users",
|
||||||
|
"222222222222222222": "streamers",
|
||||||
|
"333333333333333333": "editors",
|
||||||
|
"444444444444444444": "admins",
|
||||||
|
}
|
||||||
|
|
||||||
|
from authentik.core.models import Group
|
||||||
|
|
||||||
|
source = context.get("source")
|
||||||
|
if not source or getattr(source, "provider_type", "") != "discord":
|
||||||
|
return True # not a Discord login (e.g. akadmin): nothing to sync
|
||||||
|
|
||||||
|
connection = context.get("goauthentik.io/sources/connection")
|
||||||
|
if not connection:
|
||||||
|
return True
|
||||||
|
|
||||||
|
resp = requests.get(
|
||||||
|
f"https://discord.com/api/users/@me/guilds/{GUILD_ID}/member",
|
||||||
|
headers={"Authorization": f"Bearer {connection.access_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
ak_message(f"Could not read your Discord roles ({resp.status_code}); groups unchanged.")
|
||||||
|
return True # don't block login on a Discord hiccup
|
||||||
|
|
||||||
|
roles = set(resp.json().get("roles", []))
|
||||||
|
wanted = {g for r, g in ROLE_TO_GROUP.items() if r in roles}
|
||||||
|
managed = set(ROLE_TO_GROUP.values())
|
||||||
|
|
||||||
|
# Authentication flow: the user exists -> edit memberships directly.
|
||||||
|
user = context.get("pending_user") or (request.user if request.user.is_authenticated else None)
|
||||||
|
if user and user.pk:
|
||||||
|
for group in Group.objects.filter(name__in=managed):
|
||||||
|
if group.name in wanted:
|
||||||
|
group.users.add(user)
|
||||||
|
else:
|
||||||
|
group.users.remove(user)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Enrollment flow: the user is created later by the user-write stage; hand it the groups to attach.
|
||||||
|
plan = context.get("flow_plan")
|
||||||
|
if plan is not None:
|
||||||
|
plan.context["groups"] = list(Group.objects.filter(name__in=wanted))
|
||||||
|
return True
|
||||||
28
configure.py
28
configure.py
@@ -124,8 +124,9 @@ def ask_name(kind, taken):
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
# ------------------------------------------------------------------ writers
|
# ------------------------------------------------------------------ writers
|
||||||
AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs a Proxy Provider (forward auth, single application)
|
AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs a Proxy Provider in Authentik — domain-level for the
|
||||||
# for this external host in Authentik, assigned to the embedded outpost. Until then: 4xx, never open.
|
# 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.
|
||||||
reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000
|
reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000
|
||||||
forward_auth 127.0.0.1:9000 {
|
forward_auth 127.0.0.1:9000 {
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
@@ -134,14 +135,27 @@ AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def gate(required_group=""):
|
||||||
|
"""Caddy lines: Authentik forward-auth, then deny unless X-Authentik-Groups contains required_group."""
|
||||||
|
block = AUTHENTIK_FORWARD_AUTH
|
||||||
|
if required_group:
|
||||||
|
g = re.escape(required_group)
|
||||||
|
block += f""" @lacks_group not header_regexp X-Authentik-Groups (^|\\|){g}(\\||$)
|
||||||
|
handle @lacks_group {{
|
||||||
|
respond "This page is for members of the '{required_group}' group." 403
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
return block
|
||||||
|
|
||||||
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
||||||
|
env = read_env()
|
||||||
extra = ""
|
extra = ""
|
||||||
if live_host:
|
if live_host:
|
||||||
extra += f"""
|
extra += f"""
|
||||||
# vanguard:live
|
# vanguard:live
|
||||||
{live_host} {{
|
{live_host} {{
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
{AUTHENTIK_FORWARD_AUTH} handle /me/* {{
|
{gate(env.get("LIVE_REQUIRE_GROUP", ""))} handle /me/* {{
|
||||||
reverse_proxy 127.0.0.1:8080 # dumpd: /me/stream-key for the logged-in streamer (Go Live)
|
reverse_proxy 127.0.0.1:8080 # dumpd: /me/stream-key for the logged-in streamer (Go Live)
|
||||||
}}
|
}}
|
||||||
handle_path /api/* {{
|
handle_path /api/* {{
|
||||||
@@ -165,7 +179,7 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
|
|||||||
# vanguard:bot
|
# vanguard:bot
|
||||||
{bot_host} {{
|
{bot_host} {{
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
{AUTHENTIK_FORWARD_AUTH} reverse_proxy 127.0.0.1:8000
|
{gate(env.get("BOT_REQUIRE_GROUP", "bot-users"))} reverse_proxy 127.0.0.1:8000
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
if git_host:
|
if git_host:
|
||||||
@@ -230,6 +244,8 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b
|
|||||||
if main_host: env["MAIN_HOST"] = main_host
|
if main_host: env["MAIN_HOST"] = main_host
|
||||||
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
|
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
|
||||||
env.setdefault("LIVE_WEBHOOK_URL", "")
|
env.setdefault("LIVE_WEBHOOK_URL", "")
|
||||||
|
env.setdefault("BOT_REQUIRE_GROUP", "bot-users") # Caddy 403s anyone not in this Authentik group; empty = any logged-in member
|
||||||
|
env.setdefault("LIVE_REQUIRE_GROUP", "")
|
||||||
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
|
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
|
||||||
env["BOT_HOST"] = bot_host or env.get("BOT_HOST", "bot.example.com")
|
env["BOT_HOST"] = bot_host or env.get("BOT_HOST", "bot.example.com")
|
||||||
env.setdefault("GITEA_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
env.setdefault("GITEA_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32])
|
||||||
@@ -383,8 +399,8 @@ def run(cmd, **kw):
|
|||||||
|
|
||||||
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None):
|
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None):
|
||||||
print("\n== writing configs")
|
print("\n== writing configs")
|
||||||
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers); write_streamers_json(streamers)
|
|
||||||
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook)
|
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook)
|
||||||
|
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers); write_copyparty(admin_pw, editors); write_dump_users(admin_pw, streamers); write_streamers_json(streamers)
|
||||||
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
|
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
|
||||||
print("== app data folders")
|
print("== app data folders")
|
||||||
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
|
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
|
||||||
@@ -409,7 +425,7 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
|
|||||||
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
||||||
print(f"\n== domain : {domain or '(not set)'}")
|
print(f"\n== domain : {domain or '(not set)'}")
|
||||||
print(f"== live : https://{live_host}/ (behind Authentik forward-auth; deploy live/dist -> /srv/live; WebRTC needs 8189 open)" if live_host else "== live : (not set — run --full)")
|
print(f"== live : https://{live_host}/ (behind Authentik forward-auth; deploy live/dist -> /srv/live; WebRTC needs 8189 open)" if live_host else "== live : (not set — run --full)")
|
||||||
print(f"== bot web : https://{bot_host}/ (behind Authentik forward-auth; create the Proxy Provider for it)" if bot_host else "== bot web : (not set — run --full)")
|
print(f"== bot web : https://{bot_host}/ (Authentik forward-auth; Caddy requires group '{read_env().get('BOT_REQUIRE_GROUP','bot-users') or 'any'}')" if bot_host else "== bot web : (not set — run --full)")
|
||||||
print(f"== gitea : https://{git_host}/ ssh port 2222 (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
|
print(f"== gitea : https://{git_host}/ ssh port 2222 (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
|
||||||
print(f"== authentik: https://{auth_host}/ (first run: https://{auth_host}/if/flow/initial-setup/)" if auth_host else "== authentik: (not set — run --full)")
|
print(f"== authentik: https://{auth_host}/ (first run: https://{auth_host}/if/flow/initial-setup/)" if auth_host else "== authentik: (not set — run --full)")
|
||||||
print(f"== jellyfin : https://{watch_host}/" if watch_host else "== jellyfin : (not set — run --full)")
|
print(f"== jellyfin : https://{watch_host}/" if watch_host else "== jellyfin : (not set — run --full)")
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- ./dock:/srv/dock:ro
|
- ./dock:/srv/dock:ro
|
||||||
|
- /srv/live:/srv/live:ro # live portal static build (deployLive.sh)
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Reference in New Issue
Block a user