Split hosts: media (gateway at /), files, clips, sync; live gains /dock, /dock/clock, /dump, /me, /time
configure.py: three new host roles (files/sync/clips) in --full, --hosts, .env, Caddy generator; main host serves about/ at the root (/about* redirects); /time only where it is fetched same-origin. about/index.html links across hosts; addCommunityMediaServer.sh asks for the new names; Caddyfile.example regenerated; README/FRESH-INSTALL/HOSTNAME-CHANGE (procedure C)/STATUS updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,204 @@
|
||||
# vanguard:main
|
||||
media.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
# dump endpoint (python service on the host, does its own auth)
|
||||
handle /dump* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
# /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.
|
||||
# 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
|
||||
forward_auth 127.0.0.1:9000 {
|
||||
uri /outpost.goauthentik.io/auth/caddy
|
||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
|
||||
# Syncthing admin UI (its own login; strip the prefix, present a local Host to pass its host check)
|
||||
redir /sync /sync/ 308
|
||||
handle_path /sync/* {
|
||||
reverse_proxy 127.0.0.1:8384 {
|
||||
header_up Host {upstream_hostport}
|
||||
}
|
||||
# who does Authentik say I am? (debug: shows exactly the identity headers the apps receive)
|
||||
handle /whoami {
|
||||
header Content-Type text/plain
|
||||
respond "user: {http.request.header.X-Authentik-Username}
|
||||
groups: {http.request.header.X-Authentik-Groups}
|
||||
email: {http.request.header.X-Authentik-Email}
|
||||
name: {http.request.header.X-Authentik-Name}"
|
||||
}
|
||||
|
||||
# OBS browser dock (static)
|
||||
handle_path /dock* {
|
||||
root * /srv/dock
|
||||
file_server
|
||||
@me path /me /me/*
|
||||
handle @me {
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity for the gateway page
|
||||
}
|
||||
|
||||
# everything else is copyparty (web UI + WebDAV)
|
||||
redir /about* / 308 # old bookmarks: the gateway page used to live at /about/
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3923 {
|
||||
flush_interval -1
|
||||
}
|
||||
root * /srv/about
|
||||
try_files {path} /index.html
|
||||
file_server # the gateway page is the root of the main host
|
||||
}
|
||||
}
|
||||
|
||||
# vanguard:apex
|
||||
example.com {
|
||||
redir https://media.example.com/ 302
|
||||
}
|
||||
|
||||
# vanguard:files
|
||||
files.example.com {
|
||||
encode zstd gzip
|
||||
# /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.
|
||||
# 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
|
||||
forward_auth 127.0.0.1:9000 {
|
||||
uri /outpost.goauthentik.io/auth/caddy
|
||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
# copyparty at the root. It honours X-Authentik-Username / -Groups only when the request also carries a header
|
||||
# whose secret NAME only this proxy knows (copyparty idp-h-key). Clients cannot forge identity.
|
||||
reverse_proxy 127.0.0.1:3923 {
|
||||
header_up X-Vanguard-EXAMPLEKEY 1
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# vanguard:sync
|
||||
sync.example.com {
|
||||
encode zstd gzip
|
||||
# /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.
|
||||
# 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
|
||||
forward_auth 127.0.0.1:9000 {
|
||||
uri /outpost.goauthentik.io/auth/caddy
|
||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
@lacks_group not header_regexp X-Authentik-Groups (^|\|)media\-admins(\||$)
|
||||
handle @lacks_group {
|
||||
respond "This page is for members of the 'media-admins' group." 403
|
||||
}
|
||||
# Syncthing admin UI (its own GUI login is disabled; the gate + group is the login)
|
||||
reverse_proxy 127.0.0.1:8384 {
|
||||
header_up Host {upstream_hostport}
|
||||
}
|
||||
}
|
||||
|
||||
# vanguard:clips
|
||||
clips.example.com {
|
||||
encode zstd gzip
|
||||
# /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.
|
||||
# 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
|
||||
forward_auth 127.0.0.1:9000 {
|
||||
uri /outpost.goauthentik.io/auth/caddy
|
||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
@me path /me /me/*
|
||||
handle @me {
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity + /me/clips self-service
|
||||
}
|
||||
handle {
|
||||
root * /srv/clips
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# vanguard:live
|
||||
live.example.com {
|
||||
encode zstd gzip
|
||||
# /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.
|
||||
# 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
|
||||
forward_auth 127.0.0.1:9000 {
|
||||
uri /outpost.goauthentik.io/auth/caddy
|
||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
handle /whoami {
|
||||
header Content-Type text/plain
|
||||
respond "user: {http.request.header.X-Authentik-Username}
|
||||
groups: {http.request.header.X-Authentik-Groups}"
|
||||
}
|
||||
@me path /me /me/*
|
||||
handle @me {
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity, stream tokens (Go Live + dock)
|
||||
}
|
||||
handle /time {
|
||||
reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip (clock page + Go Live)
|
||||
}
|
||||
handle /dump* {
|
||||
reverse_proxy 127.0.0.1:8080 # dump tonight (dock)
|
||||
}
|
||||
handle_path /dock* {
|
||||
root * /srv/dock
|
||||
file_server # OBS browser dock + /dock/clock/
|
||||
}
|
||||
handle_path /api/* {
|
||||
reverse_proxy 127.0.0.1:9997
|
||||
}
|
||||
handle_path /webrtc/* {
|
||||
# Go Live sends MediaMTX credentials in X-Stream-Auth so Authentik's forward-auth never sees an
|
||||
# Authorization header; Caddy turns it into Authorization only on the hop to MediaMTX.
|
||||
@creds header X-Stream-Auth *
|
||||
reverse_proxy @creds 127.0.0.1:8889 {
|
||||
header_up Authorization {http.request.header.X-Stream-Auth}
|
||||
header_up -X-Stream-Auth
|
||||
}
|
||||
reverse_proxy 127.0.0.1:8889
|
||||
}
|
||||
handle_path /hls/* {
|
||||
reverse_proxy 127.0.0.1:8888
|
||||
}
|
||||
handle {
|
||||
root * /srv/live
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# vanguard:bot
|
||||
bot.example.com {
|
||||
encode zstd gzip
|
||||
# /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.
|
||||
# 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
|
||||
forward_auth 127.0.0.1:9000 {
|
||||
uri /outpost.goauthentik.io/auth/caddy
|
||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
|
||||
trusted_proxies private_ranges
|
||||
}
|
||||
@lacks_group not header_regexp X-Authentik-Groups (^|\|)bot\-users(\||$)
|
||||
handle @lacks_group {
|
||||
respond "This page is for members of the 'bot-users' group." 403
|
||||
}
|
||||
reverse_proxy 127.0.0.1:8000
|
||||
}
|
||||
|
||||
# vanguard:git
|
||||
git.example.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
# vanguard:auth
|
||||
auth.example.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9000
|
||||
}
|
||||
|
||||
# vanguard:watch
|
||||
watch.example.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:8096
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Fresh install — from an empty OVH box to a working stack with the deploy pipeline
|
||||
|
||||
Everything in order. Scripted steps are commands; manual steps are click lists. `HOST` = your main hostname
|
||||
(ours: `ovh1.dumpstervigilantes.rip`), `BASE` = the parent domain. Detailed background for each part is in
|
||||
(ours: `media.dumpstervigilantes.rip`), `BASE` = the parent domain. Detailed background for each part is in
|
||||
README.md (section numbers given).
|
||||
|
||||
## 0. Before touching the server
|
||||
- DNS: `A` record for `HOST` → the server IP; `CNAME`s `auth`, `watch`, `git`, `bot`, `live` → `HOST`.
|
||||
Optional: the bare domain `A` → server IP (it redirects to the watch site or `/about/`).
|
||||
- DNS: `A` record for `HOST` → the server IP; `CNAME`s `files`, `clips`, `sync`, `live`, `auth`, `watch`, `git`, `bot` → `HOST`.
|
||||
Optional: the bare domain `A` → server IP (it redirects to `https://HOST/`).
|
||||
- Discord developer portal → New Application (one app serves Authentik login, the Jellyfin plugin's bot and the
|
||||
Discord bot):
|
||||
- OAuth2 → Redirects: `https://auth.BASE/source/oauth/callback/discord/` (the last segment is the Authentik
|
||||
@@ -39,7 +39,7 @@ build, **firewall** (keep this session open; test a new ssh in a second terminal
|
||||
|
||||
cd ~/vanguard-stream && ./configure.py --full
|
||||
|
||||
Asks: main hostname; `auth`/`watch`/`git`/`bot`/`live` hostnames (defaults); bare domain (optional) and where it
|
||||
Asks: main hostname (the gateway page); `files`/`clips`/`sync`/`auth`/`watch`/`git`/`bot`/`live` hostnames (defaults); bare domain (optional) and where it
|
||||
redirects; Discord bot token + server ID (optional now, needed for the plugin feed); "is live" webhook (optional).
|
||||
Generates `.env` with every secret, the configs, starts all containers, waits, then runs the post-start fixups
|
||||
(Syncthing GUI auth off, Gitea admin user, Jellyfin plugin). `./configure.py --show` prints every credential
|
||||
@@ -111,11 +111,11 @@ Log in at `https://git.BASE/` as `GITEA_ADMIN_USER` / `GITEA_ADMIN_PASSWORD` fro
|
||||
`DEPLOY_AUTO=1` in `.env` → `./configure.py --apply`; CI: `git mv .gitea/draft-workflows/ci.yml .gitea/workflows/ci.yml`.
|
||||
|
||||
## 9. Streamers, gamers, editors (README §5, §5a, §6)
|
||||
- Streamers: OBS → WebSocket server on; Docks → Custom Browser Dock `https://HOST/dock/` → Discord login →
|
||||
- Streamers: OBS → WebSocket server on; Docks → Custom Browser Dock `https://live.BASE/dock/` → Discord login →
|
||||
Settings: OBS WebSocket password; Timecode band → "Add band + clock source" (not streaming). Then title → Go Live.
|
||||
- Gamers: `https://HOST/clips/` → paste their Syncthing Device ID → accept device + folder on their PC (Send Only).
|
||||
Syncthing UI (`https://HOST/sync/`, media-admins): Actions → Settings → Connections → incoming rate limit 12500 KiB/s.
|
||||
- Everyone: `https://HOST/about/`.
|
||||
- Gamers: `https://clips.BASE/` → paste their Syncthing Device ID → accept device + folder on their PC (Send Only).
|
||||
Syncthing UI (`https://sync.BASE/`, media-admins): Actions → Settings → Connections → incoming rate limit 12500 KiB/s.
|
||||
- Everyone: `https://HOST/` (the gateway page; editors browse at `https://files.BASE/`).
|
||||
|
||||
## 10. Afterwards
|
||||
- Pin `AUTHENTIK_TAG` in `.env` to the running version. Rotate any secret that was ever pasted into chat.
|
||||
@@ -128,7 +128,7 @@ Log in at `https://git.BASE/` as `GITEA_ADMIN_USER` / `GITEA_ADMIN_PASSWORD` fro
|
||||
|
||||
Conventions: commands prefixed `dev$` run on your workstation (it has the repo at `~/claudevs/vanguard-stream`
|
||||
and your SSH key); `srv$` run on the server over ssh as `debian`. Replace `HOST` / `BASE` with your names
|
||||
(ours: `ovh1.dumpstervigilantes.rip` / `dumpstervigilantes.rip`). Click paths are written `App → Menu → Item`.
|
||||
(ours: `media.dumpstervigilantes.rip` / `dumpstervigilantes.rip`). Click paths are written `App → Menu → Item`.
|
||||
Every step ends with how you know it worked.
|
||||
|
||||
## A. DNS and Discord (no server yet)
|
||||
@@ -180,7 +180,7 @@ D4. Log out/in once for the docker group: dev$ ssh debian@HOST 'docker ps >/dev
|
||||
## E. Configure and start the stack
|
||||
|
||||
E1. dev$ ssh -t debian@HOST 'cd ~/vanguard-stream && ./configure.py --full'
|
||||
Answers: HOST; Enter ×5 for the sub-hostnames; bare domain (Enter = BASE, or blank to skip) and its redirect;
|
||||
Answers: HOST; Enter ×8 for the sub-hostnames; bare domain (Enter = BASE, or blank to skip) and its redirect;
|
||||
bot token (from A2) ; server ID (A3) ; "is live" webhook (optional); y to apply.
|
||||
(Pulls ~2 GB of images, starts 13 containers, waits, runs post-start fixups.)
|
||||
E2. dev$ ssh debian@HOST 'cd ~/vanguard-stream && docker compose ps --format "{{.Name}} {{.Status}}"'
|
||||
@@ -282,13 +282,13 @@ I6. (later, when trusted) dev$ ssh debian@HOST 'cd ~/vanguard-stream && sed -i "
|
||||
## J. People
|
||||
|
||||
J1. Streamer (OBS): Tools → WebSocket Server Settings → Enable, set a password → OK.
|
||||
Docks → Custom Browser Docks → name Vanguard, URL https://HOST/dock/ → Apply → log in with Discord inside the dock.
|
||||
Docks → Custom Browser Docks → name Vanguard, URL https://live.BASE/dock/ → Apply → log in with Discord inside the dock.
|
||||
Dock → Settings: OBS WebSocket password → Save. Dock → Timecode band → "Add band + clock source" (while not streaming).
|
||||
Dock: title → Go Live. Check: dev$ ssh debian@HOST 'journalctl -u dumpd --since -5m --no-pager | grep mtx-auth | tail -1' → "allow publish user=…".
|
||||
J2. Gamer: https://HOST/clips/ → follow the three steps on the page (install Syncthing, paste Device ID, accept device + folder as Send Only).
|
||||
Admin check: https://HOST/sync/ shows their device connected. Once: Syncthing → Actions → Settings → Connections → Incoming Rate Limit 12500 KiB/s.
|
||||
J3. Editor: https://HOST/ (copyparty) → browse, download; https://HOST/finals/ to upload. Needs the editors role.
|
||||
J4. Everyone: https://HOST/about/.
|
||||
J2. Gamer: https://clips.BASE/ → follow the three steps on the page (install Syncthing, paste Device ID, accept device + folder as Send Only).
|
||||
Admin check: https://sync.BASE/ shows their device connected. Once: Syncthing → Actions → Settings → Connections → Incoming Rate Limit 12500 KiB/s.
|
||||
J3. Editor: https://files.BASE/ (copyparty) → browse, download; https://files.BASE/finals/ to upload. Needs the editors role.
|
||||
J4. Everyone: https://HOST/ (gateway page).
|
||||
|
||||
## K. Verify the whole pipeline once
|
||||
|
||||
|
||||
@@ -5,13 +5,17 @@ domain. Both are mostly `configure.py --full` plus a known list of places outsid
|
||||
Do it between streams: the main host is MediaMTX's WebRTC advertised host, so a main-host change needs a
|
||||
MediaMTX restart.
|
||||
|
||||
Roles: `main` (gateway page at `/`), `files` (copyparty), `clips`, `sync`, `live` (portal + `/dock/`, `/dock/clock/`,
|
||||
`/dump`, `/me`, `/time`), `auth`, `watch`, `git`, `bot`. Ours: `media.`, `files.`, `clips.`, `sync.`, `live.`, … under
|
||||
`dumpstervigilantes.rip`.
|
||||
|
||||
## Where hostnames live
|
||||
|
||||
### Generated on the server by `configure.py` (regenerated by `--full`, nothing to edit by hand)
|
||||
| Place | Holds |
|
||||
|---|---|
|
||||
| `Caddyfile` | every site block — the source of truth `configure.py` reads back (`# vanguard:<role>` markers) |
|
||||
| `.env` | `MAIN_HOST`, `WATCH_HOST`, `GIT_HOST`, `BOT_HOST`, `LIVE_HOST`, `APEX_HOST`, `APEX_REDIRECT` |
|
||||
| `.env` | `MAIN_HOST`, `FILES_HOST`, `CLIPS_HOST`, `SYNC_HOST`, `WATCH_HOST`, `GIT_HOST`, `BOT_HOST`, `LIVE_HOST`, `APEX_HOST`, `APEX_REDIRECT` |
|
||||
| `mediamtx.yml` | `webrtcAdditionalHosts: [MAIN_HOST]` (needs a MediaMTX restart to take effect) |
|
||||
| `docker-compose.yml` → container env | Jellyfin `PublishedServerUrl` (WATCH), Gitea `DOMAIN/ROOT_URL/SSH_DOMAIN` (GIT), bot `WEB_PUBLIC_URL` (BOT), MediaMTX `LIVE_HOST` (text of the "is live" post), act_runner `GITEA_INSTANCE_URL` (GIT). `compose up -d` in the apply recreates the affected containers (seconds each) |
|
||||
| Caddy TLS | certificates for new names are fetched automatically; old ones linger harmlessly |
|
||||
@@ -19,9 +23,9 @@ MediaMTX restart.
|
||||
### Pages in the repo that derive sibling hosts from the browser's location (label names hard-coded)
|
||||
| File | Assumes |
|
||||
|---|---|
|
||||
| `about/index.html` | `auth.`, `watch.`, `live.`, `git.`, `bot.` + the parent of the current host |
|
||||
| `dock/index.html` | `auth.` (sign-out), ingest host prefilled from the current host |
|
||||
| `switchToClone.sh`, `deployLive.sh` | defaults for `git.dumpstervigilantes.rip` / `ovh1.dumpstervigilantes.rip` (override by env/arg, or edit) |
|
||||
| `about/index.html` | `files.`, `clips.`, `sync.`, `live.`, `auth.`, `watch.`, `git.`, `bot.` + the parent of the current host |
|
||||
| `dock/index.html` | `auth.` (sign-out), ingest host prefilled from the current host (`live.`; any name of the box works for RTMP/SRT) |
|
||||
| `switchToClone.sh`, `deployLive.sh` | defaults for `git.dumpstervigilantes.rip` / `media.dumpstervigilantes.rip` (override by env/arg, or edit) |
|
||||
| `README.md`, `FRESH-INSTALL.md` | examples only |
|
||||
Renaming a *label* (e.g. `watch` → `video`) means editing the first two; changing only the domain does not.
|
||||
|
||||
@@ -41,9 +45,9 @@ Renaming a *label* (e.g. `watch` → `video`) means editing the first two; chang
|
||||
| Gitea runner | registered with the instance URL → `./registerGiteaRunner.sh` again | git |
|
||||
| Server clone | `git remote set-url origin ssh://git@GIT:2222/techy/Media-Server-Setup.git`; `~/.ssh/config.vanguard` Host line | git |
|
||||
| Dev box | `git remote set-url origin …` | git |
|
||||
| Streamers' OBS | dock URL (Custom Browser Docks), dock Settings → ingest host (re-save), clock browser source URL (dock → Timecode band → "Add band + clock source" again), any manual RTMP/SRT configs (copy from the dock) | main |
|
||||
| Streamers' OBS | dock URL (Custom Browser Docks), dock Settings → ingest host (re-save), clock browser source URL (dock → Timecode band → "Add band + clock source" again), any manual RTMP/SRT configs (copy from the dock) | live |
|
||||
| Jellyfin apps on phones/TVs | server address | watch |
|
||||
| Bookmarks, Discord pinned links | `/about/`, `/clips/`, watch, live | all |
|
||||
| Bookmarks, Discord pinned links | main (gateway), clips, files, watch, live | all |
|
||||
Not affected: Syncthing pairings (device IDs), MariaDB, stream tokens, the Gitea webhook (gateway IP), Authentik users/groups/policies.
|
||||
|
||||
## A. Renaming one subdomain (example: `watch.BASE` → `video.BASE`)
|
||||
@@ -77,10 +81,10 @@ Per-host extras when it is a different host:
|
||||
Plan a maintenance window: Caddy, Jellyfin, Gitea, the bot and MediaMTX all restart, and every member logs in again
|
||||
(the Authentik session cookie is bound to the base domain).
|
||||
|
||||
1. DNS on the new domain: `A HOST2 → IP`, CNAMEs `auth`, `watch`, `git`, `bot`, `live` → `HOST2` (optional bare domain A).
|
||||
1. DNS on the new domain: `A HOST2 → IP`, CNAMEs `files`, `clips`, `sync`, `live`, `auth`, `watch`, `git`, `bot` → `HOST2` (optional bare domain A).
|
||||
Verify they resolve before continuing. Leave the old domain's records in place for now.
|
||||
2. Discord developer portal → OAuth2 → add redirect `https://auth.NEW/source/oauth/callback/discord/` (keep the old one until step 9).
|
||||
3. Server, between streams: `./configure.py --full` → new main hostname; the five sub-hostnames default to the new
|
||||
3. Server, between streams: `./configure.py --full` → new main hostname; the eight sub-hostnames default to the new
|
||||
base (Enter); bare domain; `y`. Then `docker compose restart mediamtx` (webrtcAdditionalHosts changed).
|
||||
All sites answer on the new names within a minute (certificates).
|
||||
4. Authentik (log in at the NEW auth host with akadmin — Discord login is broken until step 5 is done):
|
||||
@@ -95,11 +99,30 @@ Plan a maintenance window: Caddy, Jellyfin, Gitea, the bot and MediaMTX all rest
|
||||
`./registerGiteaRunner.sh`; server: `cd ~/vanguard-stream && git remote set-url origin ssh://git@git.NEW:2222/techy/Media-Server-Setup.git`
|
||||
and edit `~/.ssh/config.vanguard` (Host line); dev box: `git remote set-url origin …`. Test `sudo vanguard-deploy pull`.
|
||||
8. Repo defaults: `switchToClone.sh` (REPO_SSH), `deployLive.sh` (HOST) — commit, push, pull.
|
||||
9. Streamers: re-add the dock at `https://HOST2/dock/`, re-save Settings, "Add band + clock source" again.
|
||||
Members: `https://HOST2/about/`; Jellyfin apps → new server address. Remove the old Discord redirect and the old DNS.
|
||||
9. Streamers: re-add the dock at `https://live.NEW/dock/`, re-save Settings, "Add band + clock source" again.
|
||||
Members: `https://HOST2/`; Jellyfin apps → new server address. Remove the old Discord redirect and the old DNS.
|
||||
|
||||
## C. Splitting the single main host into media / files / clips / sync (done 2026-11-01)
|
||||
|
||||
Before: one main host (`ovh1.BASE`) served copyparty at `/`, `/about/`, `/clips/`, `/sync/`, `/dock/`, `/dump`, `/time`.
|
||||
After: `media.BASE` = gateway at `/`; `files.BASE` = copyparty; `clips.BASE`; `sync.BASE`; `live.BASE` gains `/dock/`,
|
||||
`/dock/clock/`, `/dump`, `/me`, `/time`, `/whoami`. `/time` is only on `live` because the two pages that fetch it
|
||||
(the clock source and Go Live) are same-origin there. Authentik needs nothing: the domain-level provider covers every
|
||||
name under BASE, the outpost host (`auth.`) is unchanged.
|
||||
|
||||
1. DNS: CNAMEs `media`, `files`, `clips`, `sync` → the box (keep `ovh1` for ssh; it is no longer a web host).
|
||||
2. Dev box: pull/upload the repo (`configure.py`, `about/`, `addCommunityMediaServer.sh`, docs).
|
||||
3. Server, between streams: `./configure.py --full` → main hostname `media.BASE`; Enter for the sub-hostnames
|
||||
(defaults `files.`, `clips.`, `sync.`, …); bare domain redirect → `https://media.BASE/`; `y`.
|
||||
Then `docker compose restart mediamtx` (webrtcAdditionalHosts is now `media.`) and `./deployLive.sh` (portal
|
||||
build picks up nothing new, but confirms `/srv/live` is served on `live.`).
|
||||
4. Streamers: OBS → Custom Browser Docks → replace the dock URL with `https://live.BASE/dock/`; dock Settings →
|
||||
save; Timecode band → "Add band + clock source" again (the clock browser source URL moved).
|
||||
5. Discord: re-pin `https://media.BASE/` (members), `https://clips.BASE/` (gamers), `https://files.BASE/` (editors).
|
||||
6. Old `ovh1` bookmarks stop working (no Caddy block, TLS fails) — point people at the gateway.
|
||||
|
||||
## Checklist after any change
|
||||
dev$ for h in HOST auth.BASE watch.BASE git.BASE live.BASE bot.BASE; do printf '%-26s ' $h; curl -s -o /dev/null -m 10 -w '%{http_code}\n' https://$h/; done
|
||||
dev$ for h in HOST files.BASE clips.BASE sync.BASE live.BASE auth.BASE watch.BASE git.BASE bot.BASE; do printf '%-26s ' $h; curl -s -o /dev/null -m 10 -w '%{http_code}\n' https://$h/; done
|
||||
https://HOST/whoami → your identity (gate works on the new name)
|
||||
https://watch.BASE/sso/OID/start/authentik → Jellyfin SSO round trip
|
||||
https://git.BASE/user/login → "Sign in with authentik" round trip
|
||||
|
||||
45
README.md
45
README.md
@@ -2,6 +2,21 @@
|
||||
|
||||
> **FRESH-INSTALL.md** = the ordered walkthrough from an empty box to a working stack and pipeline, manual steps included.
|
||||
> **HOSTNAME-CHANGE.md** = every place a hostname lives and how to rename a subdomain or move to a new base domain safely.
|
||||
|
||||
**Hostnames** (all CNAMEs to the main host; one Discord login covers all of them):
|
||||
|
||||
| Host | What | Who |
|
||||
|---|---|---|
|
||||
| `media.BASE` (main) | gateway page at `/` (role-aware links + how-tos), `/whoami`, `/logout` | everyone |
|
||||
| `files.BASE` | copyparty: browse, scrub, download, upload finals | editors / media-admins |
|
||||
| `clips.BASE` | self-service clip intake (pair your Syncthing) | any member |
|
||||
| `sync.BASE` | Syncthing admin UI | media-admins |
|
||||
| `live.BASE` | live portal, Go Live, OBS dock `/dock/`, clock source `/dock/clock/`, `/dump`, `/me`, `/time` | everyone (`LIVE_REQUIRE_GROUP`) |
|
||||
| `watch.BASE` | Jellyfin | Discord via OIDC |
|
||||
| `git.BASE`, `bot.BASE`, `auth.BASE` | Gitea, bot web UI (`bot-users`), Authentik | — |
|
||||
|
||||
`/time` (the server clock the timecode strip syncs to) lives only on `live` because browsers fetch it
|
||||
same-origin: both pages that use it, the clock source and Go Live, are on that host.
|
||||
> **STATUS.md** = what runs, who does what, what's unfinished (kept current). **CommunityDiscordMedia.md** = design + phases.
|
||||
|
||||
Ingest from OBS, browse/scrub/download for editors, automatic normalization to one editing
|
||||
@@ -82,7 +97,7 @@ 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
|
||||
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.
|
||||
Syncthing's admin UI is proxied at `https://HOST/sync/` behind the Authentik gate + group `media-admins`; its
|
||||
Syncthing's admin UI is proxied at `https://sync.BASE/` behind the Authentik gate + group `media-admins`; its
|
||||
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
|
||||
@@ -103,7 +118,7 @@ There are no stream passwords or keys to hand out: the dock mints a **12-hour pu
|
||||
logged-in Discord user each time Go Live is pressed and sets it as the OBS stream key itself.
|
||||
|
||||
OBS → Tools → WebSocket Server Settings → enable, set a password.
|
||||
OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/` → log in with Discord once.
|
||||
OBS → Docks → Custom Browser Docks → `https://live.BASE/dock/` → log in with Discord once.
|
||||
In the dock's Settings: ingest host (prefilled), OBS WebSocket password/port, optional title prefix/suffix
|
||||
(`{date}` → YYYY-MM-DD, e.g. prefix `HelmetCam-` or suffix `-{date}`).
|
||||
Per stream: type a title, click **Go Live**. The dock shows the final stream name (`<you>/<Title>`) as you type
|
||||
@@ -120,7 +135,7 @@ Browser streaming without OBS: the live portal's **Go Live** (Windows Chrome 141
|
||||
|
||||
## 5a. Onboarding a gamer's clips (Syncthing) — self-service
|
||||
|
||||
Gamers do it themselves at **https://HOST/clips/** (Discord login): the page shows our Syncthing Device ID and
|
||||
Gamers do it themselves at **https://clips.BASE/** (Discord login): the page shows our Syncthing Device ID and
|
||||
asks for theirs; on submit, dumpd creates `clips/<username>` with the video-only `.stignore`, adds their device
|
||||
to our Syncthing (REST API, key read from `/srv/media/.syncthing/config.xml`) and creates the receive-only
|
||||
folder `clips-<username>` shared with them (min free 300 GB). They accept the device + folder on their PC,
|
||||
@@ -136,16 +151,16 @@ Manual fallback (admin, same result for the server folder):
|
||||
./addClipUser.sh <username> # creates /srv/media/clips/<username> with the video-only .stignore
|
||||
|
||||
Their side (Windows: Syncthing installer or SyncTrayzor; Mac/Linux: Syncthing): open the UI, **Actions → Show ID**,
|
||||
send you the Device ID. In their UI: **Add Remote Device** with the server's ID (ours: `/sync/` → Actions → Show ID),
|
||||
send you the Device ID. In their UI: **Add Remote Device** with the server's ID (ours: `sync.BASE` → Actions → Show ID),
|
||||
then **Add Folder** on their clip directory (ShadowPlay/Medal/Steam saves), Sharing tab → tick the server,
|
||||
Folder Type **Send Only**, Save.
|
||||
|
||||
Our side at https://HOST/sync/: **Add Remote Device** → paste their ID, name it after them, Save. When their folder
|
||||
Our side at https://sync.BASE/: **Add Remote Device** → paste their ID, name it after them, Save. When their folder
|
||||
offer pops up: **Add** → Folder Path `/var/syncthing/clips/<username>`, Folder Type **Receive Only**, Advanced →
|
||||
Minimum Free Disk Space **300 GB**, Save. Clips start arriving; the sweep normalizes them; Jellyfin shows the
|
||||
folder as that person's show. Their own folder structure is mirrored; only video files are pulled.
|
||||
|
||||
## 5a². Gateway page — https://HOST/about/
|
||||
## 5a². Gateway page — https://media.BASE/ (the main host root; `/about/` redirects here)
|
||||
|
||||
Role-aware landing page (Discord login): everyone sees Watch / Live / Send clips / Sign out with short how-tos;
|
||||
`streamers` get the dock + OBS setup steps; `editors` the browser + multicam/timecode notes; `media-admins`
|
||||
@@ -162,7 +177,7 @@ already `.mp4`), the raw's name is appended to `.stlocal` (an `#include`d ignore
|
||||
drops it without flagging or re-pulling), and the raw is deleted locally — it still exists on the
|
||||
gamer's PC. `configure.py` creates the folder + ignore file for each streamer.
|
||||
|
||||
In the UI (https://HOST/sync/):
|
||||
In the UI (https://sync.BASE/):
|
||||
- each clips folder → path `/var/syncthing/clips/<user>`, **Folder Type: Receive Only**,
|
||||
Edit → Advanced → **Minimum Free Disk Space: 300 GB** (so clips can never fill the mirror and stall
|
||||
the recordings).
|
||||
@@ -171,8 +186,8 @@ In the UI (https://HOST/sync/):
|
||||
|
||||
## 5c. Authentik (Discord login) and Jellyfin
|
||||
|
||||
DNS: `auth` and `watch` as CNAMEs to the main host. Then run `./addCommunityMediaServer.sh` — it asks
|
||||
for both hostnames, checks DNS, generates `.env` (Authentik secret + DB password; never commit it),
|
||||
DNS: `auth`, `watch` (and `files`, `clips`, `sync`, `live`, `git`, `bot`) as CNAMEs to the main host. Then run `./addCommunityMediaServer.sh` — it asks
|
||||
for every hostname, checks DNS, generates `.env` (Authentik secret + DB password; never commit it),
|
||||
adds the Caddy site blocks, starts the containers, waits for them and prints the steps below with your
|
||||
hostnames filled in. (`./configure.py --full` asks for the same hostnames if you prefer.) Then:
|
||||
|
||||
@@ -285,7 +300,7 @@ from `/me/stream-token` for the logged-in Discord user (member of `streamers`).
|
||||
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.
|
||||
|
||||
## 5g. Authentik forward-auth for the Caddy-gated hosts (main host, `live.`, `bot.`)
|
||||
## 5g. Authentik forward-auth for the Caddy-gated hosts (`media.`, `files.`, `clips.`, `sync.`, `live.`, `bot.`)
|
||||
|
||||
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.
|
||||
@@ -296,8 +311,8 @@ Design: **Authentik authenticates once for the whole domain; Caddy authorizes pe
|
||||
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), `SYNC_REQUIRE_GROUP=media-admins` (the `/sync` Syncthing UI on the main host).
|
||||
On the main host copyparty itself authorizes by group (`editors`, `media-admins`); the dump endpoint lets
|
||||
(empty = any logged-in member), `SYNC_REQUIRE_GROUP=media-admins` (the Syncthing UI on `sync.`).
|
||||
On `files.` copyparty itself authorizes by group (`editors`, `media-admins`); the dump endpoint lets
|
||||
`admins`/`media-admins` dump anyone's night. Leave "Intercept header authentication" **off**: login is
|
||||
Discord only, no app passwords, no WebDAV. `./configure.py` apply regenerates the Caddy blocks; a user outside the
|
||||
group gets a 403 from Caddy before the app sees the request.
|
||||
@@ -325,7 +340,7 @@ Jellyfin and Gitea use OIDC providers instead, not this.
|
||||
|
||||
## 6. Editors
|
||||
|
||||
Log in with Discord at https://media.example.com/ (copyparty behind Caddy + Authentik). Access is by Authentik
|
||||
Log in with Discord at https://files.BASE/ (copyparty behind Caddy + Authentik). Access is by Authentik
|
||||
group: **editors** read streams and clips and write `/finals`; **media-admins** have full rights everywhere
|
||||
including `/trash`. Scrub any `.mp4` in the browser, download what you want.
|
||||
|
||||
@@ -371,7 +386,7 @@ Where the time comes from, best first:
|
||||
(`bin/vgstrip.py`) at the start, middle and end of each recording → the timecode is frame-accurate at the
|
||||
start and the sidecar records drift over the file. Game image untouched. OBS: dock → *Timecode band* →
|
||||
"Add band + clock source" (canvas grows by W/160 rows, a locked browser source `Vanguard Clock` →
|
||||
`/dock/clock/` sits in the band; only while not streaming). Go Live: the "Timecode band" checkbox (default on).
|
||||
`https://live.BASE/dock/clock/` sits in the band; only while not streaming). Go Live: the "Timecode band" checkbox (default on).
|
||||
2. **Server arrival time** (no band) — microseconds in the recording filename. Within ~0.1–0.3 s per streamer
|
||||
(encoder + network delay); coarse but always available.
|
||||
|
||||
@@ -535,4 +550,4 @@ evening; after 17:00 it is tonight. The 05:00 trash purge is 05:00 Eastern.
|
||||
|------|------|------|
|
||||
| normalize-sweep.timer | every 5 min | remux/re-encode finished files to the editing standard |
|
||||
| trash-purge.timer | daily 05:00 Eastern | deletes trash older than 3 days |
|
||||
| dumpd.service | always | the dump endpoint on 127.0.0.1:8080, behind Caddy at /dump |
|
||||
| dumpd.service | always | the dump endpoint on 127.0.0.1:8080, behind Caddy on `live.` at /dump, /me, /time and on `clips.` at /me/clips |
|
||||
|
||||
22
STATUS.md
22
STATUS.md
@@ -1,7 +1,7 @@
|
||||
# Vanguard stack — what runs, what people do, what's unfinished
|
||||
|
||||
Living document. Updated whenever something is deployed or changes behaviour. Last update: 2026-10-31 (evening).
|
||||
Host: `ovh1.dumpstervigilantes.rip` (OVH SYS-1, Debian 13). Bundle: `~/vanguard-stream` on the box.
|
||||
Living document. Updated whenever something is deployed or changes behaviour. Last update: 2026-11-01.
|
||||
Box: OVH SYS-1, Debian 13, ssh `ovh1.dumpstervigilantes.rip`. Web hosts (one Discord login): `media.` gateway at `/`, `files.` copyparty, `clips.`, `sync.`, `live.` (portal + `/dock/`, `/dump`, `/time`), `watch.`, `git.`, `bot.`, `auth.` — all under `dumpstervigilantes.rip`. **Restructure committed 2026-11-01, server deploy pending** (see Unfinished). Bundle: `~/vanguard-stream` on the box.
|
||||
Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea (`ssh://git@git.dumpstervigilantes.rip:2222/techy/Media-Server-Setup.git`).
|
||||
|
||||
## Runs on its own
|
||||
@@ -29,18 +29,18 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
||||
| Who | Activity | Where | Login |
|
||||
|---|---|---|---|
|
||||
| Streamer | One-time: dock → Timecode band → "Add band + clock source" (OBS) or leave the Go Live checkbox on — gives editors frame-accurate timecode | OBS dock / Go Live | live |
|
||||
| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://ovh1…/dock/` | Discord login in the dock; dock mints a 12 h token per Go Live — **no passwords, verified live** |
|
||||
| Gamer | Self-serve pairing at `/clips/` (paste Syncthing Device ID → folder + share created); then nothing, clips save where they always did | `https://ovh1…/clips/` + Syncthing | Discord (any server member) |
|
||||
| Everyone | Landing page with role-aware links and instructions | `https://ovh1…/about/` | Discord |
|
||||
| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://live.…/dock/` | Discord login in the dock; dock mints a 12 h token per Go Live — **no passwords, verified live** |
|
||||
| Gamer | Self-serve pairing (paste Syncthing Device ID → folder + share created); then nothing, clips save where they always did | `https://clips.…/` + Syncthing | Discord (any server member) |
|
||||
| Everyone | Landing page with role-aware links and instructions | `https://media.…/` | Discord |
|
||||
| Editor | Multicam: drop clips on a timeline, sync by the embedded timecode track (frame-accurate when the band was on), cross-check with voice audio; export at the game resolution to drop the band | Resolve / Premiere | — |
|
||||
| Editor | Browse by streamer/title, scrub in browser, download originals (zip/tar for folders), upload finals | copyparty `https://ovh1…/` | Discord (Authentik); rights by group `editors` / `media-admins` |
|
||||
| Editor | Browse by streamer/title, scrub in browser, download originals (zip/tar for folders), upload finals | copyparty `https://files.…/` | Discord (Authentik); rights by group `editors` / `media-admins` |
|
||||
| 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** |
|
||||
| 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 | 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 |
|
||||
| Admin | Hostnames, bot token/guild, webhook; streamers/editors are Discord roles → Authentik groups | `./configure.py` | ssh |
|
||||
| Admin | Watch pairings / folder states (gamers self-serve the setup) | Syncthing UI `https://ovh1…/sync/` | Discord + group `media-admins` (Syncthing's own login disabled) |
|
||||
| Admin | Watch pairings / folder states (gamers self-serve the setup) | Syncthing UI `https://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 | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh |
|
||||
| Admin | Register the CI runner | `./registerGiteaRunner.sh` | ssh |
|
||||
@@ -51,10 +51,10 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
||||
|---|---|---|
|
||||
| MediaMTX | RTMP/SRT ingest, records fMP4; publish auth delegated to dumpd (tokens via `/mtx-auth`) | live, token auth verified |
|
||||
| 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`, `/me` identity, `/me/clips` Syncthing self-service; OBS dock with timecode-band setup | identity from Authentik headers | live |
|
||||
| `/about/` gateway, `/clips/` self-service | static pages behind the gate | — | live (first real pairing pending) |
|
||||
| dumpd + dock | dump, stream tokens, MediaMTX auth hook, `/time`, `/me` identity (on `live.`), `/me/clips` Syncthing self-service (on `clips.`); OBS dock with timecode-band setup | identity from Authentik headers | live |
|
||||
| `media.` gateway, `clips.` self-service | static pages behind the gate | — | live (first real pairing pending) |
|
||||
| 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`, own GUI auth off | 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 |
|
||||
| nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live |
|
||||
| Caddy | TLS + routing for all hostnames | live |
|
||||
@@ -70,6 +70,8 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
||||
|
||||
## Unfinished, in rough priority
|
||||
|
||||
0. **Hostname restructure deploy** (committed 2026-11-01): DNS CNAMEs `media`, `files`, `clips`, `sync`; `./configure.py --full` with `media.` as main; MediaMTX restart between streams; `./deployLive.sh`; streamers re-add the dock at `live./dock/` and redo "Add band + clock source"; re-pin Discord links. Procedure C in HOSTNAME-CHANGE.md.
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<h2>Contribute: stream</h2>
|
||||
<p class="lead" data-group="streamers" data-yes="You are in <code>streamers</code>: you can publish under your own name. No passwords, no stream keys to keep." data-no="Admin view — you are <b>not</b> in <code>streamers</code>, so Go Live will refuse a token for you until you get the role."></p>
|
||||
<div class="links">
|
||||
<a class="card" href="/dock/"><b>OBS dock</b><span>title → Go Live; Dump tonight; manual setup</span></a>
|
||||
<a class="card" id="l-dock" href="#"><b>OBS dock</b><span>title → Go Live; Dump tonight; manual setup</span></a>
|
||||
<a class="card" id="l-golive" href="#"><b>Go Live from the browser</b><span>Windows Chrome: share the game window, no OBS</span></a>
|
||||
</div>
|
||||
<details><summary>OBS setup (once)</summary>
|
||||
@@ -72,12 +72,12 @@
|
||||
<h2>Contribute: your game clips</h2>
|
||||
<p class="lead">Anyone in the server. Pair your gaming PC once; after that your clips upload themselves and appear in Jellyfin under your name.</p>
|
||||
<div class="links">
|
||||
<a class="card" href="/clips/"><b>Clip setup & status</b><span>pair your PC, see if it is connected and syncing</span></a>
|
||||
<a class="card" id="l-clips" href="#"><b>Clip setup & status</b><span>pair your PC, see if it is connected and syncing</span></a>
|
||||
</div>
|
||||
<details open><summary>Three steps</summary>
|
||||
<ol>
|
||||
<li>Install <a href="https://syncthing.net/downloads/" target="_blank" rel="noopener">Syncthing</a> on the PC your clips save to (Windows: Syncthing Tray works well). Open it → Actions → Show ID.</li>
|
||||
<li>Paste that Device ID on the <a href="/clips/">clips page</a> and press Connect.</li>
|
||||
<li>Paste that Device ID on the <a class="l-clips" href="#">clips page</a> and press Connect.</li>
|
||||
<li>Back in your Syncthing: accept the new device <b>Vanguard</b>, then accept the folder it offers, point it at your clip directory (ShadowPlay / Medal / Steam), Folder Type <b>Send Only</b>.</li>
|
||||
</ol>
|
||||
<ul>
|
||||
@@ -91,8 +91,8 @@
|
||||
<h2>Edit</h2>
|
||||
<p class="lead" data-group="editors" data-yes="You are in <code>editors</code>: read streams and clips, write finals." data-no="Admin view — you are not in <code>editors</code>; your copyparty rights come from <code>media-admins</code> instead."></p>
|
||||
<div class="links">
|
||||
<a class="card" href="/"><b>Browse & download</b><span>copyparty — scrub in the browser, download originals, zip folders</span></a>
|
||||
<a class="card" href="/finals/"><b>Upload finals</b><span>the only folder editors can write to</span></a>
|
||||
<a class="card" id="l-files" href="#"><b>Browse & download</b><span>copyparty — scrub in the browser, download originals, zip folders</span></a>
|
||||
<a class="card" id="l-finals" href="#"><b>Upload finals</b><span>the only folder editors can write to</span></a>
|
||||
</div>
|
||||
<details><summary>Multicam & timecode</summary>
|
||||
<ul>
|
||||
@@ -108,15 +108,15 @@
|
||||
<h2>Media admin</h2>
|
||||
<p class="lead" data-group="media-admins" data-yes="You are in <code>media-admins</code>: full rights on every folder, trash, Syncthing, dump anyone's night." data-no="Admin view — you are <b>not</b> in <code>media-admins</code>: copyparty will show you nothing and <code>/sync/</code> will 403 until you get the role."></p>
|
||||
<div class="links">
|
||||
<a class="card" href="/trash/"><b>Trash</b><span>dumped nights, purged after 3 days</span></a>
|
||||
<a class="card" href="/sync/"><b>Syncthing</b><span>device pairing, folder states</span></a>
|
||||
<a class="card" id="l-trash" href="#"><b>Trash</b><span>dumped nights, purged after 3 days</span></a>
|
||||
<a class="card" id="l-sync" href="#"><b>Syncthing</b><span>device pairing, folder states</span></a>
|
||||
<a class="card" href="/whoami"><b>Who am I</b><span>identity headers as the apps see them</span></a>
|
||||
</div>
|
||||
<details><summary>Admin notes</summary>
|
||||
<ul>
|
||||
<li>Group changes in Authentik apply on the next login: <a href="/logout">sign out</a> and back in.</li>
|
||||
<li>Dump anyone's night: the dock's Dump works on your own; for others use the API with <code>{"user":"name"}</code> or ask the owner.</li>
|
||||
<li>New gamer: they self-serve on <a href="/clips/">/clips/</a>; you only need to watch Syncthing accept the device.</li>
|
||||
<li>New gamer: they self-serve on the <a class="l-clips" href="#">clips page</a>; you only need to watch Syncthing accept the device.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
@@ -156,7 +156,7 @@
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<footer>Vanguard Media · <a href="/about/">/about</a> · <a href="/logout">sign out</a></footer>
|
||||
<footer>Vanguard Media · <a href="/">home</a> · <a href="/logout">sign out</a></footer>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
@@ -165,7 +165,9 @@ const sub = s => `https://${s}.${base}`;
|
||||
$('l-watch').href = sub('watch'); $('l-live').href = sub('live'); $('l-golive').href = sub('live') + '/#golive';
|
||||
$('l-git').href = sub('git'); $('l-bot').href = sub('bot'); $('l-auth').href = sub('auth') + '/if/admin/';
|
||||
$('l-jfadmin').href = sub('watch') + '/web/#/dashboard'; $('l-gitadmin').href = sub('git') + '/-/admin';
|
||||
$('dockurl').textContent = location.origin + '/dock/'; $('gitssh').textContent = `ssh://git@${'git.' + base}:2222/org/repo.git`;
|
||||
$('l-dock').href = sub('live') + '/dock/'; $('dockurl').textContent = sub('live') + '/dock/';
|
||||
$('l-files').href = sub('files') + '/'; $('l-finals').href = sub('files') + '/finals/'; $('l-trash').href = sub('files') + '/trash/'; $('l-sync').href = sub('sync') + '/';
|
||||
$('l-clips').href = sub('clips') + '/'; document.querySelectorAll('a.l-clips').forEach(a => a.href = sub('clips') + '/'); $('gitssh').textContent = `ssh://git@${'git.' + base}:2222/org/repo.git`;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/me', {credentials:'same-origin'}); const d = await r.json(); if (!r.ok) throw new Error(d.error || r.status);
|
||||
|
||||
@@ -24,7 +24,7 @@ read -r AUTH_CUR WATCH_CUR < <(python3 -c 'import configure as c; h=c.read_hosts
|
||||
BASE="${MAIN#*.}"; [[ $MAIN == *.*.* ]] || BASE="$MAIN"
|
||||
|
||||
say "Community media server — Phase 1 (Authentik + Jellyfin) on $MAIN"
|
||||
echo "Needs two DNS names as CNAMEs to $MAIN. Create them now if you haven't."
|
||||
echo "Every hostname below is a CNAME to $MAIN. Create them now if you haven't."
|
||||
AUTH=$(ask "Authentik hostname" "${AUTH_CUR:-auth.$BASE}")
|
||||
WATCH=$(ask "Jellyfin hostname" "${WATCH_CUR:-watch.$BASE}")
|
||||
GIT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("git",""))')
|
||||
@@ -33,11 +33,17 @@ BOT_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("bot",""))
|
||||
BOT=$(ask "Bot web UI hostname" "${BOT_CUR:-bot.$BASE}")
|
||||
LIVE_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("live",""))')
|
||||
LIVE=$(ask "Live portal hostname" "${LIVE_CUR:-live.$BASE}")
|
||||
FILES_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("files",""))')
|
||||
FILES=$(ask "Files (copyparty) hostname" "${FILES_CUR:-files.$BASE}")
|
||||
SYNC_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("sync",""))')
|
||||
SYNC=$(ask "Syncthing UI hostname" "${SYNC_CUR:-sync.$BASE}")
|
||||
CLIPS_CUR=$(python3 -c 'import configure as c; print(c.read_hosts().get("clips",""))')
|
||||
CLIPS=$(ask "Clips self-service hostname" "${CLIPS_CUR:-clips.$BASE}")
|
||||
|
||||
# ---- DNS sanity ----------------------------------------------------------------------------------
|
||||
say "DNS check"
|
||||
MAIN_IP=$(ip_of "$MAIN"); ok=1
|
||||
for h in "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE"; do
|
||||
for h in "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE" "$FILES" "$SYNC" "$CLIPS"; do
|
||||
ip=$(ip_of "$h")
|
||||
if [[ -z $ip ]]; then echo " $h: does not resolve yet"; ok=0
|
||||
elif [[ $ip != "$MAIN_IP" ]]; then echo " $h -> $ip (main is $MAIN_IP) — points somewhere else"; ok=0
|
||||
@@ -50,7 +56,7 @@ fi
|
||||
|
||||
# ---- write configs, .env, start containers -----------------------------------------------------
|
||||
say "Applying (configure.py --hosts)"
|
||||
VG_DRY=$DRY python3 configure.py --hosts "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE" | sed -n '/== writing/,$p' | grep -vE '^ (techy|diceofire|[a-z0-9_-]+) password' || true
|
||||
VG_DRY=$DRY python3 configure.py --hosts "$AUTH" "$WATCH" "$GIT" "$BOT" "$LIVE" "$FILES" "$SYNC" "$CLIPS" | sed -n '/== writing/,$p' | grep -vE '^ (techy|diceofire|[a-z0-9_-]+) password' || true
|
||||
[[ $DRY == 1 ]] && { echo "(dry run: skipping container wait)"; exit 0; }
|
||||
|
||||
# ---- wait ---------------------------------------------------------------------------------------
|
||||
@@ -150,7 +156,7 @@ PHASE 3 — Jellyfin (direct play only on this CPU)
|
||||
Custom CSS: paste jellyfin/branding.css
|
||||
-> Save; reload https://$WATCH/
|
||||
|
||||
AUTHENTIK FORWARD-AUTH (gates https://$LIVE/ and https://$BOT/; later the main host) — once:
|
||||
AUTHENTIK FORWARD-AUTH (gates every Vanguard host: $MAIN, $FILES, $CLIPS, $SYNC, $LIVE, $BOT) — once:
|
||||
1. Applications -> Outposts -> edit "authentik Embedded Outpost" -> config: authentik_host: https://$AUTH
|
||||
2. Applications -> Create with provider -> Application "Vanguard sites"; Proxy Provider, authorization
|
||||
flow implicit-consent, mode "Forward auth (domain level)", authentication URL https://$AUTH,
|
||||
@@ -163,9 +169,9 @@ AUTHENTIK FORWARD-AUTH (gates https://$LIVE/ and https://$BOT/; later the main h
|
||||
"discord-roles-to-groups" from authentik/discord-roles-to-groups.py (server id + role-id->group table);
|
||||
bind to default-source-enrollment AND default-source-authentication (engine mode all).
|
||||
5. Test: private window -> https://$LIVE/ -> Discord -> portal; https://$BOT/ -> 403 unless you hold the
|
||||
bot role; https://$LIVE/me/stream-key shows your username.
|
||||
bot role; https://$LIVE/me shows your username and groups.
|
||||
|
||||
LIVE PORTAL (https://$LIVE/)
|
||||
LIVE PORTAL (https://$LIVE/) — also hosts the OBS dock (https://$LIVE/dock/) and the timecode clock source.
|
||||
Deploy the page from the dev box: ./deployLive.sh (builds live/ and copies dist/ to /srv/live).
|
||||
WebRTC viewers and Go Live need UDP/TCP 8189 open (firewall rule included). Optional "is live" Discord
|
||||
post: LIVE_WEBHOOK_URL in .env (asked by configure.py --full).
|
||||
|
||||
198
configure.py
198
configure.py
@@ -6,8 +6,12 @@
|
||||
./configure.py --show print current streamers, editors, domain and the OBS settings
|
||||
./configure.py --apply regenerate everything from the values already on disk and apply (non-interactive;
|
||||
used by deploy.sh). Fails if the main hostname was never set.
|
||||
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive;
|
||||
used by addCommunityMediaServer.sh)
|
||||
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE [FILES [SYNC [CLIPS]]]]]]
|
||||
set the other hostnames and apply (non-interactive; used by addCommunityMediaServer.sh).
|
||||
Pass "" to leave one unset.
|
||||
|
||||
Hosts: main = the gateway page at /, files = copyparty, clips = clip intake, sync = Syncthing UI,
|
||||
live = portal + OBS dock (/dock/, /dock/clock/, /dump, /me, /time), auth, watch, git, bot.
|
||||
|
||||
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), .env
|
||||
(editors/admins are Authentik groups now: editors, media-admins, admins — nothing local)
|
||||
@@ -154,8 +158,9 @@ def gate(required_group=""):
|
||||
"""
|
||||
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="", files_host="", sync_host="", clips_host=""):
|
||||
env = read_env()
|
||||
gate_any = gate("")
|
||||
extra = ""
|
||||
apex, target = env.get("APEX_HOST", ""), env.get("APEX_REDIRECT", "")
|
||||
if apex and target:
|
||||
@@ -164,18 +169,70 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
|
||||
{apex} {{
|
||||
redir {target} 302
|
||||
}}
|
||||
"""
|
||||
if files_host:
|
||||
extra += f"""
|
||||
# vanguard:files
|
||||
{files_host} {{
|
||||
encode zstd gzip
|
||||
{gate_any} # copyparty at the root. It honours X-Authentik-Username / -Groups only when the request also carries a header
|
||||
# whose secret NAME only this proxy knows (copyparty idp-h-key). Clients cannot forge identity.
|
||||
reverse_proxy 127.0.0.1:3923 {{
|
||||
header_up X-Vanguard-{env.get("COPYPARTY_IDP_KEY", "key")} 1
|
||||
flush_interval -1
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
if sync_host:
|
||||
extra += f"""
|
||||
# vanguard:sync
|
||||
{sync_host} {{
|
||||
encode zstd gzip
|
||||
{gate(env.get("SYNC_REQUIRE_GROUP", "media-admins"))} # Syncthing admin UI (its own GUI login is disabled; the gate + group is the login)
|
||||
reverse_proxy 127.0.0.1:8384 {{
|
||||
header_up Host {{upstream_hostport}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
if clips_host:
|
||||
extra += f"""
|
||||
# vanguard:clips
|
||||
{clips_host} {{
|
||||
encode zstd gzip
|
||||
{gate_any} @me path /me /me/*
|
||||
handle @me {{
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity + /me/clips self-service
|
||||
}}
|
||||
handle {{
|
||||
root * /srv/clips
|
||||
try_files {{path}} /index.html
|
||||
file_server
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
if live_host:
|
||||
extra += f"""
|
||||
# vanguard:live
|
||||
{live_host} {{
|
||||
encode zstd gzip
|
||||
{gate(env.get("LIVE_REQUIRE_GROUP", ""))} @me path /me /me/*
|
||||
{gate(env.get("LIVE_REQUIRE_GROUP", ""))} handle /whoami {{
|
||||
header Content-Type text/plain
|
||||
respond "user: {{http.request.header.X-Authentik-Username}}
|
||||
groups: {{http.request.header.X-Authentik-Groups}}"
|
||||
}}
|
||||
@me path /me /me/*
|
||||
handle @me {{
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity + /me/stream-token for the logged-in streamer (Go Live)
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity, stream tokens (Go Live + dock)
|
||||
}}
|
||||
handle /time {{
|
||||
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 (clock page + Go Live)
|
||||
}}
|
||||
handle /dump* {{
|
||||
reverse_proxy 127.0.0.1:8080 # dump tonight (dock)
|
||||
}}
|
||||
handle_path /dock* {{
|
||||
root * /srv/dock
|
||||
file_server # OBS browser dock + /dock/clock/
|
||||
}}
|
||||
handle_path /api/* {{
|
||||
reverse_proxy 127.0.0.1:9997
|
||||
@@ -231,24 +288,11 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:8096
|
||||
}}
|
||||
"""
|
||||
sync_group = env.get("SYNC_REQUIRE_GROUP", "media-admins")
|
||||
sync_gate = ""
|
||||
if sync_group:
|
||||
g = re.escape(sync_group)
|
||||
sync_gate = f""" @sync_lacks_group {{
|
||||
path /sync /sync/*
|
||||
not header_regexp X-Authentik-Groups (^|\\|){g}(\\||$)
|
||||
}}
|
||||
handle @sync_lacks_group {{
|
||||
respond "Syncthing admin is for the '{sync_group}' group." 403
|
||||
}}
|
||||
"""
|
||||
Path("Caddyfile").write_text(f"""# vanguard:main
|
||||
{domain} {{
|
||||
encode zstd gzip
|
||||
{AUTHENTIK_FORWARD_AUTH}
|
||||
# who does Authentik say I am? (debug: shows exactly the identity headers the apps receive)
|
||||
{gate_any} # who does Authentik say I am? (debug: shows exactly the identity headers the apps receive)
|
||||
handle /whoami {{
|
||||
header Content-Type text/plain
|
||||
respond "user: {{http.request.header.X-Authentik-Username}}
|
||||
@@ -256,60 +300,24 @@ groups: {{http.request.header.X-Authentik-Groups}}
|
||||
email: {{http.request.header.X-Authentik-Email}}
|
||||
name: {{http.request.header.X-Authentik-Name}}"
|
||||
}}
|
||||
|
||||
# dump endpoint + stream tokens (python service on the host; identity from the X-Authentik-* headers Caddy just set)
|
||||
handle /dump* {{
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}}
|
||||
@me path /me /me/*
|
||||
handle @me {{
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity, stream tokens, clips self-service
|
||||
reverse_proxy 127.0.0.1:8080 # dumpd: identity for the gateway page
|
||||
}}
|
||||
handle /time {{
|
||||
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 is disabled)
|
||||
{sync_gate} redir /sync /sync/ 308
|
||||
handle_path /sync/* {{
|
||||
reverse_proxy 127.0.0.1:8384 {{
|
||||
header_up Host {{upstream_hostport}}
|
||||
}}
|
||||
}}
|
||||
|
||||
# OBS browser dock (static)
|
||||
handle_path /dock* {{
|
||||
root * /srv/dock
|
||||
file_server
|
||||
}}
|
||||
|
||||
# gateway page: role-aware links + instructions (static; talks to /me)
|
||||
redir /about /about/ 308
|
||||
handle_path /about/* {{
|
||||
root * /srv/about
|
||||
file_server
|
||||
}}
|
||||
|
||||
# clip intake self-service (static page; talks to /me/clips)
|
||||
redir /clips /clips/ 308
|
||||
handle_path /clips/* {{
|
||||
root * /srv/clips
|
||||
file_server
|
||||
}}
|
||||
|
||||
# copyparty. It honours X-Authentik-Username / -Groups only when the request also carries a header whose
|
||||
# secret NAME only this proxy knows (copyparty idp-h-key). Clients cannot forge identity.
|
||||
redir /about* / 308 # old bookmarks: the gateway page used to live at /about/
|
||||
handle {{
|
||||
reverse_proxy 127.0.0.1:3923 {{
|
||||
header_up X-Vanguard-{env.get("COPYPARTY_IDP_KEY", "key")} 1
|
||||
flush_interval -1
|
||||
}}
|
||||
root * /srv/about
|
||||
try_files {{path}} /index.html
|
||||
file_server # the gateway page is the root of the main host
|
||||
}}
|
||||
}}
|
||||
""" + extra)
|
||||
|
||||
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None):
|
||||
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None, files_host="", sync_host="", clips_host=""):
|
||||
env = read_env()
|
||||
if files_host: env["FILES_HOST"] = files_host
|
||||
if sync_host: env["SYNC_HOST"] = sync_host
|
||||
if clips_host: env["CLIPS_HOST"] = clips_host
|
||||
env["LIVE_HOST"] = live_host or env.get("LIVE_HOST", "live.example.com")
|
||||
if main_host: env["MAIN_HOST"] = main_host
|
||||
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
|
||||
@@ -474,15 +482,15 @@ def run(cmd, **kw):
|
||||
if DRY: return 0
|
||||
return subprocess.call(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, files_host="", sync_host="", clips_host=""):
|
||||
# admin_pw / editors are legacy no-ops: access is by Authentik group now
|
||||
if not streamers and Path("mediamtx.yml").exists() and "CHANGEME" in Path("mediamtx.yml").read_text():
|
||||
print("\n!! mediamtx.yml contains CHANGEME placeholders — it looks like the repo template was copied over the")
|
||||
print(" generated config. Applying now would leave NO streamers (OBS ingest and the dump button stop working).")
|
||||
if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1)
|
||||
print("\n== writing configs")
|
||||
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(domain); write_copyparty()
|
||||
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook, files_host, sync_host, clips_host)
|
||||
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); write_mediamtx(domain); write_copyparty()
|
||||
Path("stream-token.secret").write_text(read_env()["STREAM_TOKEN_SECRET"] + "\n"); os.chmod("stream-token.secret", 0o600)
|
||||
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
|
||||
print("== app data folders")
|
||||
@@ -554,11 +562,14 @@ def post_start_fixups(dc):
|
||||
if DRY: print(" (dry) installJellyfinPlugin.sh")
|
||||
else: subprocess.run(["bash", "installJellyfinPlugin.sh"], capture_output=True, text=True); print(" jellyfin plugin: ensured")
|
||||
|
||||
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)'}")
|
||||
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
|
||||
print(f"\n== gateway : https://{domain or 'HOST'}/ (/whoami, /logout)")
|
||||
print(f"== files : https://{files_host}/ (copyparty; editors / media-admins)" if files_host else "== files : (not set — run --full)")
|
||||
print(f"== clips : https://{clips_host}/ (self-service clip intake, any member)" if clips_host else "== clips : (not set — run --full)")
|
||||
print(f"== sync : https://{sync_host}/ (Syncthing UI, media-admins)" if sync_host else "== sync : (not set — run --full)")
|
||||
_e = read_env()
|
||||
if _e.get("APEX_HOST") and _e.get("APEX_REDIRECT"): print(f"== apex : https://{_e['APEX_HOST']}/ -> {_e['APEX_REDIRECT']}")
|
||||
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}/ (portal; /dock/ for OBS; WebRTC needs 8189 open)" if live_host else "== live : (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 admin {read_env().get('GITEA_ADMIN_USER','vanguard')} / {read_env().get('GITEA_ADMIN_PASSWORD','?')} (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
|
||||
print(f"== authentik: https://{auth_host}/ akadmin password = AUTHENTIK_BOOTSTRAP_PASSWORD in .env (set on first start; change it in the UI)" if auth_host else "== authentik: (not set — run --full)")
|
||||
@@ -572,11 +583,10 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
||||
print("== access : by Authentik group — editors (read streams/clips, write finals), media-admins (everything), admins (dump anyone)")
|
||||
print(" Discord login only — no WebDAV; downloads via the web UI")
|
||||
print("== streamers: anyone in the Authentik 'streamers' group (Discord role). No passwords, no keys to hand out.")
|
||||
print(f" OBS: add the dock https://{domain or 'HOST'}/dock/ in OBS, log in with Discord, Go Live — it mints a 12 h token")
|
||||
print(f" OBS: add the dock https://{live_host or 'LIVE'}/dock/ in OBS, log in with Discord, Go Live — it mints a 12 h token")
|
||||
print(f" and sets the stream key itself. Manual setups: dock → 'Manual OBS setup' (rtmp://{domain or 'HOST'}:1935/ + key).")
|
||||
print(f"\n editors log in with Discord at https://{domain or 'HOST'}/")
|
||||
print(f" verify: curl -sI https://{domain or 'HOST'}/dock/ | head -1 (expect 200)")
|
||||
print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/dump (expect 401)\n")
|
||||
print(f"\n editors: https://{files_host or 'FILES'}/ everyone: https://{domain or 'HOST'}/")
|
||||
print(f" verify: curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/ (expect 302 → Authentik)\n")
|
||||
|
||||
# ------------------------------------------------------------------ modes
|
||||
def manage_users(kind, users):
|
||||
@@ -596,12 +606,15 @@ def manage_users(kind, users):
|
||||
else:
|
||||
users[n] = ask_pw(n, users[n])
|
||||
|
||||
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
|
||||
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
|
||||
print("== FULL SETUP (enter keeps the shown default)\n")
|
||||
while True:
|
||||
domain = ask("public hostname (DNS A record must point here)", domain)
|
||||
domain = ask("main hostname = the gateway page (DNS A record must point here)", domain)
|
||||
if domain and "." in domain and " " not in domain: break
|
||||
print(" need a hostname like media.example.com")
|
||||
files_host = ask("Files (copyparty) hostname (CNAME to the above)", files_host or default_sub(domain, "files"))
|
||||
clips_host = ask("Clips self-service hostname (CNAME to the above)", clips_host or default_sub(domain, "clips"))
|
||||
sync_host = ask("Syncthing UI hostname (CNAME to the above)", sync_host or default_sub(domain, "sync"))
|
||||
auth_host = ask("Authentik hostname (CNAME to the above)", auth_host or default_sub(domain, "auth"))
|
||||
watch_host = ask("Jellyfin hostname (CNAME to the above)", watch_host or default_sub(domain, "watch"))
|
||||
git_host = ask("Gitea hostname (CNAME to the above)", git_host or default_sub(domain, "git"))
|
||||
@@ -609,7 +622,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
||||
live_host = ask("Live portal hostname (CNAME to the above)", live_host or default_sub(domain, "live"))
|
||||
env0 = read_env(); parent = default_sub(domain, "x")[2:] if domain else ""
|
||||
apex = ask("Bare domain to serve too (A record must point here; blank = skip)", env0.get("APEX_HOST", parent))
|
||||
apex_to = ask(" bare domain redirects to", env0.get("APEX_REDIRECT") or (f"https://{watch_host}/" if watch_host else "")) if apex else ""
|
||||
apex_to = ask(" bare domain redirects to", env0.get("APEX_REDIRECT") or f"https://{domain}/") if apex else ""
|
||||
print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
|
||||
discord_token, discord_guild = ask_discord()
|
||||
live_webhook = ask(" Discord webhook URL for 'is live' notices (optional)", read_env().get("LIVE_WEBHOOK_URL", ""))
|
||||
@@ -636,7 +649,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
|
||||
Path(".env").write_text("".join(f"{k}={v}\n" for k, v in e.items())) if Path(".env").exists() else None
|
||||
if not Path(".env").exists():
|
||||
Path(".env").write_text(f"APEX_HOST={apex}\nAPEX_REDIRECT={apex_to}\n"); os.chmod(".env", 0o600)
|
||||
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook
|
||||
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host
|
||||
|
||||
def ask_discord():
|
||||
env = read_env()
|
||||
@@ -663,27 +676,28 @@ def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
hosts = read_hosts()
|
||||
domain, auth_host, watch_host, git_host, bot_host, live_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", ""), hosts.get("live", "")
|
||||
files_host, sync_host, clips_host = hosts.get("files", ""), hosts.get("sync", ""), hosts.get("clips", "")
|
||||
streamers, accounts = read_streamers(), read_accounts()
|
||||
admin_pw = accounts.pop("admin", "")
|
||||
editors = accounts
|
||||
if mode == "--show":
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
|
||||
if mode == "--apply":
|
||||
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
|
||||
if mode == "--hosts":
|
||||
if len(sys.argv) not in (4, 5, 6, 7): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST [LIVE_HOST]]]"); sys.exit(2)
|
||||
if not 4 <= len(sys.argv) <= 10: print("usage: configure.py --hosts AUTH WATCH [GIT [BOT [LIVE [FILES [SYNC [CLIPS]]]]]]"); sys.exit(2)
|
||||
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
|
||||
auth_host, watch_host = sys.argv[2].strip(), sys.argv[3].strip()
|
||||
if len(sys.argv) >= 5: git_host = sys.argv[4].strip()
|
||||
if len(sys.argv) >= 6: bot_host = sys.argv[5].strip()
|
||||
if len(sys.argv) == 7: live_host = sys.argv[6].strip()
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
|
||||
a = [x.strip() for x in sys.argv[2:]] + [None] * 8
|
||||
auth_host, watch_host = a[0], a[1]
|
||||
git_host, bot_host, live_host = (a[2] if a[2] is not None else git_host), (a[3] if a[3] is not None else bot_host), (a[4] if a[4] is not None else live_host)
|
||||
files_host, sync_host, clips_host = (a[5] if a[5] is not None else files_host), (a[6] if a[6] is not None else sync_host), (a[7] if a[7] is not None else clips_host)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
|
||||
discord_token = discord_guild = live_webhook = None
|
||||
if mode == "--full":
|
||||
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
|
||||
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host)
|
||||
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
|
||||
elif mode == "":
|
||||
r = normal(domain, admin_pw, streamers, editors)
|
||||
@@ -691,8 +705,8 @@ def main():
|
||||
discord_token, discord_guild = r
|
||||
else:
|
||||
print(__doc__); sys.exit(2)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
|
||||
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host)
|
||||
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try: main()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Build the live portal here and push the static files to /srv/live on the server. Caddy serves them as-is.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/live"
|
||||
HOST="${1:-debian@ovh1.dumpstervigilantes.rip}"
|
||||
HOST="${1:-debian@media.dumpstervigilantes.rip}" # any name of the box works for ssh
|
||||
npm install --no-audit --no-fund --loglevel=error
|
||||
npm run build
|
||||
ssh "$HOST" 'sudo install -d -o "$(id -u)" -g "$(id -g)" /srv/live && rm -rf /srv/live/*'
|
||||
|
||||
Reference in New Issue
Block a user