Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ share for translations. One package, `session_ops`, deployed to one self-hosted
| --- | --- | --- |
| `zendesk-digest` | Weekday Discord digest of the Zendesk tickets awaiting a reply, after closing positive app-store reviews | [zendesk-digest](docs/jobs/zendesk-digest.md) |
| `zendesk-relay` | Drafts and sends Zendesk replies from `claude:` private notes | [zendesk-relay](docs/jobs/zendesk-relay.md) |
| `session-ops-discord` | `/run` a job and `/mau-upload` an export from Discord | [session-ops-discord](docs/jobs/session-ops-discord.md) |
| `github-prs-digest` | Weekday Discord digest of open pull requests from outside contributors | [github-prs-digest](docs/jobs/github-prs-digest.md) |
| `crowdin-duplicates` | Crowdin string slots holding more than one translation, as they open and close | [crowdin-duplicates](docs/jobs/crowdin-duplicates.md) |
| `crowdin-sync` | Weekday: Crowdin translations into iOS, Android and the localization module, and its submodule bumped in each client | [crowdin-sync](docs/jobs/crowdin-sync.md) |
Expand Down
36 changes: 35 additions & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ does the work: accounts, venv, env files, units, timers, and migrating an older
| `session-ops@<job>.path` → `.service` | For a job with a `watch`: starts it when a matching file lands in its state directory. |
| `session-ops-queue.timer` → `.service` | Starts the jobs in `jobs.toml`'s `[queue]`, which then run one at a time in its order. |
| `zendesk-relay.service` | Always on, `127.0.0.1:8080`: Zendesk's `claude:` note webhooks. |
| `session-ops-discord.service` | Always on, `127.0.0.1:8081`: the `/run` and `/mau-upload` slash commands. See [Discord commands](#discord-commands). |
| `session-ops-alert@.service` | Every unit's `OnFailure=` backstop; see [session-ops-silence](../docs/jobs/session-ops-silence.md). |

`session-ops list` shows the jobs; `session-ops run <job> [--dry-run] [-- job arguments]`
Expand Down Expand Up @@ -53,6 +54,37 @@ From a host that ran the digests out of `/opt/zendesk`, `install.sh` copies thei
files and state over. Remove `/opt/zendesk`, `/etc/zendesk` and `/var/lib/zendesk`
(and the `github-prs` equivalents) once both digests have run from the new units.

## Discord commands

What `/run` and `/mau-upload` do, and who may run them:
[session-ops-discord](../docs/jobs/session-ops-discord.md). To set them up:

1. In the Developer Portal, create an application. Put its public key, its application
id, the server's id and who may run the commands in `/etc/session-ops/discord.env`,
then run `install.sh` again.
2. Invite it with the `applications.commands` scope only:
`https://discord.com/oauth2/authorize?client_id=<app id>&scope=applications.commands`.
3. Copy the `location = /discord/interactions` block from
[`nginx-webhooks.conf`](nginx-webhooks.conf) into the live
`/etc/nginx/sites-available/webhooks.session.codes`, which certbot owns, then
`nginx -t && systemctl reload nginx`. Until then the route is a 404 and Discord will
not save the URL.
4. Set its Interactions Endpoint URL to `https://webhooks.session.codes/discord/interactions`.
Discord checks it with a signed PING before saving.
5. Register the commands, with the bot token from the Bot page, typed in rather than kept:

```bash
read -rs DISCORD_BOT_TOKEN && export DISCORD_BOT_TOKEN
set -a && . /etc/session-ops/discord.env && set +a
/opt/session-ops/.venv/bin/session-ops discord-register
```

6. The commands start hidden from everyone but server administrators. In Server
Settings → Integrations, allow them for the role or channel that runs jobs. Seeing
them is not running them: the relay's allowlist still decides.

Register again after changing which jobs have `discord = true`.

## Secrets

Each `/etc/session-ops/<name>.env` has a commented `<name>.env.example` beside it,
Expand All @@ -70,6 +102,8 @@ systemctl start session-ops@<job>.service && journalctl -fu session-ops@<job>
systemctl start session-ops-alert@test.service # posts to the alerts channel
curl -sS -o /dev/null -w '%{http_code}\n' -X POST 127.0.0.1:8080/zendesk/notes \
-H 'Content-Type: application/json' -d '{"ticket_id":"1"}' # expect 401
curl -sS -o /dev/null -w '%{http_code}\n' -X POST 127.0.0.1:8081/discord/interactions -d '{}' # expect 401
runuser -u opsbot -- systemctl --no-ask-password start session-ops@token-expiry.service # expect Access denied
```

## Rehearsing on a spare host
Expand All @@ -89,7 +123,7 @@ done
for link in /etc/systemd/system/paths.target.wants/session-ops@*.path; do
[ -L "$link" ] && systemctl disable --now "${link##*/}"
done
systemctl disable --now session-ops-queue.timer zendesk-relay.service
systemctl disable --now session-ops-queue.timer zendesk-relay.service session-ops-discord.service
for repo in session-android session-ios session-localization session-desktop-dynamic-assets \
session-desktop session-app session-website session-appium session-playwright; do
gh pr list -R "session-foundation/$repo" --state open --json number,headRefName \
Expand Down
14 changes: 14 additions & 0 deletions deploy/env/discord.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# /etc/session-ops/discord.env: the Discord app behind /run and /mau-upload.
# A `#` starts a comment only as a line's first character. Restart session-ops-discord
# after an edit: it reads this file at start.

# General Information in the Developer Portal. Unset refuses every interaction.
DISCORD_PUBLIC_KEY=
# The one server the commands are registered on and answered from.
DISCORD_GUILD_ID=
# Comma-separated; either list grants. Both empty refuses everybody.
ALLOWED_USER_IDS=
ALLOWED_ROLE_IDS=
# For `session-ops discord-register`. Its DISCORD_BOT_TOKEN is typed in when registering,
# not kept here: the relay never needs it.
DISCORD_APP_ID=
42 changes: 29 additions & 13 deletions deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ account() {
id -u "$1" >/dev/null 2>&1 ||
useradd --system --no-create-home --home /nonexistent --shell /usr/sbin/nologin "$1"
}
for user in ghdigest crowdin publisher sessionops; do account "$user"; done
for user in ghdigest crowdin publisher sessionops opsbot; do account "$user"; done
# The Claude Code CLI keeps its binary, login and cache under this account's $HOME.
if ! id -u zendesk >/dev/null 2>&1; then
useradd --system --home /home/zendesk --shell /usr/sbin/nologin zendesk
Expand Down Expand Up @@ -68,7 +68,7 @@ move /etc/github-prs/env "$ETC/github-prs.env" 600 root
move "$ETC/env" "$ETC/alerts.env" 600 root
# systemd reads EnvironmentFile= as root before dropping privileges, so these need
# no group: a job's account cannot read another job's secrets.
for name in zendesk github-prs crowdin publish alerts mau; do
for name in zendesk github-prs crowdin publish alerts mau discord; do
[ -e "$ETC/$name.env" ] || install -m 600 /dev/null "$ETC/$name.env"
done
# What each file takes, commented; the env files stay empty until filled, since a job
Expand All @@ -93,9 +93,14 @@ if [ -e "$NEW_HOUSE" ] && grep -qx "ZENDESK_HOUSE_ANSWERS=$OLD_HOUSE" "$ETC/zend
fi

# A watched job's inbox: root drops files in, and the job's account moves them out.
# mau's also takes /mau-upload's, from the Discord relay's account.
"$OPS" list --watched | while read -r job user dir; do
install -d -o "$user" -g "$user" -m 711 "$STATE/$job"
install -d -o "$user" -g "$user" -m 700 "$dir"
if [ "$job" = mau ]; then
install -d -o "$user" -g opsbot -m 770 "$dir"
else
install -d -o "$user" -g "$user" -m 700 "$dir"
fi
done

install -m 644 "$ROOT/deploy/session-ops.tmpfiles" /etc/tmpfiles.d/session-ops.conf
Expand All @@ -115,6 +120,12 @@ install -m 644 "$ROOT"/deploy/*.service "$ROOT"/deploy/*.timer "$ROOT"/deploy/*.
rm -f "$UNITS"/session-ops@*.service.d/job.conf "$UNITS"/session-ops@*.timer.d/schedule.conf \
"$UNITS"/session-ops@*.path.d/watch.conf "$UNITS"/session-ops-queue.timer.d/schedule.conf
"$OPS" units --out "$UNITS" >/dev/null
# polkit reloads its rules when this changes.
if [ -d /etc/polkit-1/rules.d ]; then
"$OPS" polkit --out /etc/polkit-1/rules.d/50-session-ops-discord.rules >/dev/null
else
echo "polkit is missing, so /run in Discord can start no job" >&2
fi

READY=$("$OPS" list --ready)
QUEUED=$("$OPS" list --queued)
Expand Down Expand Up @@ -173,16 +184,21 @@ fi
for job in $("$OPS" list --not-ready); do
echo "not enabled: session-ops@$job (its env file is empty)"
done
if [ -s "$ETC/zendesk.env" ]; then
systemctl enable zendesk-relay.service >/dev/null
# A relay that hit its start limit refuses `start` until the limit is cleared.
systemctl reset-failed zendesk-relay.service 2>/dev/null || true
systemctl try-restart zendesk-relay.service
systemctl start zendesk-relay.service
echo "running zendesk-relay.service"
else
echo "not enabled: zendesk-relay.service ($ETC/zendesk.env is empty)"
fi
# An always-on service, enabled once its env file has content.
relay() {
if [ -s "$ETC/$2.env" ]; then
systemctl enable "$1.service" >/dev/null
# A relay that hit its start limit refuses `start` until the limit is cleared.
systemctl reset-failed "$1.service" 2>/dev/null || true
systemctl try-restart "$1.service"
systemctl start "$1.service"
echo "running $1.service"
else
echo "not enabled: $1.service ($ETC/$2.env is empty)"
fi
}
relay zendesk-relay zendesk
relay session-ops-discord discord

if [ ! -s "$ETC/alerts.env" ]; then
cat >&2 <<EOF
Expand Down
26 changes: 19 additions & 7 deletions deploy/nginx-webhooks.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# TLS termination for the Zendesk note webhook endpoint.
# TLS termination for the Zendesk note webhook and the Discord interactions endpoint.
#
# nginx rather than anything of its own, because the host already runs nginx with
# certbot for other sites. A second web server would fight it for :80 and :443 and
Expand Down Expand Up @@ -48,9 +48,10 @@ server {
listen [::]:80;
server_name webhooks.session.codes;

# Webhook bodies are small; anything larger is not from Zendesk. This is a
# public endpoint, so the cap matters — nothing downstream limits a body, and the
# relay has to read the whole thing to verify its signature.
# Webhook bodies are small, a Discord upload being a link to the file; anything
# larger is from neither Zendesk nor Discord. This is a public endpoint, so the cap
# matters — nothing downstream limits a body, and each relay has to read the whole
# thing to verify its signature.
client_max_body_size 256k;

server_tokens off;
Expand All @@ -61,9 +62,8 @@ server {
# matching uvicorn's --no-access-log in zendesk-relay.service.
access_log off;

# Zendesk is the only thing that needs to reach this, and it only ever POSTs to
# one path. The path is vendor-namespaced so this host and certificate can carry
# other integrations later without a second of either.
# Zendesk and Discord each POST to one path, vendor-namespaced so this host and
# certificate carry both.
#
# A `claude:` private note on a ticket. Exact path, and nothing here may alter
# the body: the HMAC signature covers the raw bytes, so any rewriting would turn
Expand All @@ -80,6 +80,18 @@ server {
proxy_connect_timeout 5s;
}

# Discord's slash commands. Same constraint as above: the Ed25519 signature covers
# the raw body. Discord waits three seconds for an answer.
location = /discord/interactions {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 10s;
proxy_connect_timeout 5s;
}

location = /healthz {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
Expand Down
40 changes: 40 additions & 0 deletions deploy/session-ops-discord.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[Unit]
Description=Discord slash commands for session-ops jobs
Documentation=https://github.com/session-foundation/session-shared-scripts
After=network-online.target
Wants=network-online.target
OnFailure=session-ops-alert@%n.service
StartLimitIntervalSec=5min
StartLimitBurst=5

[Service]
Type=exec
User=opsbot
Group=opsbot
EnvironmentFile=/etc/session-ops/discord.env
# Loopback only: nginx terminates TLS, and the signature check should not be the only barrier.
ExecStart=/opt/session-ops/.venv/bin/uvicorn session_ops.ops.discord_relay:app --host 127.0.0.1 --port 8081 \
--no-access-log --proxy-headers --forwarded-allow-ips 127.0.0.1
Restart=always
RestartSec=2

NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
# AF_UNIX for systemctl, which asks systemd and polkit over D-Bus.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# `-`: a host without the inbox yet still answers /run.
ReadWritePaths=-/var/lib/session-ops/mau/inbox

[Install]
WantedBy=multi-user.target
2 changes: 1 addition & 1 deletion deploy/session-ops-queue.timer
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Documentation=https://github.com/session-foundation/session-shared-scripts

[Timer]
Persistent=yes
RandomizedDelaySec=2min
# No random delay: /run in Discord refuses a job its timeout would carry past the next run.

[Install]
WantedBy=timers.target
5 changes: 2 additions & 3 deletions docs/jobs/github-prs-digest.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,8 @@ cache rather than something to back up.

### Late, not lost

Two runs can be further apart than 72 hours: April's DST weekend is 73, the timer's
`RandomizedDelaySec` adds up to two minutes, and a host that was down runs once when it
comes back. So the state also keeps `covered_until`, the time the last run whose every
Two runs can be further apart than 72 hours: April's DST weekend is 73, and a host that
was down runs once when it comes back. So the state also keeps `covered_until`, the time the last run whose every
message Discord accepted *started* its search, and each run reaches back to whichever is
earlier, that or 72 hours ago. A run whose post fails partway, or that fails before
posting, never moves it forward; a dry run writes nothing. The header then names the
Expand Down
3 changes: 2 additions & 1 deletion docs/jobs/mau.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ In Play Console, Statistics, a report saved once:
- All countries / regions, no breakdown
- A date range ending today, such as Last 30 days, with the Console in English

Export report → CSV, then:
Export report → CSV, then run `/mau-upload` in Discord with the file, which says at once
if it is not the export the job reads. Or:

```sh
rsync "All countries _ regions.csv" root@<host>:/var/lib/session-ops/mau/inbox/
Expand Down
29 changes: 29 additions & 0 deletions docs/jobs/session-ops-discord.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Discord Commands for the Jobs

| | |
| --- | --- |
| Runs | `session-ops-discord.service`, always on, behind nginx at `POST /discord/interactions` |
| Secrets | `/etc/session-ops/discord.env`: the app's public key, the server and who may run the commands |
| Setup | [deploy/README.md](../../deploy/README.md#discord-commands) |
| Logs | `journalctl -u session-ops-discord -n 50 --no-pager`: who ran what, and each outcome |

| Command | Does |
| --- | --- |
| `/run job:<job>` | Starts `session-ops@<job>.service` for a job `jobs.toml` marks `discord = true`. Refused while that job or any queued job is running or waiting to, or when the queue's next run is sooner than the job's timeout from now. The job posts its outcome in its own channel. |
| `/mau-upload file:<csv>` | Downloads the attachment, checks it parses as the Play Console export, and moves it into [mau](mau.md)'s inbox, whose path unit runs the job. A file it would reject is refused in Discord and never reaches the inbox. |

The app has no gateway connection and only the `applications.commands` scope, so Discord
sends it the commands run against it and nothing else: no messages, members or other
channels. Each request carries who ran it, their roles, the server, the channel and the
options chosen.

A request is refused unless Discord signed it in the last five minutes, it comes from
`DISCORD_GUILD_ID`, and its author is in `ALLOWED_USER_IDS` or holds a role in
`ALLOWED_ROLE_IDS`. Both lists empty refuses everybody. The commands are registered
hidden from everyone but server administrators, and the server's Integrations settings
show them to the right role; that only hides them, and these checks are what refuse
everyone else, administrators included.

The relay runs as `opsbot`. The polkit rule `install.sh` writes from `jobs.toml` lets that
account start the `discord = true` jobs and nothing else, and the mau inbox is the one
path it can write.
2 changes: 1 addition & 1 deletion src/session_ops/github_prs/digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ def window_start(state, now, window_hours, retention_days=DEFAULT_RETENTION_DAYS
the state says reporting is complete.

A fixed window alone drops whatever moved in a gap longer than it: a DST weekend,
a timer's random delay, a host down across a run, a run whose post failed partway.
a host down across a run, a run whose post failed partway.
Never further back than the retention, past which the state has forgotten what it
reported anyway.
"""
Expand Down
3 changes: 3 additions & 0 deletions src/session_ops/jobs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# channel_env where it posts, and where its own failures are reported; a job
# without one reports to ALERT_DISCORD_WEBHOOK_URL
# unit further [Service] lines, over the template's hardening
# discord offered to `/run` in Discord, and startable by the relay's account
# watch a glob under {state}: a file matching it starts the job, which must
# move it out, or the path unit starts it again
#
Expand Down Expand Up @@ -73,6 +74,7 @@ channel_env = "CROWDIN_DISCORD_WEBHOOK_URL"
max_age_hours = 80
# About 9 minutes; a full scan without --croql is ~110k requests, about 85 minutes.
timeout = "2h"
discord = true

[[job]]
name = "session-ops-silence"
Expand Down Expand Up @@ -113,6 +115,7 @@ env = ["CROWDIN_API_TOKEN", "PUBLISH_GIT_AUTHOR"]
channel_env = "CROWDIN_DISCORD_WEBHOOK_URL"
max_age_hours = 80
timeout = "1h"
discord = true
# Readable by this unit alone, at $CREDENTIALS_DIRECTORY; publishing needs it.
unit = ["LoadCredential=github-app.pem:/etc/session-ops/github-app.pem"]

Expand Down
Loading
Loading