Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c58d5ed0 | ||
|
|
8e5cdf50b5 | ||
|
|
d3f389cab1 | ||
|
|
f7ce35c636 | ||
|
|
a52d6a89bc | ||
|
|
adeb872abb | ||
|
|
b6380b6c8a | ||
|
|
96d1778bb4 | ||
|
|
0aa34123a8 | ||
|
|
070a94d0d7 | ||
|
|
9b0cbc9db1 | ||
|
|
fa0351084b | ||
|
|
61ed3c64e3 | ||
|
|
be0ff3b854 | ||
|
|
93078858c1 | ||
|
|
076340227f | ||
|
|
bf770fed83 | ||
|
|
b1335a830f | ||
|
|
cf42d0aab0 | ||
|
|
6c50f51feb |
@@ -67,7 +67,7 @@ matrix/
|
||||
- coturn config: `/etc/turnserver.conf`
|
||||
- LiveKit config: `/etc/livekit/config.yaml`
|
||||
- LiveKit service: `livekit-server.service`
|
||||
- lk-jwt-service: `lk-jwt-service.service` (now binds `:8071` via drop-in `/etc/systemd/system/lk-jwt-service.service.d/override.conf`; serves JWT tokens for MatrixRTC at `/sfu/get` and legacy `/get_token`)
|
||||
- lk-jwt-service: `lk-jwt-service.service` (now binds `127.0.0.1:8071` via drop-in `/etc/systemd/system/lk-jwt-service.service.d/override.conf`; serves JWT tokens for MatrixRTC at `/sfu/get` and legacy `/get_token`)
|
||||
- voice-limit-guard: `voice-limit-guard.service` (binds `:8070`, fronts lk-jwt-service — enforces hard per-room voice participant limits **and publish permissions (screenshare/camera via JWT re-signing)** for ALL clients; script `/opt/voice-limit-guard/voice-limit-guard.py`) — see [Voice Channel Limits & Call Permissions](#voice-channel-limits--call-permissions)
|
||||
- Hookshot: `/opt/hookshot/`, service: `matrix-hookshot.service`
|
||||
- Hookshot config: `/opt/hookshot/config.yml`
|
||||
@@ -84,6 +84,7 @@ matrix/
|
||||
- Service: `draupnir.service`
|
||||
- Management room: `#management:matrix.lotusguild.org` (`!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI`)
|
||||
- Bot account: `@draupnir:matrix.lotusguild.org` (power level 100 in all protected rooms and the Lotus Guild space)
|
||||
- Encryption: off (`experimentalRustCrypto: false`), so Draupnir cannot read commands sent in the encrypted management room; send them unencrypted for now. Turning it on needs a device-bound access token first: with the current token, 2.9.0 exits with "Encryption not possible: server not revealing device ID" (tried and reverted 2026-10-16, #26 / #28).
|
||||
- Subscribed ban lists: `#community-moderation-effort-bl:neko.dev`, `#matrix-org-coc-bl:matrix.org`
|
||||
- Rebuild: `NODE_OPTIONS="--max_old_space_size=6144" npm run build`
|
||||
- Healthz endpoint: `http://10.10.10.24:8081/healthz` (200 = healthy, 418 = disconnected)
|
||||
@@ -205,6 +206,7 @@ Every Matrix client must fetch a LiveKit JWT from lk-jwt-service before it can j
|
||||
- **Participant limit** — it forwards to lk-jwt-service, and if a token is issued decodes the JWT to get the LiveKit alias (`video.room`) + requester (`sub`), then asks LiveKit `ListParticipants` how many **distinct Matrix users** are in the room. requester already present (rejoin) → allow · distinct users ≥ limit → **403** · otherwise → allow.
|
||||
- **Publish permissions (screenshare / camera)** — LiveKit is a pure SFU and **cannot cap a publisher's bitrate/framerate** (no such field exists in the grant/config/API — that stays a Lotus-client-cooperative setting). But the JWT's `video.canPublishSources` **is** SFU-enforced for every client. Since the guard holds the LiveKit signing secret, when a room forbids a source it **decodes the issued token, drops `screen_share`/`screen_share_audio` (and/or `camera`) from `canPublishSources`, and re-signs it** (HS256, same key). Microphone is always kept. The SFU then rejects those tracks for **all** clients — nothing to opt into.
|
||||
- **Live (mid-call) enforcement** — the JWT re-sign covers anyone *joining* after a policy change. For people **already in the call**, a background **reconcile loop** (every `GUARD_RECONCILE_INTERVAL`, default 3 s) calls LiveKit `UpdateParticipant` to narrow their `canPublishSources`, which **unpublishes an in-progress screenshare/camera server-side for all clients** and blocks re-publish (confirmed LiveKit 1.9.11 behavior: reducing `can_publish_sources` removes the offending live track). So flipping a room to audio-only kills existing cameras/screenshares within ~one interval. The loop learns each LiveKit room's Matrix id from tokens it issues, only ever **removes** forbidden sources (never grants), preserves every other permission flag (full-replace safety), and no-ops once compliant. Disable with `GUARD_RECONCILE=0`.
|
||||
- **Connect-time limit (matrix #25)** — the token check can't stop a token fetched while there was space (lk-jwt tokens last an hour) from being used once the room is full. The same reconcile loop compares each limited room's **distinct Matrix users** with `max_users`, and when people who **arrived since the previous sweep** take it over the limit it calls LiveKit `RemoveParticipant` on them, newest first (all their devices). People already in the call are never removed, so **lowering a limit mid-call kicks nobody**; a person's extra devices never count twice. The first sweep of a room only records who is there. Simultaneous joins are already handled at token issue (per-room lock + 30 s slot reservations).
|
||||
- **Fail-open:** any error (admin API down, bad/absent token, LiveKit unreachable, unparseable room id, unexpected JWT shape) returns the upstream response **unchanged**, so calls keep working even if enforcement is degraded. The limit check and the source-policy re-sign are **independent** (a LiveKit-admin outage during the limit count can't skip the source restriction, and vice-versa). Before re-signing, the guard **verifies its own secret actually signed the token** — on a `LIVEKIT_SECRET` mismatch it skips the restriction and passes the original token through (so a secret drift can never emit a token the SFU rejects). A room with no policy set takes a zero-overhead fast path (token untouched).
|
||||
|
||||
> `LIVEKIT_KEY`/`LIVEKIT_SECRET` live in `/etc/matrix-deploy.env` (0600, not in git). The same pair must
|
||||
@@ -237,7 +239,9 @@ install -D -m644 /opt/matrix-config/livekit/voice-limit-guard.py /opt/voice-limi
|
||||
install -m644 /opt/matrix-config/systemd/voice-limit-guard.service /etc/systemd/system/voice-limit-guard.service
|
||||
# one-time: rebind lk-jwt-service to :8071
|
||||
mkdir -p /etc/systemd/system/lk-jwt-service.service.d
|
||||
printf '[Service]\nEnvironment=LIVEKIT_JWT_BIND=:8071\n' > /etc/systemd/system/lk-jwt-service.service.d/override.conf
|
||||
# Loopback only (matrix #24): the guard runs on this LXC; binding all
|
||||
# interfaces let any LAN host mint unguarded tokens straight from :8071.
|
||||
printf '[Service]\nEnvironment=LIVEKIT_JWT_BIND=127.0.0.1:8071\n' > /etc/systemd/system/lk-jwt-service.service.d/override.conf
|
||||
systemctl daemon-reload && systemctl restart lk-jwt-service && systemctl enable --now voice-limit-guard
|
||||
```
|
||||
|
||||
@@ -289,7 +293,7 @@ The token in `draupnir/production.yaml` in this repo is **intentionally redacted
|
||||
| 7880 | LiveKit HTTP | 0.0.0.0 |
|
||||
| 7881 | LiveKit RTC TCP | 0.0.0.0 |
|
||||
| 8070 | voice-limit-guard (fronts lk-jwt-service) | 0.0.0.0 |
|
||||
| 8071 | lk-jwt-service (behind guard) | 0.0.0.0 |
|
||||
| 8071 | lk-jwt-service (behind guard) | 127.0.0.1 (matrix #24; was 0.0.0.0) |
|
||||
| 8080 | synapse-admin (nginx) | 0.0.0.0 |
|
||||
| 3478 | coturn STUN/TURN | 0.0.0.0 |
|
||||
| 5349 | coturn TURNS/TLS | 0.0.0.0 |
|
||||
@@ -469,6 +473,16 @@ chmod 600 /etc/cinny-monitor.env
|
||||
|
||||
**Cinny-build webhook token** (header `X-Build-Token` on the `cinny-build` hook): `CINNY_BUILD_TOKEN` in LXC 106's `/etc/webhook/secrets.env`, not in git. Reachable on `10.10.10.6:9001` (`webhook-lotus`); `:9000` is bound to `127.0.0.1`. Note: LotusBot doesn't implement `!cinny-update` at the moment, so nothing calls this hook.
|
||||
|
||||
### Avatar decorations (matrix #22)
|
||||
|
||||
Served from **`https://chat.lotusguild.org/decorations/`**: `<slug>.png` and `thumbs/<slug>.webp`, `Cache-Control: public, max-age=604800`, no cookies. They used to come through Nextcloud's public DAV, which started a PHP session per image and sent no cache headers.
|
||||
|
||||
- **Storage:** Gitea generic package `avatar-decorations` (`https://code.lotusguild.org/api/packages/LotusGuild/generic/avatar-decorations/<version>/`), in ≤150 MB tar parts (the proxy refuses larger uploads). Nothing goes in git except the pin.
|
||||
- **Pin:** `cinny/decorations.sha256`, the sha256 of each part, which names the version.
|
||||
- **Deploy:** `deploy/lxc106-cinny.sh` installs the pinned version when the pin changes (or nothing is installed yet). It downloads the parts, checks them against the pin and against the `MANIFEST.sha256` inside, unpacks to `/var/www/decorations-<version>`, and atomically repoints `/var/www/decorations`. The previous version is kept for rollback; a failed download or checksum leaves the live set unchanged. nginx: `location ^~ /decorations/` in `cinny/nginx.conf`.
|
||||
- **Updating the set:** put the files in a folder (`<slug>.png` + `thumbs/<slug>.webp`) and run `GITEA_TOKEN=… deploy/publish-decorations.sh <folder> <new-version>`. That rewrites the pin; commit it. Add or remove the catalog entries in cinny's `src/app/features/lotus/avatarDecorations.ts`. Use a new version for every change: an image replaced under the same name can be cached for up to a week.
|
||||
- **Rollback:** revert `cinny/decorations.sha256`, or on LXC 106 run `ln -sfn /var/www/decorations-<old>/decorations /var/www/decorations`.
|
||||
|
||||
### Webhook secrets — rotating
|
||||
|
||||
Secrets used to be committed in `deploy/hooks-lxc*.json` in this public repo; all exposed ones were rotated on 2026-09-23 and moved to `secrets.env`. To rotate one:
|
||||
@@ -798,7 +812,7 @@ Leaving a space does **not** leave its child rooms; they are independent rooms w
|
||||
- [ ] `synchronous_commit = off` — **deliberately NOT applied.** Would be a real write-latency win, but this cluster hard-resets every few hours (HA self-fences on corosync quorum loss + the large1 DIMM), so it would drop up to ~600ms of committed messages *per reset* rather than once in a blue moon. Revisit once the cluster is stable.
|
||||
|
||||
### Auth & SSO
|
||||
- [x] Token-based registration
|
||||
- [x] Open registration disabled (`enable_registration: false`, 2026-10-16): accounts come from LLDAP and are created on first sign-in through Lotus SSO (Authelia OIDC, which keeps its own registration on). The old registration token was deleted.
|
||||
- [x] SSO/OIDC via Authelia
|
||||
- [x] `allow_existing_users: true` for linking accounts to SSO
|
||||
- [x] Password auth alongside SSO
|
||||
|
||||
+10
-3
@@ -8,9 +8,16 @@
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
"spaces": [
|
||||
"!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc",
|
||||
"#homelab:codestorm.net"
|
||||
],
|
||||
"rooms": [
|
||||
"#jellyfin:matrix.org"
|
||||
],
|
||||
"servers": [
|
||||
"matrixrooms.info"
|
||||
]
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
a7aacaa65cb06e4b1c990b9253362f2eb148aae72e2e11c396d34e899792d68d avatar-decorations-1.0.0.part1.tar
|
||||
a6919d8bb8e1c2f838b0f9acdbc1131cbfd7bfe5fe9c373b25f406474bd41095 avatar-decorations-1.0.0.part2.tar
|
||||
fb7624f2470659d933463187d2afe73428d5554291f62484c8a28fac6196f427 avatar-decorations-1.0.0.part3.tar
|
||||
909e07db0134b29258514acf4d598bb467aff8b157f0767b6c3afeadc1eb048c avatar-decorations-1.0.0.part4.tar
|
||||
+16
-1
@@ -160,7 +160,22 @@ echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $WEBROOT..."
|
||||
# /var/www/html/config.json by lxc106-cinny.sh. The build ships a DEV default
|
||||
# (allowCustomHomeservers:true); rsyncing it would clobber the production config
|
||||
# on every deploy. Keep the app bundle and the runtime config separate.
|
||||
rsync -a --delete --exclude config.json dist/ "$WEBROOT/"
|
||||
# [matrix #23] Keep the previous builds' hashed assets for a while: a browser
|
||||
# still holding an older index.html (or service-worker precache) asks for its
|
||||
# assets/index-<old hash>.js, and deleting them on every deploy broke the app
|
||||
# for those users until their cached shell expired. Prune after 14 days.
|
||||
# The asset excludes are ANCHORED (leading /): an unanchored `assets/` also
|
||||
# matched public/element-call/assets/, so the call page's index.html was
|
||||
# updated while its hashed scripts never were, and calls failed to load.
|
||||
# config.json stays unanchored on purpose: public/element-call/config.json is
|
||||
# the production call config too.
|
||||
rsync -a --delete --exclude config.json \
|
||||
--exclude /assets/ --exclude /public/element-call/assets/ dist/ "$WEBROOT/"
|
||||
for dir in assets public/element-call/assets; do
|
||||
mkdir -p "$WEBROOT/$dir"
|
||||
rsync -a "dist/$dir/" "$WEBROOT/$dir/"
|
||||
find "$WEBROOT/$dir" -type f -mtime +14 -delete
|
||||
done
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) ====="
|
||||
|
||||
|
||||
@@ -12,4 +12,4 @@ add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header Permissions-Policy 'accelerometer=(), autoplay=(self "https://call.chat.lotusguild.org"), camera=(self "https://call.chat.lotusguild.org"), display-capture=(self "https://call.chat.lotusguild.org"), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self "https://call.chat.lotusguild.org"), midi=(), payment=(), usb=()' always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://matrix.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://drive.lotusguild.org https://media.giphy.com https://media0.giphy.com https://media1.giphy.com https://media2.giphy.com https://media3.giphy.com https://media4.giphy.com https://www.openstreetmap.org https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' https://matrix.lotusguild.org wss://matrix.lotusguild.org https://isitup.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im https://api.giphy.com https://*.giphy.com wss:; media-src 'self' https: blob:; frame-src 'self' https:; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://matrix.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://drive.lotusguild.org https://media.giphy.com https://media0.giphy.com https://media1.giphy.com https://media2.giphy.com https://media3.giphy.com https://media4.giphy.com https://www.openstreetmap.org https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' https://matrix.lotusguild.org wss://matrix.lotusguild.org https://isitup.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://www.mozilla.org https://vector.im https://api.giphy.com https://*.giphy.com https://www.tiktok.com wss:; media-src 'self' https: blob:; frame-src 'self' https:; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
# [matrix #23] SPA routes (/, /home/…, /login/…) are rewritten to /index.html
|
||||
# inside `location /`, where no cache headers were set: browsers then reused a
|
||||
# heuristically fresh shell after a deploy and requested deleted assets. Only
|
||||
# the rewritten shell gets no-cache; other files there keep their defaults.
|
||||
map $uri $lotus_shell_cache_control {
|
||||
/index.html "no-cache, no-store, must-revalidate";
|
||||
default "";
|
||||
}
|
||||
|
||||
# All traffic arrives through Nginx Proxy Manager (10.10.10.27), so without
|
||||
# realip every visitor shared ONE chat_limit bucket (20 r/s, keyed by
|
||||
# $binary_remote_addr = the proxy): a scanner burst got real users 503s.
|
||||
# Each server block below trusts X-Real-IP from the proxy only (NPM sets it
|
||||
# to the client address, overwriting anything the client sent).
|
||||
#
|
||||
# [matrix #22] Decorations get their own, larger budget: opening the profile
|
||||
# decoration picker can fetch hundreds of thumbnails, which would otherwise use
|
||||
# up chat_limit and 503 the app's own files.
|
||||
limit_req_zone $binary_remote_addr zone=decorations_limit:10m rate=100r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
@@ -14,6 +34,8 @@ server {
|
||||
server_tokens off;
|
||||
client_max_body_size 50m;
|
||||
|
||||
set_real_ip_from 10.10.10.27;
|
||||
real_ip_header X-Real-IP;
|
||||
limit_req zone=chat_limit burst=60 nodelay;
|
||||
limit_conn chat_conn 25;
|
||||
index index.html;
|
||||
@@ -98,7 +120,24 @@ server {
|
||||
proxy_connect_timeout 5;
|
||||
}
|
||||
|
||||
# [matrix #22] Avatar decorations, installed by deploy/lxc106-cinny.sh from
|
||||
# the Gitea generic package (outside the web root, so app deploys'
|
||||
# rsync --delete never touches them). ^~ beats the image regex below: these
|
||||
# can be replaced in place under the same name, so a week, not immutable.
|
||||
location ^~ /decorations/ {
|
||||
alias /var/www/decorations/;
|
||||
# Replaces the server-level chat_limit here (nginx doesn't inherit
|
||||
# limit_req into a location that sets its own).
|
||||
limit_req zone=decorations_limit burst=700 nodelay;
|
||||
include snippets/cinny-security-headers.conf;
|
||||
add_header Cache-Control "public, max-age=604800" always;
|
||||
location ~ /MANIFEST\.sha256$ { return 404; }
|
||||
}
|
||||
|
||||
location / {
|
||||
# add_header here drops the server-level headers, so include them again.
|
||||
include snippets/cinny-security-headers.conf;
|
||||
add_header Cache-Control $lotus_shell_cache_control always;
|
||||
rewrite ^/config\.json$ /config.json break;
|
||||
rewrite ^/manifest\.json$ /manifest.json break;
|
||||
rewrite ^/sw\.js$ /sw.js break;
|
||||
@@ -128,6 +167,8 @@ server {
|
||||
|
||||
root /var/www/html;
|
||||
server_tokens off;
|
||||
set_real_ip_from 10.10.10.27;
|
||||
real_ip_header X-Real-IP;
|
||||
limit_req zone=chat_limit burst=60 nodelay;
|
||||
limit_conn chat_conn 25;
|
||||
|
||||
|
||||
+50
-1
@@ -16,7 +16,7 @@ echo "=== $(date) === LXC106 deploy triggered ==="
|
||||
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
git clone "$CLONE_URL" "$REPO_DIR"
|
||||
CHANGED="cinny/config.json cinny/nginx.conf cinny/upstream-check.sh cinny/lotus-build.sh cinny/lotus_deploy.sh deploy/hooks-lxc106.json systemd/cinny-upstream-check.cron"
|
||||
CHANGED="cinny/config.json cinny/decorations.sha256 cinny/nginx.conf cinny/upstream-check.sh cinny/lotus-build.sh cinny/lotus_deploy.sh deploy/hooks-lxc106.json systemd/cinny-upstream-check.cron"
|
||||
else
|
||||
cd "$REPO_DIR"
|
||||
git fetch --all
|
||||
@@ -63,6 +63,55 @@ PY
|
||||
fi
|
||||
fi
|
||||
|
||||
# [matrix #22] Avatar decorations: a versioned Gitea generic package, pinned
|
||||
# (version + sha256 of each part) in cinny/decorations.sha256, installed to
|
||||
# /var/www/decorations-<version> and served by nginx at /decorations/. Runs on a
|
||||
# pin change or when nothing is installed yet. A failed download or checksum
|
||||
# leaves the live set untouched; the previous version is kept for rollback.
|
||||
install_decorations() {
|
||||
local pin="$REPO_DIR/cinny/decorations.sha256"
|
||||
local ver base tmp dest
|
||||
ver=$(grep -oE 'avatar-decorations-[0-9][^ ]*\.part1\.tar' "$pin" | sed -E 's/^avatar-decorations-(.*)\.part1\.tar$/\1/')
|
||||
[ -n "$ver" ] || { echo "✗ decorations: no version in $pin"; return 1; }
|
||||
dest="/var/www/decorations-$ver"
|
||||
if [ -d "$dest/decorations" ] && [ "$(readlink -f /var/www/decorations)" = "$dest/decorations" ]; then
|
||||
echo "decorations $ver already live"; return 0
|
||||
fi
|
||||
base="https://code.lotusguild.org/api/packages/LotusGuild/generic/avatar-decorations/$ver"
|
||||
tmp=$(mktemp -d /var/www/.decorations-XXXXXX)
|
||||
# Every step checks its own exit status: `set -e` does not apply inside a
|
||||
# subshell on the left of `||`, so a failed checksum would be ignored.
|
||||
if ! (
|
||||
cd "$tmp" || exit 1
|
||||
cp "$pin" SHA256SUMS || exit 1
|
||||
while read -r _ f; do
|
||||
curl -fsSL --retry 3 -o "$f" "$base/$f" || exit 1
|
||||
done < SHA256SUMS
|
||||
sha256sum -c --quiet SHA256SUMS || exit 1
|
||||
while read -r _ f; do
|
||||
tar -xf "$f" || exit 1
|
||||
rm -f "$f"
|
||||
done < SHA256SUMS
|
||||
cd decorations || exit 1
|
||||
sha256sum -c --quiet MANIFEST.sha256 || exit 1
|
||||
); then
|
||||
echo "✗ decorations $ver: download/verify failed — live set unchanged"
|
||||
rm -rf "$tmp"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$dest"; mkdir -p "$dest"; mv "$tmp/decorations" "$dest/decorations"; rm -rf "$tmp"
|
||||
chmod -R a+rX "$dest"
|
||||
ln -sfn "$dest/decorations" /var/www/decorations.new && mv -T /var/www/decorations.new /var/www/decorations
|
||||
# Keep the live version and the one before it; drop older ones.
|
||||
find /var/www -maxdepth 1 -name 'decorations-*' -type d -printf '%T@ %p\n' | sort -rn \
|
||||
| tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf
|
||||
echo "✓ decorations $ver live at /var/www/decorations ($(find "$dest/decorations" -maxdepth 1 -name '*.png' | wc -l) images)"
|
||||
}
|
||||
if echo "$CHANGED" | grep -q '^cinny/decorations.sha256' || [ ! -e /var/www/decorations ]; then
|
||||
echo "Deploying avatar decorations..."
|
||||
install_decorations || true
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -q '^cinny/nginx.conf'; then
|
||||
echo "Deploying cinny nginx site config..."
|
||||
# Back up the live config, swap in the repo copy, and validate before
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Publish a new avatar-decorations version to the Gitea generic package
|
||||
# registry and write the pin that LXC 106's deploy installs (matrix #22).
|
||||
#
|
||||
# GITEA_TOKEN=… deploy/publish-decorations.sh <dir> <version>
|
||||
#
|
||||
# <dir> holds <slug>.png files and thumbs/<slug>.webp (the same layout the app
|
||||
# requests). The set is split into ≤150 MB tar parts (the registry's proxy
|
||||
# refuses large uploads), each part is uploaded, and cinny/decorations.sha256
|
||||
# is rewritten. Commit that file to deploy; nothing changes until you do.
|
||||
set -euo pipefail
|
||||
SRC=${1:?usage: publish-decorations.sh <dir> <version>}
|
||||
VER=${2:?usage: publish-decorations.sh <dir> <version>}
|
||||
: "${GITEA_TOKEN:?set GITEA_TOKEN (package write scope)}"
|
||||
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
BASE="https://code.lotusguild.org/api/packages/LotusGuild/generic/avatar-decorations/$VER"
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
mkdir -p "$WORK/decorations/thumbs"
|
||||
cp "$SRC"/*.png "$WORK/decorations/"
|
||||
cp "$SRC"/thumbs/*.webp "$WORK/decorations/thumbs/"
|
||||
cd "$WORK"
|
||||
for f in decorations/*.png; do
|
||||
s=$(basename "$f" .png)
|
||||
[ -f "decorations/thumbs/$s.webp" ] || { echo "missing thumbs/$s.webp" >&2; exit 1; }
|
||||
done
|
||||
(cd decorations && find . -type f \( -name '*.png' -o -name '*.webp' \) | sort | xargs sha256sum > MANIFEST.sha256)
|
||||
|
||||
python3 - "$VER" <<'PY'
|
||||
import os, subprocess, sys
|
||||
ver, root, limit = sys.argv[1], 'decorations', 150 * 1024 * 1024
|
||||
files = sorted(os.path.join(root, f) for f in os.listdir(root) if f.endswith('.png'))
|
||||
first = [os.path.join(root, 'MANIFEST.sha256')] + sorted(os.path.join(root, 'thumbs', t) for t in os.listdir(os.path.join(root, 'thumbs')))
|
||||
parts, cur, size = [], list(first), sum(os.path.getsize(f) for f in first)
|
||||
for f in files:
|
||||
if size + os.path.getsize(f) > limit and cur:
|
||||
parts.append(cur); cur, size = [], 0
|
||||
cur.append(f); size += os.path.getsize(f)
|
||||
parts.append(cur)
|
||||
for i, p in enumerate(parts, 1):
|
||||
lst = f'part{i}.list'
|
||||
open(lst, 'w').write('\n'.join(p) + '\n')
|
||||
subprocess.run(['tar', '--sort=name', '--owner=0', '--group=0', '--mtime=@0', '-cf',
|
||||
f'avatar-decorations-{ver}.part{i}.tar', '-T', lst], check=True)
|
||||
PY
|
||||
|
||||
sha256sum avatar-decorations-"$VER".part*.tar > SHA256SUMS
|
||||
for f in avatar-decorations-"$VER".part*.tar SHA256SUMS; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H "Authorization: token $GITEA_TOKEN" \
|
||||
--upload-file "$f" "$BASE/$f")
|
||||
[ "$code" = 201 ] || { echo "upload of $f failed: HTTP $code" >&2; exit 1; }
|
||||
echo "uploaded $f"
|
||||
done
|
||||
cp SHA256SUMS "$REPO_ROOT/cinny/decorations.sha256"
|
||||
echo "Pinned $VER in cinny/decorations.sha256: commit it to deploy."
|
||||
@@ -389,3 +389,109 @@ class NormalizePermissionTest(unittest.TestCase):
|
||||
guard.livekit_update_participant = lambda *a: None
|
||||
participant = {"identity": "@a:x:DEV", "permission": {"canPublish": True, "canPublishSources": ["CAMERA"]}}
|
||||
self.assertFalse(guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE"}))
|
||||
|
||||
|
||||
class SlotReservationTests(unittest.TestCase):
|
||||
"""[matrix #25] Simultaneous joins can't both pass a full room."""
|
||||
|
||||
def test_second_simultaneous_join_is_blocked(self):
|
||||
r = guard.SlotReservations(ttl=30)
|
||||
present = set()
|
||||
counted = present | r.reserved("room", present, now=0)
|
||||
self.assertFalse(guard.should_block(1, counted, "@a:x"))
|
||||
r.reserve("room", "@a:x", now=0)
|
||||
# @b asks a moment later, before @a has connected to LiveKit.
|
||||
counted = present | r.reserved("room", present, now=1)
|
||||
self.assertTrue(guard.should_block(1, counted, "@b:x"))
|
||||
# @a asking again (second device) is still allowed.
|
||||
self.assertFalse(guard.should_block(1, counted, "@a:x"))
|
||||
|
||||
def test_reservation_expires_and_clears_once_present(self):
|
||||
r = guard.SlotReservations(ttl=30)
|
||||
r.reserve("room", "@a:x", now=0)
|
||||
self.assertEqual(r.reserved("room", set(), now=31), set())
|
||||
r.reserve("room", "@a:x", now=40)
|
||||
self.assertEqual(r.reserved("room", {"@a:x"}, now=41), set())
|
||||
|
||||
def test_room_lock_is_per_room(self):
|
||||
r = guard.SlotReservations()
|
||||
self.assertIs(r.room_lock("a"), r.room_lock("a"))
|
||||
self.assertIsNot(r.room_lock("a"), r.room_lock("b"))
|
||||
|
||||
|
||||
def _p(identity, joined_ms):
|
||||
return {"identity": identity, "joined_at_ms": str(joined_ms),
|
||||
"permission": {"canPublish": True, "canPublishSources": ["MICROPHONE"]}}
|
||||
|
||||
|
||||
class TestOverLimitRemovals(unittest.TestCase):
|
||||
"""[matrix #25] connect-time enforcement of the voice limit."""
|
||||
|
||||
def test_first_sweep_records_and_removes_nobody(self):
|
||||
parts = [_p("@a:x:DEV1", 1), _p("@b:x:DEV1", 2)]
|
||||
removals, seen = guard.over_limit_removals(parts, 1, None)
|
||||
self.assertEqual(removals, [])
|
||||
self.assertEqual(seen, {"@a:x", "@b:x"})
|
||||
|
||||
def test_a_newcomer_past_the_limit_is_removed_with_all_devices(self):
|
||||
parts = [_p("@a:x:DEV1", 1), _p("@b:x:DEV1", 5), _p("@b:x:DEV2", 6)]
|
||||
removals, seen = guard.over_limit_removals(parts, 1, {"@a:x"})
|
||||
self.assertEqual(sorted(removals), ["@b:x:DEV1", "@b:x:DEV2"])
|
||||
self.assertEqual(seen, {"@a:x"})
|
||||
|
||||
def test_newest_newcomers_go_first(self):
|
||||
parts = [_p("@a:x:D", 1), _p("@b:x:D", 10), _p("@c:x:D", 20)]
|
||||
removals, _ = guard.over_limit_removals(parts, 2, {"@a:x"})
|
||||
self.assertEqual(removals, ["@c:x:D"])
|
||||
|
||||
def test_lowering_the_limit_mid_call_kicks_nobody(self):
|
||||
parts = [_p("@a:x:D", 1), _p("@b:x:D", 2), _p("@c:x:D", 3)]
|
||||
removals, seen = guard.over_limit_removals(parts, 1, {"@a:x", "@b:x", "@c:x"})
|
||||
self.assertEqual(removals, [])
|
||||
self.assertEqual(seen, {"@a:x", "@b:x", "@c:x"})
|
||||
|
||||
def test_extra_devices_of_a_present_user_are_not_newcomers(self):
|
||||
parts = [_p("@a:x:DEV1", 1), _p("@a:x:DEV2", 9)]
|
||||
removals, _ = guard.over_limit_removals(parts, 1, {"@a:x"})
|
||||
self.assertEqual(removals, [])
|
||||
|
||||
def test_no_limit_removes_nobody(self):
|
||||
parts = [_p("@a:x:D", 1), _p("@b:x:D", 2)]
|
||||
self.assertEqual(guard.over_limit_removals(parts, 0, {"@a:x"})[0], [])
|
||||
|
||||
def test_joined_at_seconds_are_understood(self):
|
||||
parts = [{"identity": "@a:x:D", "joined_at": "100"}, {"identity": "@b:x:D", "joined_at": "200"},
|
||||
{"identity": "@c:x:D", "joinedAt": "150"}]
|
||||
removals, _ = guard.over_limit_removals(parts, 1, set())
|
||||
self.assertEqual(sorted(removals), ["@b:x:D", "@c:x:D"])
|
||||
|
||||
|
||||
class TestReconcileRoomLimit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig = (guard.room_state, guard.livekit_list_participants,
|
||||
guard.livekit_update_participant, guard.livekit_remove_participant)
|
||||
self.removed, self.updates = [], []
|
||||
guard.livekit_remove_participant = lambda a, i: self.removed.append(i)
|
||||
guard.livekit_update_participant = lambda a, i, p: self.updates.append(i)
|
||||
guard._seen_users.clear()
|
||||
|
||||
def tearDown(self):
|
||||
(guard.room_state, guard.livekit_list_participants,
|
||||
guard.livekit_update_participant, guard.livekit_remove_participant) = self._orig
|
||||
guard._seen_users.clear()
|
||||
|
||||
def test_a_late_connect_with_an_early_token_is_removed(self):
|
||||
guard.room_state = lambda rid, max_age=0: {"max_users": 1, "allow_screenshare": True, "allow_camera": True}
|
||||
guard.livekit_list_participants = lambda a: [_p("@a:x:D", 1)]
|
||||
guard.reconcile_room("alias", "!room:x") # first sweep: @a is there
|
||||
self.assertEqual(self.removed, [])
|
||||
guard.livekit_list_participants = lambda a: [_p("@a:x:D", 1), _p("@late:x:D", 50)]
|
||||
guard.reconcile_room("alias", "!room:x")
|
||||
self.assertEqual(self.removed, ["@late:x:D"])
|
||||
self.assertEqual(self.updates, []) # nothing forbidden in this room
|
||||
|
||||
def test_unlimited_unrestricted_room_is_not_listed(self):
|
||||
guard.room_state = lambda rid, max_age=0: {"max_users": 0, "allow_screenshare": True, "allow_camera": True}
|
||||
guard.livekit_list_participants = lambda a: (_ for _ in ()).throw(AssertionError("listed"))
|
||||
guard.reconcile_room("alias", "!room:x")
|
||||
self.assertEqual(self.removed, [])
|
||||
|
||||
@@ -145,6 +145,41 @@ def should_block(limit: int, present_users: set, requester: str) -> bool:
|
||||
return len(present_users) >= limit
|
||||
|
||||
|
||||
# [matrix #25] Tokens issued but not yet connected weren't counted, so two
|
||||
# users pressing Join together could both pass a 1-person limit. Each admitted
|
||||
# user holds a short reservation (counted like a present user) until LiveKit
|
||||
# lists them or it expires; checks for a room are serialised with a lock.
|
||||
RESERVATION_TTL = 30.0
|
||||
|
||||
|
||||
class SlotReservations:
|
||||
"""Per-room reservations for admitted-but-not-yet-connected users."""
|
||||
|
||||
def __init__(self, ttl: float = RESERVATION_TTL):
|
||||
self.ttl = ttl
|
||||
self._by_room = {} # alias -> {user: expiry}
|
||||
self._guard = threading.Lock()
|
||||
self._room_locks = {}
|
||||
|
||||
def room_lock(self, alias: str) -> threading.Lock:
|
||||
with self._guard:
|
||||
return self._room_locks.setdefault(alias, threading.Lock())
|
||||
|
||||
def reserved(self, alias: str, present: set, now: float) -> set:
|
||||
"""Unexpired reservations; drops expired ones and users now present."""
|
||||
room = self._by_room.get(alias, {})
|
||||
for user in [u for u, exp in room.items() if exp <= now or u in present]:
|
||||
del room[user]
|
||||
return set(room)
|
||||
|
||||
def reserve(self, alias: str, user: str, now: float) -> None:
|
||||
if user:
|
||||
self._by_room.setdefault(alias, {})[user] = now + self.ttl
|
||||
|
||||
|
||||
RESERVATIONS = SlotReservations()
|
||||
|
||||
|
||||
def allowed_sources(policy: dict):
|
||||
"""Pure decision: the narrowed `canPublishSources` list, or None.
|
||||
|
||||
@@ -370,6 +405,7 @@ def remember_room(alias: str, room_id: str) -> None:
|
||||
def forget_room(alias: str) -> None:
|
||||
with _alias_lock:
|
||||
_alias_to_room.pop(alias, None)
|
||||
_seen_users.pop(alias, None)
|
||||
|
||||
|
||||
def forbidden_sources(policy: dict) -> set:
|
||||
@@ -416,6 +452,65 @@ def normalize_permission(perm: dict) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _joined_at(participant: dict) -> float:
|
||||
"""When this device joined, in ms (ParticipantInfo joined_at_ms, else s)."""
|
||||
for key in ("joined_at_ms", "joinedAtMs"):
|
||||
if participant.get(key) not in (None, "", 0, "0"):
|
||||
return float(participant[key])
|
||||
for key in ("joined_at", "joinedAt"):
|
||||
if participant.get(key) not in (None, "", 0, "0"):
|
||||
return float(participant[key]) * 1000.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def over_limit_removals(participants: list, limit: int, seen):
|
||||
"""[matrix #25] Pure: which devices to remove so the room is back within
|
||||
its limit, and the set of users now known to be in the room.
|
||||
|
||||
Only users who ARRIVED since the previous sweep (not in `seen`) can be
|
||||
removed, newest first: a token fetched while there was space can be used
|
||||
to connect up to an hour later, past the limit, but lowering the limit
|
||||
mid-call must not kick people already there. Users are distinct Matrix
|
||||
users (matrix_user), so a person's extra devices never count twice, and
|
||||
removing a user removes all their devices. `seen is None` means the room
|
||||
hasn't been swept yet: record it, remove nobody.
|
||||
"""
|
||||
first_join = {}
|
||||
identities = {}
|
||||
for p in participants:
|
||||
identity = p.get("identity") or ""
|
||||
user = matrix_user(identity)
|
||||
if not user:
|
||||
continue
|
||||
identities.setdefault(user, []).append(identity)
|
||||
joined = _joined_at(p)
|
||||
first_join[user] = min(first_join.get(user, joined), joined)
|
||||
users = set(first_join)
|
||||
if seen is None or limit <= 0 or len(users) <= limit:
|
||||
return [], users
|
||||
newcomers = sorted(
|
||||
(u for u in users if u not in seen),
|
||||
key=lambda u: (first_join[u], u),
|
||||
reverse=True,
|
||||
)
|
||||
removed = newcomers[: len(users) - limit]
|
||||
return [i for u in removed for i in identities[u]], users - set(removed)
|
||||
|
||||
|
||||
def livekit_remove_participant(alias: str, identity: str) -> None:
|
||||
"""Disconnect a participant (livekit RemoveParticipant). Already gone ->
|
||||
Twirp not_found (HTTP 404), which is benign."""
|
||||
try:
|
||||
_livekit_admin_post(alias, "RemoveParticipant", {"room": alias, "identity": identity})
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 404:
|
||||
log(f"remove_participant {identity} in {alias} -> HTTP {exc.code}")
|
||||
|
||||
|
||||
# alias -> set of users present at the previous sweep (see over_limit_removals)
|
||||
_seen_users = {}
|
||||
|
||||
|
||||
def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool:
|
||||
"""Enforce the forbidden-source policy on one live participant. Returns True
|
||||
if an UpdateParticipant call was issued."""
|
||||
@@ -448,8 +543,10 @@ def reconcile_room(alias: str, room_id: str) -> None:
|
||||
# Read the policy fresh-ish so a flip is picked up within ~one interval.
|
||||
policy = room_state(room_id, max_age=RECONCILE_INTERVAL)
|
||||
forbidden = forbidden_sources(policy)
|
||||
if not forbidden:
|
||||
return # nothing restricted; leave permissions untouched
|
||||
limit = int(policy.get("max_users", 0) or 0)
|
||||
if not forbidden and limit <= 0:
|
||||
_seen_users.pop(alias, None)
|
||||
return # nothing restricted; leave the room alone
|
||||
try:
|
||||
participants = livekit_list_participants(alias)
|
||||
except urllib.error.HTTPError as exc:
|
||||
@@ -466,7 +563,18 @@ def reconcile_room(alias: str, room_id: str) -> None:
|
||||
# empty_timeout). Don't forget on an empty read — only a 404 (room gone)
|
||||
# prunes — so a transient empty/race can't drop mid-call enforcement.
|
||||
return
|
||||
# [matrix #25] Connect-time limit: the token check alone can't stop a token
|
||||
# fetched earlier (valid for an hour) from being used once the room is full.
|
||||
removals, now_seen = over_limit_removals(participants, limit, _seen_users.get(alias))
|
||||
_seen_users[alias] = now_seen
|
||||
for identity in removals:
|
||||
livekit_remove_participant(alias, identity)
|
||||
log(f"removed {identity} from {alias}: over the voice limit of {limit}")
|
||||
if not forbidden:
|
||||
return
|
||||
for p in participants:
|
||||
if p.get("identity") in removals:
|
||||
continue
|
||||
try:
|
||||
reconcile_participant(alias, p, forbidden)
|
||||
except Exception as exc:
|
||||
@@ -618,11 +726,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# LiveKit-admin outage here cannot skip the publish-source policy below.
|
||||
if limit > 0 and alias:
|
||||
try:
|
||||
present = livekit_present_users(alias)
|
||||
if should_block(limit, present, requester):
|
||||
log(f"blocked {requester or '?'} from {room_id}: {len(present)}/{limit}")
|
||||
self._send_blocked()
|
||||
return
|
||||
with RESERVATIONS.room_lock(alias):
|
||||
present = livekit_present_users(alias)
|
||||
now = time.monotonic()
|
||||
counted = present | RESERVATIONS.reserved(alias, present, now)
|
||||
if should_block(limit, counted, requester):
|
||||
log(f"blocked {requester or '?'} from {room_id}: {len(counted)}/{limit}")
|
||||
self._send_blocked()
|
||||
return
|
||||
if requester not in present:
|
||||
RESERVATIONS.reserve(alias, requester, now)
|
||||
except Exception as exc:
|
||||
# Fail open on the limit only; still apply the policy below.
|
||||
log(f"limit check error for {room_id}: {exc}")
|
||||
|
||||
Reference in New Issue
Block a user