mirror of
https://github.com/wekan/wekan.git
synced 2026-11-13 13:45:02 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
860a385496 | ||
|
|
1c70df29a2 | ||
|
|
8bdf86eaef | ||
|
|
6e4c0539af
|
||
|
|
b8fcef2fd4
|
||
|
|
4e2e6adc73 | ||
|
|
091541a960 |
No files matched your search
+2
-1
@@ -1,3 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
.gitea/CODEOWNERS linguist-detectable=false
|
||||
@@ -0,0 +1,3 @@
|
||||
# Pull requests request review from the eligible staff team(s).
|
||||
# One approval from either an Admin or Developer satisfies branch protection.
|
||||
* @trueog/Admins @trueog/Developers
|
||||
@@ -126,3 +126,8 @@ mongo-tools-patches/
|
||||
|
||||
# Generated on the Windows single-EXE runner (releases/append-windows-payload.mjs --manifest)
|
||||
wekan-real-files.h
|
||||
|
||||
# TrueOG: never track .env files
|
||||
.env
|
||||
*.env
|
||||
.env.*
|
||||
@@ -0,0 +1,3 @@
|
||||
.minicard-title .minicard-complete-toggle {
|
||||
display: none;
|
||||
}
|
||||
@@ -54,4 +54,5 @@ import '/client/components/cards/subtasks.css';
|
||||
import '/client/components/cards/similarCards.css';
|
||||
import '/client/components/cards/fromText.css';
|
||||
import '/client/components/cards/whiteboard.css';
|
||||
import '/client/components/cards/minicardOverrides.css';
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# WeKan settings. Every setting is listed, with comments, in
|
||||
# https://github.com/wekan/wekan/blob/main/docker-compose.yml
|
||||
# Restart after editing: systemctl restart wekan
|
||||
|
||||
# The address users open WeKan at. WeKan builds its links from it and its
|
||||
# live updates connect to it, so it must match the address in the browser.
|
||||
ROOT_URL=http://localhost:8080
|
||||
PORT=8080
|
||||
|
||||
# Database, attachments and avatars: /var/lib/wekan/files/{db,attachments,avatars}
|
||||
WRITABLE_PATH=/var/lib/wekan
|
||||
|
||||
#MAIL_URL=smtp://user:password@mailserver.example.com:25/
|
||||
#MAIL_FROM=WeKan <wekan@example.com>
|
||||
@@ -627,6 +627,16 @@ Meteor.methods({
|
||||
// so it is auditable rather than silent.
|
||||
// #4178: a member the team brings carries the board's active teams they
|
||||
// belong to, so leaving those teams takes the membership away again.
|
||||
// Add every member of an assigned active team, since the browser of a non-site-admin cannot see them
|
||||
const assignedTeamIds = boardTeamsArray.filter(t => t.isActive !== false).map(t => t.teamId);
|
||||
if (assignedTeamIds.length > 0) {
|
||||
const teamUsers = await Meteor.users.find({ 'teams.teamId': { $in: assignedTeamIds } }, { fields: { _id: 1 } }).fetchAsync();
|
||||
for (const teamUser of teamUsers) {
|
||||
if (!membersArray.some(m => m.userId === teamUser._id)) {
|
||||
membersArray.push({ userId: teamUser._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeTeamIds = boardTeamsArray.filter(t => t.isActive).map(t => t.teamId);
|
||||
const newIds = membersArray.map(m => m.userId).filter(id => !board.members.some(m => m && m.userId === id));
|
||||
const teamsOfNew = new Map();
|
||||
|
||||
@@ -2549,7 +2549,11 @@ Users.after.insert(async (userId, doc) => {
|
||||
}
|
||||
|
||||
const disableRegistration = (await ReactiveCache.getCurrentSetting()).disableRegistration;
|
||||
if (doc.authenticationMethod !== 'ldap' && disableRegistration) {
|
||||
// TrueOG: an account created by the OIDC service (services.oidc, which only the server-side OIDC login sets)
|
||||
// needs no invitation code: the identity provider (Staff-Auth) decides who gets in. Without this, with
|
||||
// registration disabled, a first OIDC sign-in created the account and then failed with
|
||||
// error-invitation-code-not-exist. Password registration stays closed.
|
||||
if (doc.authenticationMethod !== 'ldap' && disableRegistration && !doc.services?.oidc) {
|
||||
let invitationCode = null;
|
||||
// #6620: authenticationMethod is only set for oauth2/ldap signups (see
|
||||
// ATCreateUserServer/enrollOrLoginOidcUser above) - a normal
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# TrueOG deployment of WeKan (todo.true-og.net)
|
||||
|
||||
How TrueOG runs this fork: Dockerless, under OpenRC, on FerretDB 2.7 over PostgreSQL 17, behind nginx and
|
||||
Cloudflare, with sign-in only through Staff-Auth (TrueOG's Ory Hydra OpenID Connect provider). Nothing here holds
|
||||
a secret: values that are secret on the server appear as `<placeholders>`. The working copies live on the server in
|
||||
`/home/system/gitea-upgrade` (scripts) and `/opt/wekan`, `/etc` (configuration); this folder records them.
|
||||
|
||||
| Path | Live location | What it is |
|
||||
| --- | --- | --- |
|
||||
| `wekan.env.example` | `/opt/wekan/wekan.env` | Environment, sourced by the OpenRC script. Secrets replaced. |
|
||||
| `openrc/wekan.initd`, `openrc/wekan.confd` | `/etc/init.d/wekan`, `/etc/conf.d/wekan` | Service; `node=` picks the Node version (26 since WeKan 13.06). |
|
||||
| `nginx/wekan.conf` | `/etc/nginx/sites-available/wekan.conf` | Reverse proxy; injects the sign-in-page style (below). |
|
||||
| `authwatch/` | `/usr/local/sbin/trueog-wekan-authwatch.mjs`, `/etc/init.d/trueog-wekan-authwatch`, `/etc/logrotate.d/` | Locks and signs out WeKan accounts when their Staff-Auth sign-in ends. Installed by `install-wekan-authwatch.sh`; tests in `trueog-wekan-authwatch-test.mjs`. |
|
||||
| `upgrade/` | `/home/system/gitea-upgrade` | `upgrade-wekan.sh` (step-by-step upgrades with verified backups) and its helpers; `wekan-patch-13.06.py` applies TrueOG's patches to the compiled release. |
|
||||
|
||||
## Sign-in (since 2026-11-11)
|
||||
|
||||
- **Staff-Auth only.** Hydra client "TrueOG WeKan" (authorization code, scopes `openid profile email`,
|
||||
`client_secret_post`, access tokens 2160 h). `OAUTH2_*` in the environment; `OAUTH2_ID_MAP=sub`, so a WeKan
|
||||
account is linked to a Staff-Auth account by `services.oidc.id` = Staff-Auth's user uuid. Any active Staff-Auth
|
||||
account may sign in; the first sign-in creates the WeKan account (named after the Minecraft name).
|
||||
- **No passwords.** `PASSWORD_LOGIN_ENABLED=false` hides the form; no account has a stored password, so the server
|
||||
refuses password logins too. Registration and "forgot password" are disabled in the Admin Panel.
|
||||
- **Straight to Staff-Auth.** `OIDC_REDIRECTION_ENABLED=true`: a signed-out visit goes to Staff-Auth (one automatic
|
||||
try per browser tab). WeKan only redirects after its own sign-in page has drawn, so nginx injects a style that keeps
|
||||
that page (`.auth-layout`) invisible for 3 seconds; if the redirect did not happen, the button appears.
|
||||
- **Real client addresses.** `HTTP_FORWARDED_COUNT=1`: nginx resolves the visitor from Cloudflare's
|
||||
`CF-Connecting-IP` and appends it as the last `X-Forwarded-For` entry; WeKan listens on 127.0.0.1 only.
|
||||
- **Patches** (`upgrade/wekan-patch-13.06.py`, same changes as this fork's source): `setBoardTeams` (members of a
|
||||
board's teams become board members server-side) and `oidcSignup` (accounts created by the OIDC service need no
|
||||
invitation code although registration is disabled; `server/models/users.js`).
|
||||
- **Ending a sign-in.** `trueog-wekan-authwatch` locks a WeKan account (disabled, sessions ended; open tabs closed
|
||||
within about 2 s through WeKan's own API) when its Staff-Auth account is deactivated or deleted, unlocks it when the
|
||||
account is active again, and signs a person out of WeKan (sessions ended, account not disabled) when Hydra no longer
|
||||
accepts the token from their last sign-in (logout, revocation, expiry). No account is exempt. Add each new WeKan
|
||||
version to `REVIEWED_WEKAN` after reading its source, or the service only watches.
|
||||
|
||||
## Admin Panel settings (stored in WeKan's database, not in files)
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Disable registration / forgot password | on / on |
|
||||
| Display authentication method | off |
|
||||
| OIDC button text (`oidcBtnText`) | `Sign in with Staff-Auth` |
|
||||
| Custom HTML after body start | `<style>/* TrueOG: hide the minicard completion checkbox */ .minicard-title .minicard-complete-toggle { display: none !important; }</style>` |
|
||||
|
||||
The WeKan account `staffauth-lockbot` (no password, marked `trueogLockBot`) is created by the authwatch installer;
|
||||
it must stay an administrator and enabled for open tabs to close at once.
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/bin/sh
|
||||
# Install (or update) trueog-wekan-authwatch, the service that locks a WeKan account within about a second of
|
||||
# its Staff-Auth account being deactivated or deleted (and unlocks it when that account is active again).
|
||||
# doas sh /home/system/gitea-upgrade/install-wekan-authwatch.sh install or update, then verify
|
||||
# doas sh /home/system/gitea-upgrade/install-wekan-authwatch.sh remove stop and remove it (and its DB role)
|
||||
# Complete output and INSTALL_WEKAN_AUTHWATCH_EXIT=<status> are saved in logs/install-wekan-authwatch-<time>.log.
|
||||
# WeKan, Staff-Auth and Hydra are not restarted or reconfigured. What it sets up:
|
||||
# - PostgreSQL role wekan_authwatch (login, SELECT on staffauth users(uuid, deactivated) only), with a new
|
||||
# random password in /etc/trueog-wekan-authwatch/pgpass (system, 0600) on every run;
|
||||
# - WeKan admin account staffauth-lockbot (made by this tool, marked trueogLockBot) with NO password: it cannot
|
||||
# sign in; the service writes itself a login token into the account and keeps it in memory. The service locks
|
||||
# in the database, then asks WeKan through its REST API to disable the account too (open tabs close in ~2 s);
|
||||
# - /usr/local/sbin/trueog-wekan-authwatch.mjs, /etc/init.d/trueog-wekan-authwatch (supervise-daemon, user
|
||||
# system, WeKan's Node), /etc/logrotate.d/trueog-wekan-authwatch; default runlevel.
|
||||
# Everything that runs WeKan's Node or loads modules from /opt/wekan runs as system (those files belong to system,
|
||||
# so running them as root would hand system root). Nothing is installed unless the tests pass and the service's own --check, as system against the live
|
||||
# databases, connects and finds nothing to lock right now.
|
||||
SRC=/home/system/gitea-upgrade
|
||||
[ "$(id -u)" = 0 ] || { echo "run as root: doas sh $0" >&2; exit 1; }
|
||||
if [ -z "${WEKAN_AUTHWATCH_LOGGED:-}" ]; then
|
||||
mkdir -p "$SRC/logs"
|
||||
LOG=$SRC/logs/install-wekan-authwatch-$(date +%F_%H-%M-%S).log
|
||||
: > "$LOG"; chmod 644 "$LOG"; chown system:system "$LOG"
|
||||
echo "started $(date -Is): sh $0 $*" >> "$LOG"
|
||||
WEKAN_AUTHWATCH_LOGGED=1 sh "$0" "$@" >> "$LOG" 2>&1
|
||||
rc=$?
|
||||
echo "finished $(date -Is)" >> "$LOG"
|
||||
echo "INSTALL_WEKAN_AUTHWATCH_EXIT=$rc" >> "$LOG"
|
||||
cat "$LOG"
|
||||
exit "$rc"
|
||||
fi
|
||||
set -eu
|
||||
NAME=trueog-wekan-authwatch
|
||||
BIN=/usr/local/sbin/$NAME.mjs
|
||||
INITD=/etc/init.d/$NAME
|
||||
ROTATE=/etc/logrotate.d/$NAME
|
||||
CONFDIR=/etc/$NAME
|
||||
PGPASS=$CONFDIR/pgpass
|
||||
SVCLOG=/var/log/wekan/$NAME.log
|
||||
ROLE=wekan_authwatch
|
||||
NODE=$(sed -n 's/^node="\(.*\)"$/\1/p' /etc/conf.d/wekan)
|
||||
[ -n "$NODE" ] && [ -x "$NODE" ] || { echo "no Node from node= in /etc/conf.d/wekan" >&2; exit 1; }
|
||||
|
||||
as_system() { (cd / && env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin HOME=/home/system PGPASSFILE="$PGPASS" runuser -u system -- "$@"); }
|
||||
pg() { runuser -u postgres -- /usr/bin/psql -X -q -At -v ON_ERROR_STOP=1 "$@"; }
|
||||
enabled() { rc-update show default | grep -Eq "(^|[[:space:]])$NAME([[:space:]]|\$)"; }
|
||||
running() { pgrep -f "^$NODE $BIN" >/dev/null; }
|
||||
stop_service() {
|
||||
rc-service "$NAME" stop >/dev/null 2>&1 || true
|
||||
n=0
|
||||
while running; do
|
||||
[ "$n" -lt 30 ] || { echo "$NAME did not stop after 30 seconds" >&2; return 1; }
|
||||
sleep 1; n=$((n + 1))
|
||||
done
|
||||
}
|
||||
# Start detached, then require: OpenRC says started, supervise-daemon supervises it, and its log shows the
|
||||
# installed code starting and its first pass ("watching N linked WeKan accounts").
|
||||
start_and_verify() {
|
||||
version=$(sha256sum "$BIN" | cut -c1-16)
|
||||
mark=0
|
||||
[ ! -f "$SVCLOG" ] || mark=$(wc -l < "$SVCLOG")
|
||||
setsid -f /bin/sh -c "rc-service $NAME start >>/var/log/wekan/openrc-detached-start.log 2>&1"
|
||||
n=0
|
||||
until rc-service "$NAME" status >/dev/null 2>&1 &&
|
||||
pgrep -f "^supervise-daemon $NAME " >/dev/null && running &&
|
||||
tail -n +"$((mark + 1))" "$SVCLOG" 2>/dev/null | grep -q "started version=$version " &&
|
||||
tail -n +"$((mark + 1))" "$SVCLOG" 2>/dev/null | grep -q "watching [0-9]* linked WeKan accounts"; do
|
||||
[ "$n" -lt 30 ] || { echo "$NAME was not started, supervised and watching after 30 seconds" >&2
|
||||
tail -n +"$((mark + 1))" "$SVCLOG" 2>/dev/null | tail -5 >&2; return 1; }
|
||||
sleep 1; n=$((n + 1))
|
||||
done
|
||||
if tail -n +"$((mark + 1))" "$SVCLOG" | grep -q "not reviewed"; then
|
||||
echo "$NAME is running but watches only (WeKan version not in REVIEWED_WEKAN)" >&2; return 1
|
||||
fi
|
||||
if ! tail -n +"$((mark + 1))" "$SVCLOG" | grep -q "started version=$version .*API nudge on"; then
|
||||
echo "$NAME is running without its WeKan API nudge (open tabs would close only at WeKan's next poll)" >&2; return 1
|
||||
fi
|
||||
[ "$(pgrep -c -f "^$NODE $BIN")" = 1 ] || { echo "more than one $NAME process" >&2; return 1; }
|
||||
tail -n +"$((mark + 1))" "$SVCLOG"
|
||||
}
|
||||
|
||||
if [ "${1:-}" = remove ]; then
|
||||
stop_service
|
||||
rc-update del "$NAME" default >/dev/null 2>&1 || true
|
||||
rm -f "$BIN" "$INITD" "$ROTATE"
|
||||
if [ -s "$SRC/$NAME.mjs" ]; then
|
||||
as_system "$NODE" --no-warnings "$SRC/$NAME.mjs" --remove-bot
|
||||
else
|
||||
echo "note: $SRC/$NAME.mjs is missing, so the WeKan account staffauth-lockbot was not removed" >&2
|
||||
fi
|
||||
rm -rf "$CONFDIR"
|
||||
if [ "$(pg -d postgres -c "select 1 from pg_roles where rolname='$ROLE'")" = 1 ]; then
|
||||
pg -d staffauth -c "revoke all on users from $ROLE; revoke all on schema public from $ROLE"
|
||||
pg -d postgres -c "revoke all on database staffauth from $ROLE; drop role $ROLE"
|
||||
fi
|
||||
echo "removed $NAME, the WeKan account staffauth-lockbot and the role $ROLE (Staff-Auth still refuses sign-in for deactivated or deleted accounts;"
|
||||
echo "WeKan sessions already open are no longer ended by it)"
|
||||
echo "INSTALL_WEKAN_AUTHWATCH_REMOVED"
|
||||
exit 0
|
||||
fi
|
||||
[ "$#" = 0 ] || { echo "usage: doas sh $0 [remove]" >&2; exit 2; }
|
||||
|
||||
for f in "$SRC/$NAME.mjs" "$SRC/$NAME-test.mjs" "$SRC/$NAME.initd" "$SRC/$NAME.logrotate"; do
|
||||
[ -s "$f" ] || { echo "missing source $f" >&2; exit 1; }
|
||||
done
|
||||
as_system "$NODE" --check "$SRC/$NAME.mjs"
|
||||
sh -n "$SRC/$NAME.initd"
|
||||
|
||||
WORK=$(mktemp -d /var/tmp/$NAME-install.XXXXXX)
|
||||
chmod 700 "$WORK"
|
||||
# The tests use a scratch FerretDB database only (wekan_authwatch_test, dropped afterwards); as system they skip
|
||||
# the scratch-PostgreSQL part, which needs root (run the test file as root by hand for that part).
|
||||
tests=0
|
||||
as_system "$NODE" --no-warnings "$SRC/$NAME-test.mjs" > "$WORK/tests" 2>&1 || tests=$?
|
||||
tail -3 "$WORK/tests" | sed 's/^/tests: /'
|
||||
if [ "$tests" != 0 ] || ! tail -1 "$WORK/tests" | grep -Eq '^([0-9]+)/\1 passed$'; then
|
||||
echo "tests failed; nothing installed" >&2; rm -rf "$WORK"; exit 1
|
||||
fi
|
||||
|
||||
# The database role: read-only, two columns. A new password on every run, written only to the pgpass file
|
||||
# (sent to psql on its standard input, so it never appears in a process list).
|
||||
mkdir -p "$CONFDIR"; chown system:system "$CONFDIR"; chmod 700 "$CONFDIR"
|
||||
PW=$(/usr/bin/python3 -I -c 'import secrets; print(secrets.token_hex(24))')
|
||||
if [ "$(pg -d postgres -c "select 1 from pg_roles where rolname='$ROLE'")" != 1 ]; then
|
||||
pg -d postgres -c "create role $ROLE login nosuperuser nocreatedb nocreaterole noinherit"
|
||||
fi
|
||||
# The new pgpass is written first and moved into place only after PostgreSQL took the password, so a failure
|
||||
# leaves the running service with a matching pair. The statement is kept out of PostgreSQL's log, even on error.
|
||||
umask 077
|
||||
printf '127.0.0.1:5432:staffauth:%s:%s\n' "$ROLE" "$PW" > "$PGPASS.new"
|
||||
chown system:system "$PGPASS.new"; chmod 600 "$PGPASS.new"
|
||||
umask 022
|
||||
printf "set log_statement = 'none';\nset log_min_error_statement = 'panic';\n\\\\set pw '%s'\nalter role %s login password :'pw';\n" "$PW" "$ROLE" | pg -d postgres -f -
|
||||
mv "$PGPASS.new" "$PGPASS"
|
||||
unset PW
|
||||
pg -d postgres -c "revoke all on database staffauth from $ROLE; grant connect on database staffauth to $ROLE"
|
||||
pg -d staffauth -c "revoke all on users from $ROLE; grant usage on schema public to $ROLE; grant select (uuid, deactivated) on users to $ROLE"
|
||||
echo "role $ROLE: login, SELECT (uuid, deactivated) on staffauth.users only; password in $PGPASS"
|
||||
|
||||
# The WeKan account the service uses to make WeKan disable a locked account itself (closing open tabs at
|
||||
# once): created or reset (administrator, enabled, no password, no tokens); the tool refuses an existing account
|
||||
# of that name it did not make, and proves through the REST API that a token it mints acts as the admin bot.
|
||||
rm -f "$CONFDIR/wekan-bot" "$CONFDIR/allow-mass-lock"
|
||||
as_system "$NODE" --no-warnings "$SRC/$NAME.mjs" --provision-bot > "$WORK/bot" 2>&1 || {
|
||||
sed 's/^/bot: /' "$WORK/bot"; echo "could not set up the WeKan account staffauth-lockbot; service not installed" >&2; rm -rf "$WORK"; exit 1; }
|
||||
sed 's/^/bot: /' "$WORK/bot"
|
||||
grep -q '^WEKAN_LOCKBOT_OK$' "$WORK/bot" || { echo "staffauth-lockbot not verified; service not installed" >&2; rm -rf "$WORK"; exit 1; }
|
||||
|
||||
# The service's own check, as system, against the live databases: reads Staff-Auth through the new role,
|
||||
# reads WeKan's users, writes nothing. It must report nothing to lock right now.
|
||||
preflight=0
|
||||
as_system "$NODE" --no-warnings "$SRC/$NAME.mjs" --check > "$WORK/preflight" 2>&1 || preflight=$?
|
||||
sed 's/^/preflight: /' "$WORK/preflight"
|
||||
if [ "$preflight" != 0 ] || ! grep -q '^WEKAN_AUTHWATCH_CHECK_OK$' "$WORK/preflight"; then
|
||||
echo "preflight check failed; service not installed (the role and $PGPASS stay; 'remove' drops them)" >&2
|
||||
rm -rf "$WORK"; exit 1
|
||||
fi
|
||||
if ! grep -q 'would lock \[\], unlock \[\]' "$WORK/preflight" || grep -q -E 'NOT reviewed|refused:' "$WORK/preflight" ||
|
||||
! grep -q 'API: bot signs in$' "$WORK/preflight"; then
|
||||
echo "preflight: it would change accounts right now, refuses a mass lock, this WeKan version is not reviewed," >&2
|
||||
echo "or the bot cannot sign in; check by hand first" >&2
|
||||
rm -rf "$WORK"; exit 1
|
||||
fi
|
||||
|
||||
BACKUP=$WORK/previous
|
||||
mkdir "$BACKUP"
|
||||
[ ! -e "$BIN" ] || cp -p "$BIN" "$BACKUP/service"
|
||||
[ ! -e "$INITD" ] || cp -p "$INITD" "$BACKUP/initd"
|
||||
[ ! -e "$ROTATE" ] || cp -p "$ROTATE" "$BACKUP/logrotate"
|
||||
was_enabled=no; ! enabled || was_enabled=yes
|
||||
was_running=no; ! running || was_running=yes
|
||||
DONE=no
|
||||
# Any failing exit from here on puts the previous files, runlevel membership and running state back (a
|
||||
# first installation is removed again); the same signal list as install-authwatch.sh.
|
||||
SIGNALS="1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 24 25 26 27 29 30 31"
|
||||
n=34
|
||||
while [ "$n" -le 64 ]; do SIGNALS="$SIGNALS $n"; n=$((n + 1)); done
|
||||
rollback() {
|
||||
status=$?
|
||||
trap '' $SIGNALS
|
||||
trap - EXIT
|
||||
if [ "$DONE" != yes ]; then
|
||||
echo "installation failed; restoring the previous state" >&2
|
||||
ok=yes
|
||||
stop_service || ok=no
|
||||
for pair in "service:$BIN:755" "initd:$INITD:755" "logrotate:$ROTATE:644"; do
|
||||
name=${pair%%:*}; rest=${pair#*:}; dst=${rest%%:*}; mode=${rest#*:}
|
||||
if [ -e "$BACKUP/$name" ]; then
|
||||
install -m "$mode" -o root -g root "$BACKUP/$name" "$dst" && cmp -s "$BACKUP/$name" "$dst" || ok=no
|
||||
else
|
||||
rm -f "$dst" || true
|
||||
[ ! -e "$dst" ] || ok=no
|
||||
fi
|
||||
done
|
||||
if [ "$was_enabled" = yes ]; then
|
||||
enabled || rc-update add "$NAME" default >/dev/null 2>&1 || true
|
||||
enabled || ok=no
|
||||
else
|
||||
rc-update del "$NAME" default >/dev/null 2>&1 || true
|
||||
! enabled || ok=no
|
||||
fi
|
||||
if [ "$was_running" = yes ] && [ "$ok" = yes ]; then
|
||||
start_and_verify >/dev/null 2>&1 || { ok=no; echo "the previous $NAME is restored but not verified running" >&2; }
|
||||
fi
|
||||
if [ "$ok" = yes ]; then
|
||||
rm -rf "$WORK"
|
||||
echo "previous state restored (the role $ROLE and $PGPASS stay; 'remove' drops them)" >&2
|
||||
else
|
||||
echo "ROLLBACK INCOMPLETE: files, runlevel or running state differ from before; backup kept in $BACKUP" >&2
|
||||
fi
|
||||
[ "$status" != 0 ] || status=1
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap rollback EXIT
|
||||
for sig in $SIGNALS; do
|
||||
trap "trap '' $SIGNALS; exit $((128 + sig))" "$sig"
|
||||
done
|
||||
|
||||
stop_service
|
||||
install -m 755 -o root -g root "$SRC/$NAME.mjs" "$BIN"
|
||||
install -m 755 -o root -g root "$SRC/$NAME.initd" "$INITD"
|
||||
install -m 644 -o root -g root "$SRC/$NAME.logrotate" "$ROTATE"
|
||||
cmp -s "$SRC/$NAME.mjs" "$BIN" && cmp -s "$SRC/$NAME.initd" "$INITD" && cmp -s "$SRC/$NAME.logrotate" "$ROTATE"
|
||||
echo "installed $BIN, $INITD and $ROTATE"
|
||||
rotate_status=0
|
||||
logrotate -d "$ROTATE" > "$WORK/logrotate" 2>&1 || rotate_status=$?
|
||||
if [ "$rotate_status" != 0 ] || grep -Ei "^error|duplicate log entry|^warning: .*$NAME:[0-9]+" "$WORK/logrotate" >&2; then
|
||||
echo "logrotate rejects $ROTATE (status $rotate_status)" >&2; exit 1
|
||||
fi
|
||||
enabled || rc-update add "$NAME" default >/dev/null
|
||||
enabled || { echo "$NAME is not in the default runlevel" >&2; exit 1; }
|
||||
start_and_verify
|
||||
DONE=yes
|
||||
trap - EXIT $SIGNALS
|
||||
rm -rf "$WORK"
|
||||
echo "$NAME $(sha256sum "$BIN" | cut -c1-16) is enabled, started, supervised and watching"
|
||||
echo "INSTALL_WEKAN_AUTHWATCH_OK"
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for trueog-wekan-authwatch. Run as root from /:
|
||||
// /home/system/.nvm/versions/node/v26.13.0/bin/node /home/system/gitea-upgrade/trueog-wekan-authwatch-test.mjs
|
||||
// Uses a scratch FerretDB database (wekan_authwatch_test, dropped at the end) through WeKan's own driver and
|
||||
// MONGO_URL, and a scratch PostgreSQL database (wekan_authwatch_test, dropped at the end) for the psql path.
|
||||
// The live WeKan and Staff-Auth databases are never written.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const W = await import(path.join(here, 'trueog-wekan-authwatch.mjs'));
|
||||
|
||||
let failed = 0, passed = 0;
|
||||
const ok = (cond, name) => { if (cond) passed++; else { failed++; console.log('FAIL', name); } };
|
||||
|
||||
const s = { ...W.settings({}), confirmMs: 20, exempt: [] };
|
||||
const live = W.mongoUrl('/opt/wekan/wekan.env');
|
||||
const u = new URL(live);
|
||||
if (u.pathname.replace(/^\//, '') === 'wekan_authwatch_test') throw new Error('refusing');
|
||||
u.pathname = '/wekan_authwatch_test';
|
||||
const { MongoClient } = W.loadMongo('/opt/wekan');
|
||||
const client = new MongoClient(u.toString(), { serverSelectionTimeoutMS: 5000 });
|
||||
await client.connect();
|
||||
const db = client.db();
|
||||
if (db.databaseName !== 'wekan_authwatch_test') throw new Error('wrong scratch db ' + db.databaseName);
|
||||
await db.dropDatabase();
|
||||
const users = db.collection('users');
|
||||
|
||||
const id = n => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
const tok = [{ hashedToken: 'x', when: new Date() }];
|
||||
async function reset(docs) {
|
||||
await users.deleteMany({});
|
||||
await users.insertMany(docs.map(d => ({ services: { resume: { loginTokens: tok } }, ...d })));
|
||||
}
|
||||
const user = (name, n, extra = {}) => ({ _id: name, username: name, services: { oidc: { id: id(n) }, resume: { loginTokens: tok } }, ...extra });
|
||||
const staffMap = entries => new Map(entries.map(([n, active]) => [id(n), active]));
|
||||
const nudged = [];
|
||||
const ctxWith = (staffSeq, over = {}) => {
|
||||
let i = 0;
|
||||
const notes = [];
|
||||
return {
|
||||
users, s: over.s || s, write: true, version: '13.06', notes,
|
||||
readStaff: async () => { const v = staffSeq[Math.min(i++, staffSeq.length - 1)]; if (v instanceof Error) throw v; return v; },
|
||||
note: (k, m) => notes.push(k + ': ' + m), alert: m => notes.push('refused: ' + m),
|
||||
nudge: async id => { nudged.push(id); }, ...over,
|
||||
};
|
||||
};
|
||||
const get = name => users.findOne({ _id: name });
|
||||
|
||||
// 1 active, untouched
|
||||
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
|
||||
let r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])]));
|
||||
ok(r.done.length === 0 && !(await get('a')).loginDisabled, 'active accounts untouched');
|
||||
|
||||
// 2 deactivated -> locked, tokens cleared, reason set
|
||||
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]])]));
|
||||
let a = await get('a');
|
||||
ok(a.loginDisabled === true && a.loginDisabledReason === 'staff-auth' && a.services.resume.loginTokens.length === 0, 'deactivated -> locked, tokens cleared');
|
||||
ok((await get('b')).services.resume.loginTokens.length === 1, 'other account keeps its tokens');
|
||||
ok(a.services.oidc.id === id(1) && a.username === 'a', 'nothing else changed on the locked account');
|
||||
|
||||
// 3 reactivated -> unlocked, reason gone
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])]));
|
||||
a = await get('a');
|
||||
ok(a.loginDisabled === false && a.loginDisabledReason === undefined, 'reactivated -> unlocked');
|
||||
|
||||
// 4 deleted (missing from Staff-Auth) -> locked
|
||||
r = await W.runPass(ctxWith([staffMap([[2, true], [9, true]])]));
|
||||
ok((await get('a')).loginDisabled === true, 'deleted -> locked');
|
||||
|
||||
// 5 transient: deactivated on first read, active on the confirming read -> nothing
|
||||
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
|
||||
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]]), staffMap([[1, true], [2, true], [9, true]])]));
|
||||
ok(!(await get('a')).loginDisabled, 'not confirmed -> not locked');
|
||||
|
||||
// 6 exempt owner deactivated -> not locked, reported (exempt by Staff-Auth uuid, any username)
|
||||
s.exempt = [id(9)];
|
||||
await users.updateOne({ _id: 'owner' }, { $set: { username: 'Renamed' } });
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, false]])]));
|
||||
ok(!(await get('owner')).loginDisabled && r.exemptHits.length === 1, 'exempt account never locked');
|
||||
|
||||
// 7 disabled by someone else, Staff-Auth active -> stays disabled; deactivated -> untouched
|
||||
await reset([user('a', 1, { loginDisabled: true, loginDisabledReason: 'inactive' }), user('b', 2, { loginDisabled: true }), user('c', 3)]);
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [3, true]])]));
|
||||
ok((await get('a')).loginDisabled === true && (await get('a')).loginDisabledReason === 'inactive' && (await get('b')).loginDisabled === true, 'other disables never undone');
|
||||
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [3, true]])]));
|
||||
ok((await get('a')).loginDisabledReason === 'inactive' && (await get('a')).services.resume.loginTokens.length === 1, 'already disabled account untouched');
|
||||
|
||||
// 8 mass: three at once -> refused
|
||||
await reset([user('a', 1), user('b', 2), user('c', 3), user('d', 4), user('owner', 9)]);
|
||||
let ctx = ctxWith([staffMap([[4, true], [9, true]])]);
|
||||
r = await W.runPass(ctx);
|
||||
ok(r.refused && !(await get('a')).loginDisabled && !(await get('c')).loginDisabled && ctx.notes.some(n => n.startsWith('refused')), 'three at once refused');
|
||||
|
||||
// 9 every lockable account (2 of 2) -> refused; 1 of 2 -> allowed
|
||||
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
|
||||
r = await W.runPass(ctxWith([staffMap([[9, true], [5, true]])]));
|
||||
ok(r.refused && !(await get('a')).loginDisabled, 'every lockable account refused');
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [9, true]])]));
|
||||
ok((await get('b')).loginDisabled === true && !r.refused, 'one of two allowed');
|
||||
|
||||
// 10 read error -> throws, nothing changed
|
||||
await reset([user('a', 1), user('b', 2)]);
|
||||
let threw = false;
|
||||
try { await W.runPass(ctxWith([new Error('db down')])); } catch { threw = true; }
|
||||
ok(threw && !(await get('a')).loginDisabled, 'read error changes nothing');
|
||||
threw = false;
|
||||
try { await W.runPass(ctxWith([staffMap([[1, false], [2, true]]), new Error('db down')])); } catch { threw = true; }
|
||||
ok(threw && !(await get('a')).loginDisabled, 'error on the confirming read changes nothing');
|
||||
|
||||
// 11 unreviewed WeKan version -> watch only
|
||||
ctx = ctxWith([staffMap([[1, false], [2, true]])], { version: '13.99' });
|
||||
r = await W.runPass(ctx);
|
||||
ok(!(await get('a')).loginDisabled && ctx.notes.some(n => n.startsWith('unreviewed')), 'unreviewed version watches only');
|
||||
ctx = ctxWith([staffMap([[1, false], [2, true]])], { version: null });
|
||||
r = await W.runPass(ctx);
|
||||
ok(!(await get('a')).loginDisabled, 'unknown version watches only');
|
||||
|
||||
// 12 --check style (write false) -> nothing written
|
||||
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true]])], { write: false }));
|
||||
ok(r.lock.length === 1 && !(await get('a')).loginDisabled, 'dry run writes nothing');
|
||||
|
||||
// 13 accounts without Staff-Auth link or with a malformed id are ignored
|
||||
await reset([{ _id: 'local', username: 'local' }, { _id: 'bad', username: 'bad', services: { oidc: { id: 'not-a-uuid' } } }, user('a', 1), user('b', 2)]);
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true]])]));
|
||||
ok(!(await get('local')).loginDisabled && !(await get('bad')).loginDisabled && r.linked === 2, 'unlinked and malformed ignored');
|
||||
|
||||
// 14 upper-case uuid in WeKan matches Staff-Auth's lower-case
|
||||
await reset([user('a', 1), user('b', 2)]);
|
||||
await users.updateOne({ _id: 'a' }, { $set: { 'services.oidc.id': id(1).toUpperCase() } });
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true]])]));
|
||||
ok(!(await get('a')).loginDisabled, 'upper-case id treated as the same subject');
|
||||
|
||||
// 18 nudge: called once per lock, never for unlocks; its failure keeps the lock
|
||||
await reset([user('a', 1), user('b', 2), user('owner', 9)]);
|
||||
nudged.length = 0;
|
||||
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]])]));
|
||||
ok(nudged.length === 1 && nudged[0] === 'a', 'nudge called for the locked account');
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])]));
|
||||
ok(nudged.length === 1, 'no nudge on unlock');
|
||||
a = await get('a');
|
||||
ok(a.inactivityClockStart instanceof Date, 'unlock restarts the inactivity clock');
|
||||
r = await W.runPass(ctxWith([staffMap([[1, false], [2, true], [9, true]])], { nudge: async () => { throw new Error('HTTP 500'); } }));
|
||||
ok((await get('a')).loginDisabled === true && r.done.length === 1, 'failed nudge keeps the lock');
|
||||
r = await W.runPass(ctxWith([staffMap([[1, true], [2, true], [9, true]])], { nudge: null }));
|
||||
ok((await get('a')).loginDisabled === false, 'works without a nudge');
|
||||
|
||||
// 19 mass-lock override file
|
||||
await reset([user('a', 1), user('b', 2), user('c', 3), user('owner', 9)]);
|
||||
r = await W.runPass(ctxWith([staffMap([[9, true]])], { allowMass: () => true }));
|
||||
ok(!r.refused && (await get('a')).loginDisabled && (await get('c')).loginDisabled, 'override lets a mass lock through');
|
||||
|
||||
// 20 clean() blanks connection strings and long tokens
|
||||
ok(!W.clean(new Error('connect mongodb://' + 'u:p' + '@h:1/db failed')).includes('u:' + 'p') && !W.clean('x ' + 'A'.repeat(40)).includes('AAAA'), 'clean redacts');
|
||||
|
||||
// 21 the password-less bot on the scratch database (no REST here)
|
||||
await users.deleteMany({});
|
||||
const botId = await W.provisionBot(users);
|
||||
let bot = await users.findOne({ _id: botId });
|
||||
ok(bot.isAdmin === true && bot.trueogLockBot === true && !bot.services.password && bot.loginDisabled === false, 'bot: admin, marked, no password');
|
||||
const tk = await W.mintBotToken(users);
|
||||
bot = await users.findOne({ _id: botId });
|
||||
const sha64 = (await import('node:crypto')).createHash('sha256').update(tk).digest('base64');
|
||||
ok(bot.services.resume.loginTokens.length === 1 && bot.services.resume.loginTokens[0].hashedToken === sha64, 'minted token stored as Meteor hashes it');
|
||||
const tk2 = await W.mintBotToken(users);
|
||||
bot = await users.findOne({ _id: botId });
|
||||
ok(tk2 !== tk && bot.services.resume.loginTokens.length === 1, 'a new token replaces the old one');
|
||||
await users.updateOne({ _id: botId }, { $set: { loginDisabled: true, 'services.password': { bcrypt: 'x' } } });
|
||||
threw = false; try { await W.mintBotToken(users); } catch { threw = true; } ok(threw, 'no token for a disabled bot');
|
||||
ok(await W.provisionBot(users) === botId, 'reprovision keeps the account');
|
||||
bot = await users.findOne({ _id: botId });
|
||||
ok(bot.loginDisabled === false && !bot.services.password && bot.services.resume.loginTokens.length === 0, 'reprovision re-enables, drops any password and tokens');
|
||||
await users.deleteOne({ _id: botId });
|
||||
await users.insertOne({ _id: 'imposter', username: W.BOT_USERNAME, isAdmin: true, profile: { trueogLockBot: true } });
|
||||
threw = false; try { await W.provisionBot(users); } catch { threw = true; } ok(threw, 'refuses an account it did not make');
|
||||
threw = false; try { await W.mintBotToken(users); } catch { threw = true; } ok(threw, 'no token for an account it did not make');
|
||||
ok(await W.removeBot(users) === 0 && await users.findOne({ _id: 'imposter' }), 'remove leaves a foreign account alone');
|
||||
await users.deleteOne({ _id: 'imposter' });
|
||||
await W.provisionBot(users);
|
||||
ok(await W.removeBot(users) === 1 && !(await users.findOne({ username: W.BOT_USERNAME })), 'remove deletes the bot');
|
||||
|
||||
// 22 sign-out: inactive Staff-Auth token ends WeKan sessions (not a lock); fresh sign-in wins; errors change nothing
|
||||
await users.deleteMany({});
|
||||
const withTok = (name, n, token, extra = {}) => ({ _id: name, username: name,
|
||||
services: { oidc: { id: id(n), ...(token ? { accessToken: token } : {}) }, resume: { loginTokens: tok } }, ...extra });
|
||||
await users.insertMany([withTok('a', 1, 'T-a'), withTok('b', 2, 'T-b'), withTok('nt', 3, null),
|
||||
withTok('off', 4, 'T-off', { loginDisabled: true }), { _id: 'local', username: 'local', services: { resume: { loginTokens: tok } } },
|
||||
{ ...withTok('idle', 5, 'T-idle'), services: { oidc: { id: id(5), accessToken: 'T-idle' }, resume: { loginTokens: [] } } }]);
|
||||
const activeSet = new Set(['T-b']);
|
||||
const soCtx = (over = {}) => ({ users, s, write: true, version: '13.06', isActive: async tk => activeSet.has(tk), ...over });
|
||||
let so = await W.runSignOutPass(soCtx());
|
||||
const tokensOf = async n => (await get(n)).services.resume.loginTokens.length;
|
||||
ok(so.signedOut.sort().join() === 'a,nt', 'signs out inactive-token and no-token accounts');
|
||||
ok(await tokensOf('a') === 0 && await tokensOf('nt') === 0 && (await get('a')).loginDisabled !== true, 'sessions cleared, account not disabled');
|
||||
ok(await tokensOf('b') === 1 && await tokensOf('off') === 1 && await tokensOf('local') === 1, 'active, disabled and unlinked accounts untouched');
|
||||
ok((await get('a')).services.oidc.accessToken === 'T-a', 'stored token kept');
|
||||
// a fresh sign-in between the look and the write wins
|
||||
await users.updateOne({ _id: 'a' }, { $set: { 'services.resume.loginTokens': tok } });
|
||||
let calls = 0;
|
||||
so = await W.runSignOutPass(soCtx({ isActive: async tk => { calls++; if (calls === 2) await users.updateOne({ _id: 'a' }, { $set: { 'services.oidc.accessToken': 'T-new' } }); return tk === 'T-new' || activeSet.has(tk); } }));
|
||||
ok(await tokensOf('a') === 1, 'fresh sign-in during the check is not signed out');
|
||||
// introspection error: nothing changes
|
||||
await users.updateOne({ _id: 'a' }, { $set: { 'services.oidc.accessToken': 'T-a' } });
|
||||
threw = false; try { await W.runSignOutPass(soCtx({ isActive: async () => { throw new Error('hydra down'); } })); } catch { threw = true; }
|
||||
ok(threw && await tokensOf('a') === 1, 'Hydra error changes nothing');
|
||||
// not confirmed on the second look (became active again): nothing
|
||||
let flip = 0;
|
||||
so = await W.runSignOutPass(soCtx({ isActive: async tk => tk === 'T-a' ? (flip++ > 0) : activeSet.has(tk) }));
|
||||
ok(await tokensOf('a') === 1, 'not confirmed -> not signed out');
|
||||
// dry run and unreviewed version write nothing
|
||||
so = await W.runSignOutPass(soCtx({ write: false }));
|
||||
ok(so.candidates.length === 1 && await tokensOf('a') === 1, 'dry run writes nothing');
|
||||
so = await W.runSignOutPass(soCtx({ version: '13.99' }));
|
||||
ok(await tokensOf('a') === 1, 'unreviewed version writes nothing');
|
||||
// introspect() against a stand-in Hydra
|
||||
const http = await import('node:http');
|
||||
const srv = http.createServer((req, res) => { let b = ''; req.on('data', d => b += d); req.on('end', () => {
|
||||
const tk = new URLSearchParams(b).get('token');
|
||||
if (tk === 'boom') { res.writeHead(500); return res.end('x'); }
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(tk === 'odd' ? {} : { active: tk === 'good' })); }); });
|
||||
await new Promise(r => srv.listen(0, '127.0.0.1', r));
|
||||
const hs = { ...s, hydraAdmin: 'http://127.0.0.1:' + srv.address().port };
|
||||
ok(await W.introspect(hs, 'good') === true && await W.introspect(hs, 'bad') === false, 'introspect active/inactive');
|
||||
for (const bad of ['boom', 'odd']) { threw = false; try { await W.introspect(hs, bad); } catch { threw = true; } ok(threw, 'introspect throws on ' + bad); }
|
||||
srv.close();
|
||||
|
||||
// 15 parseStaff
|
||||
ok(W.parseStaff(`${id(1)}\tf\n${id(2)}\tt\n`).get(id(2)) === false, 'parseStaff t/f');
|
||||
for (const [bad, name] of [['', 'empty'], [`${id(1)}\tx\n`, 'bad flag'], [`nope\tf\n`, 'bad uuid'], [`${id(1)}\tf\textra\n`, 'extra column']]) {
|
||||
let t = false; try { W.parseStaff(bad); } catch { t = true; } ok(t, 'parseStaff rejects ' + name);
|
||||
}
|
||||
|
||||
// 16 mongoUrl parsing
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wauth-'));
|
||||
const envf = path.join(tmp, 'e');
|
||||
for (const [text, want] of [["export MONGO_URL='mongodb://x/y'\n", 'mongodb://x/y'], ['export MONGO_URL="mongodb://x/z"\n', 'mongodb://x/z'], ['export MONGO_URL=mongodb://x/w\n', 'mongodb://x/w']]) {
|
||||
fs.writeFileSync(envf, '# c\nexport A=1\n' + text); ok(W.mongoUrl(envf) === want, 'mongoUrl ' + want);
|
||||
}
|
||||
fs.writeFileSync(envf, 'export MONGO_URL=http://x\n'); { let t = false; try { W.mongoUrl(envf); } catch { t = true; } ok(t, 'mongoUrl rejects non-mongodb'); }
|
||||
|
||||
if (process.getuid() === 0) {
|
||||
// 17 real psql path against a scratch PostgreSQL database
|
||||
const pg = (sql, db = 'postgres') => spawnSync('/usr/sbin/runuser', ['-u', 'postgres', '--', '/usr/bin/psql', '-X', '-q', '-At', '-v', 'ON_ERROR_STOP=1', '-d', db, '-c', sql], { encoding: 'utf8' });
|
||||
pg('drop database if exists wekan_authwatch_test');
|
||||
ok(pg('create database wekan_authwatch_test').status === 0, 'scratch pg created');
|
||||
pg(`create table users (id serial, uuid uuid not null, deactivated boolean not null, totp_secret text); insert into users (uuid, deactivated, totp_secret) values ('${id(1)}', false, 'S'), ('${id(2)}', true, 'S')`, 'wekan_authwatch_test');
|
||||
const wrapper = path.join(tmp, 'psql');
|
||||
fs.writeFileSync(wrapper, '#!/bin/sh\nexec /usr/sbin/runuser -u postgres -- /usr/bin/psql "$@"\n', { mode: 0o755 });
|
||||
const ps = { ...s, psql: wrapper, pgConninfo: 'dbname=wekan_authwatch_test' };
|
||||
const staff = await W.readStaffViaPsql(ps);
|
||||
ok(staff.get(id(1)) === true && staff.get(id(2)) === false && staff.size === 2, 'psql read');
|
||||
threw = false; try { await W.readStaffViaPsql({ ...ps, pgConninfo: 'dbname=does_not_exist_x' }); } catch { threw = true; } ok(threw, 'psql error throws');
|
||||
pg('delete from users', 'wekan_authwatch_test');
|
||||
threw = false; try { await W.readStaffViaPsql(ps); } catch { threw = true; } ok(threw, 'empty Staff-Auth throws');
|
||||
// column grant: a role with SELECT on uuid, deactivated only can run the query but not read totp_secret
|
||||
pg(`drop role if exists wekan_authwatch_t; create role wekan_authwatch_t; grant select (uuid, deactivated) on users to wekan_authwatch_t; insert into users (uuid, deactivated) values ('${id(1)}', false)`, 'wekan_authwatch_test');
|
||||
ok(pg('set role wekan_authwatch_t; select uuid, deactivated from users', 'wekan_authwatch_test').status === 0, 'column grant allows the query');
|
||||
ok(pg('set role wekan_authwatch_t; select totp_secret from users', 'wekan_authwatch_test').status !== 0, 'column grant hides other columns');
|
||||
pg('drop database wekan_authwatch_test'); pg('drop role wekan_authwatch_t');
|
||||
|
||||
} else {
|
||||
console.log('psql section skipped (needs root for scratch PostgreSQL)');
|
||||
}
|
||||
|
||||
await db.dropDatabase();
|
||||
await client.close();
|
||||
fs.rmSync(tmp, { recursive: true });
|
||||
console.log(`${passed}/${passed + failed} passed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/sbin/openrc-run
|
||||
# /etc/init.d/trueog-wekan-authwatch: locks a WeKan account within about a second of its Staff-Auth account
|
||||
# being deactivated or deleted, and unlocks it when that account is active again. Reads Staff-Auth's users
|
||||
# table (role wekan_authwatch: uuid and deactivated only) and WeKan's users; see the header of
|
||||
# /usr/local/sbin/trueog-wekan-authwatch.mjs. Runs as system (WeKan's user, owner of /opt/wekan/wekan.env)
|
||||
# with the same Node as WeKan (node= in /etc/conf.d/wekan). Stopping it is safe: Staff-Auth still refuses
|
||||
# sign-in for deactivated or deleted accounts; only WeKan sessions already open would then stay.
|
||||
description="TrueOG WeKan lock on Staff-Auth deactivation"
|
||||
|
||||
node=$(sed -n 's/^node="\(.*\)"$/\1/p' /etc/conf.d/wekan 2>/dev/null)
|
||||
|
||||
export USER=system HOME=/home/system PGPASSFILE=/etc/trueog-wekan-authwatch/pgpass
|
||||
|
||||
supervisor="supervise-daemon"
|
||||
command="$node"
|
||||
command_args="/usr/local/sbin/trueog-wekan-authwatch.mjs"
|
||||
command_user="system:system"
|
||||
directory="/"
|
||||
pidfile="/run/trueog-wekan-authwatch.pid"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
output_log="/var/log/wekan/trueog-wekan-authwatch.log"
|
||||
error_log="/var/log/wekan/trueog-wekan-authwatch.log"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after wekan ferretdb postgresql
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
[ -n "$node" ] && [ -x "$node" ] || { eerror "no Node from node= in /etc/conf.d/wekan"; return 1; }
|
||||
[ -r /usr/local/sbin/trueog-wekan-authwatch.mjs ] || { eerror "/usr/local/sbin/trueog-wekan-authwatch.mjs is missing"; return 1; }
|
||||
checkpath -d -m 0755 -o system:system /var/log/wekan
|
||||
checkpath -f -m 0640 -o system:system /var/log/wekan/trueog-wekan-authwatch.log
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Installed to /etc/logrotate.d/trueog-wekan-authwatch by install-wekan-authwatch.sh. The log only gains a
|
||||
# line when the service starts, the number of linked accounts changes, an account is locked or unlocked, or
|
||||
# a problem is first seen (supervise-daemon keeps it open in append mode, hence copytruncate).
|
||||
/var/log/wekan/trueog-wekan-authwatch.log {
|
||||
monthly
|
||||
rotate 12
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su system system
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env node
|
||||
// trueog-wekan-authwatch: locks a WeKan account within about a second of its Staff-Auth account being
|
||||
// deactivated or deleted, and unlocks it when the Staff-Auth account is active again.
|
||||
//
|
||||
// Operator 2026-11-11: WeKan signs in through Staff-Auth (Hydra client "TrueOG WeKan"); any active staff
|
||||
// account may sign in; demotion between roles does not matter for WeKan; deactivation and deletion lock;
|
||||
// open sessions must end within seconds ("A": through WeKan's own API).
|
||||
//
|
||||
// How: once a second it reads uuid and deactivated from Staff-Auth's users table (PostgreSQL role
|
||||
// wekan_authwatch, SELECT on those two columns only) and every WeKan user linked to Staff-Auth
|
||||
// (services.oidc.id = the Staff-Auth uuid, which is Hydra's subject). A linked WeKan account whose
|
||||
// Staff-Auth account is missing or deactivated, confirmed by a second read 0.5 s later, is locked:
|
||||
// 1. directly in the database, in one conditional update: loginDisabled=true, loginDisabledReason=
|
||||
// "staff-auth", resume tokens cleared. This is the lock. WeKan refuses that account's sign-in (every
|
||||
// login type, OIDC included), REST, files and exports from this moment;
|
||||
// 2. then through WeKan's REST API as the password-less admin account staffauth-lockbot (PUT /api/users/:id
|
||||
// {action: disableLogin}, the same change made inside WeKan). A write made inside WeKan makes its
|
||||
// observers re-read at once, so an open browser tab is closed within about 2 s
|
||||
// (METEOR_POLLING_THROTTLE_MS) instead of at WeKan's next poll (METEOR_POLLING_INTERVAL_MS, 30 s).
|
||||
// If this step fails the lock from step 1 stands and the tab closes at that next poll.
|
||||
// Sign-out (operator 2026-11-11: logging out of Staff-Auth must sign people out of the apps too): every few seconds
|
||||
// the Staff-Auth access token WeKan stored at the person's last sign-in (services.oidc.accessToken; the Hydra client
|
||||
// "TrueOG WeKan" issues them for 2160 h) is checked with Hydra's introspection on the admin loopback. A token Hydra
|
||||
// reports inactive (Staff-Auth revoked the person's grants: logout, role change, deactivation; or it expired), twice
|
||||
// 0.5 s apart, ends that account's WeKan sessions: resume tokens cleared, account NOT disabled, conditional on the
|
||||
// stored token being unchanged (a fresh sign-in in between wins). An account with sessions but no stored token (from
|
||||
// before WeKan used Staff-Auth) is signed out too. WeKan then sends the person to Staff-Auth to sign in again. Any
|
||||
// Hydra error (or a non-200 answer) changes nothing. Open tabs close at WeKan's next poll.
|
||||
// An account this service locked is unlocked (loginDisabled=false, reason removed, inactivity clock
|
||||
// restarted as WeKan's own re-enable does) once its Staff-Auth account is active again; the person then
|
||||
// signs in again. Nothing is ever deleted.
|
||||
//
|
||||
// Safeguards:
|
||||
// - acts only when /opt/wekan/current is a WeKan release listed in REVIEWED_WEKAN (the meaning of
|
||||
// loginDisabled, the resume tokens and the REST action were read in that version's source);
|
||||
// otherwise watch only;
|
||||
// - an account disabled by anyone else (an administrator, WeKan's inactive-user job) is never unlocked,
|
||||
// and an account already disabled is never touched. Known limit: an administrator who disables an
|
||||
// account this service had already locked leaves the reason "staff-auth", so it is unlocked when the
|
||||
// person's Staff-Auth account is active again;
|
||||
// - no account is exempt (operator 2026-11-11: "no account should be exempt"); Staff-Auth subjects listed in
|
||||
// TRUEOG_WEKAN_AUTHWATCH_EXEMPT (empty by default, matched by uuid) would never be locked;
|
||||
// - any database error or an empty Staff-Auth user list ends the pass with nothing changed (backoff);
|
||||
// - more than MAX_LOCKS locks at once, or locking every lockable account when two or more are linked,
|
||||
// is refused (a restore or a broken read looks like that) and alerted every 10 minutes until it
|
||||
// clears, unless the operator creates /etc/trueog-wekan-authwatch/allow-mass-lock (alerted every 10
|
||||
// minutes while it exists; every installer run removes it).
|
||||
// Off switch: rc-service trueog-wekan-authwatch stop (Staff-Auth still refuses sign-in for deactivated or
|
||||
// deleted accounts; only WeKan sessions already open would then stay).
|
||||
//
|
||||
// Modes: no argument = run; --check = connect to both databases, one pass without writing, print what
|
||||
// would change, exit 0 with WEKAN_AUTHWATCH_CHECK_OK; --provision-bot = create or reset the password-less
|
||||
// WeKan admin account staffauth-lockbot and prove it acts through the REST API (WEKAN_LOCKBOT_OK);
|
||||
// --remove-bot = delete that account (only one this tool made: marked trueogLockBot).
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
||||
|
||||
export const REVIEWED_WEKAN = ['13.06'];
|
||||
export const REASON = 'staff-auth';
|
||||
export const BOT_USERNAME = 'staffauth-lockbot';
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
export function settings(env = process.env) {
|
||||
return {
|
||||
wekanHome: env.TRUEOG_WEKAN_HOME || '/opt/wekan',
|
||||
envFile: env.TRUEOG_WEKAN_ENV_FILE || '/opt/wekan/wekan.env',
|
||||
wekanUrl: env.TRUEOG_WEKAN_URL || 'http://127.0.0.1:3001',
|
||||
psql: env.TRUEOG_WEKAN_AUTHWATCH_PSQL || '/usr/bin/psql',
|
||||
pgConninfo: env.TRUEOG_WEKAN_AUTHWATCH_PG || 'host=127.0.0.1 dbname=staffauth user=wekan_authwatch connect_timeout=3',
|
||||
pgpass: env.PGPASSFILE || '/etc/trueog-wekan-authwatch/pgpass',
|
||||
hydraAdmin: env.TRUEOG_WEKAN_AUTHWATCH_HYDRA_ADMIN || 'http://127.0.0.1:4445',
|
||||
signOutEveryMs: Number(env.TRUEOG_WEKAN_AUTHWATCH_SIGNOUT_MS || 3000),
|
||||
massLockFile: env.TRUEOG_WEKAN_AUTHWATCH_MASS_LOCK || '/etc/trueog-wekan-authwatch/allow-mass-lock',
|
||||
exempt: (env.TRUEOG_WEKAN_AUTHWATCH_EXEMPT ?? '')
|
||||
.split(/[\s,]+/).filter(Boolean).map(x => x.toLowerCase()),
|
||||
intervalMs: Number(env.TRUEOG_WEKAN_AUTHWATCH_INTERVAL_MS || 1000),
|
||||
confirmMs: Number(env.TRUEOG_WEKAN_AUTHWATCH_CONFIRM_MS || 500),
|
||||
maxLocks: 2,
|
||||
};
|
||||
}
|
||||
|
||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
// Error text may quote a connection string or a token: keep it short and blank those.
|
||||
export const clean = e => String(e?.message ?? e).replace(/mongodb(\+srv)?:\/\/\S+/g, 'mongodb://...')
|
||||
.replace(/[A-Za-z0-9_\-]{32,}/g, '...').split('\n')[0].slice(0, 300);
|
||||
|
||||
// MONGO_URL from WeKan's own env file (export MONGO_URL='...'); never logged.
|
||||
export function mongoUrl(envFile) {
|
||||
const text = fs.readFileSync(envFile, 'utf8');
|
||||
const m = text.match(/^\s*export\s+MONGO_URL=(?:'([^'\n]*)'|"([^"\n]*)"|([^\s'"#]+))\s*$/m);
|
||||
const url = m && (m[1] ?? m[2] ?? m[3]);
|
||||
if (!url || !/^mongodb(\+srv)?:\/\//.test(url)) throw new Error('no MONGO_URL in ' + envFile);
|
||||
return url;
|
||||
}
|
||||
|
||||
// The WeKan release /opt/wekan/current points at ("13.06"), or null.
|
||||
export function wekanVersion(wekanHome) {
|
||||
try {
|
||||
return path.basename(fs.readlinkSync(path.join(wekanHome, 'current')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const bundleRequire = (wekanHome, dir) => createRequire(path.join(wekanHome, 'current/bundle/programs/server/npm/node_modules/meteor', dir, 'index.js'));
|
||||
export const loadMongo = wekanHome => bundleRequire(wekanHome, 'npm-mongo/node_modules')('mongodb');
|
||||
|
||||
// Staff-Auth's accounts: Map(uuid -> active). Throws on any problem, including an empty table.
|
||||
export function readStaffViaPsql(s) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(s.psql, ['-X', '-q', '-A', '-t', '-F', '\t', '-v', 'ON_ERROR_STOP=1', '-d', s.pgConninfo,
|
||||
'-c', 'select uuid, deactivated from users'], {
|
||||
env: { PATH: '/usr/bin:/bin', PGPASSFILE: s.pgpass, PGCONNECT_TIMEOUT: '3', LC_ALL: 'C' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000,
|
||||
});
|
||||
let out = '', err = '';
|
||||
child.stdout.on('data', d => { out += d; });
|
||||
child.stderr.on('data', d => { err += d; });
|
||||
child.on('error', e => reject(new Error('psql could not run (' + e.code + ')')));
|
||||
child.on('close', code => {
|
||||
if (code !== 0) return reject(new Error('psql exit ' + code + ': ' + clean(err)));
|
||||
try { resolve(parseStaff(out)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseStaff(out) {
|
||||
const staff = new Map();
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line) continue;
|
||||
const [uuid, deactivated, extra] = line.split('\t');
|
||||
if (extra !== undefined || !UUID.test(uuid) || !['t', 'f'].includes(deactivated)) {
|
||||
throw new Error('unexpected Staff-Auth row');
|
||||
}
|
||||
staff.set(uuid, deactivated === 'f');
|
||||
}
|
||||
if (!staff.size) throw new Error('Staff-Auth has no users (refusing to judge)');
|
||||
return staff;
|
||||
}
|
||||
|
||||
export const subOf = u => {
|
||||
const id = u?.services?.oidc?.id;
|
||||
return typeof id === 'string' && UUID.test(id.toLowerCase()) ? id.toLowerCase() : null;
|
||||
};
|
||||
|
||||
// What one pass would do, from two reads of Staff-Auth taken confirmMs apart.
|
||||
export function decide(users, staff, staffAgain, s, allowMass = false) {
|
||||
const linked = users.filter(subOf);
|
||||
const lockable = linked.filter(u => !s.exempt.includes(subOf(u)));
|
||||
const lock = [], unlock = [], exemptHits = [];
|
||||
for (const u of linked) {
|
||||
const sub = subOf(u);
|
||||
const active = staff.get(sub) === true, activeAgain = staffAgain.get(sub) === true;
|
||||
if (!active && !activeAgain && u.loginDisabled !== true) {
|
||||
if (s.exempt.includes(sub)) exemptHits.push(u);
|
||||
else lock.push(u);
|
||||
} else if (active && activeAgain && u.loginDisabled === true && u.loginDisabledReason === REASON) {
|
||||
unlock.push(u);
|
||||
}
|
||||
}
|
||||
let refused = null;
|
||||
if (!allowMass) {
|
||||
if (lock.length > s.maxLocks) refused = `${lock.length} accounts at once (limit ${s.maxLocks})`;
|
||||
else if (lock.length >= 2 && lock.length === lockable.length) refused = `every lockable account (${lock.length})`;
|
||||
}
|
||||
return { lock: refused ? [] : lock, unlock, refused, exemptHits, linked: linked.length };
|
||||
}
|
||||
|
||||
export async function lockUser(users, u) {
|
||||
const r = await users.updateOne(
|
||||
{ _id: u._id, 'services.oidc.id': u.services.oidc.id, loginDisabled: { $ne: true } },
|
||||
{ $set: { loginDisabled: true, loginDisabledReason: REASON, 'services.resume.loginTokens': [] } });
|
||||
return r.modifiedCount === 1;
|
||||
}
|
||||
|
||||
export async function unlockUser(users, u) {
|
||||
const r = await users.updateOne(
|
||||
{ _id: u._id, 'services.oidc.id': u.services.oidc.id, loginDisabled: true, loginDisabledReason: REASON },
|
||||
{ $set: { loginDisabled: false, inactivityClockStart: new Date() }, $unset: { loginDisabledReason: '' } });
|
||||
return r.modifiedCount === 1;
|
||||
}
|
||||
|
||||
const PROJECTION = { username: 1, loginDisabled: 1, loginDisabledReason: 1, 'services.oidc.id': 1 };
|
||||
|
||||
// One pass. ctx: { users, readStaff(), s, version, write, note(key, msg), alert(msg), nudge(id), allowMass() }.
|
||||
export async function runPass(ctx) {
|
||||
const { users, s } = ctx;
|
||||
const allowMass = ctx.allowMass ? ctx.allowMass() : false;
|
||||
const staff = await ctx.readStaff();
|
||||
const linked = await users.find({ 'services.oidc.id': { $exists: true } }, { projection: PROJECTION }).toArray();
|
||||
let d = decide(linked, staff, staff, s, allowMass);
|
||||
if (!d.lock.length && !d.unlock.length && !d.refused) return { ...d, done: [] };
|
||||
await sleep(s.confirmMs);
|
||||
const staffAgain = await ctx.readStaff();
|
||||
const fresh = await users.find({ 'services.oidc.id': { $exists: true } }, { projection: PROJECTION }).toArray();
|
||||
d = decide(fresh, staff, staffAgain, s, allowMass);
|
||||
const done = [];
|
||||
if (d.refused) {
|
||||
ctx.alert(`NOT locking ${d.refused}: looks like a restore or a broken read. Check by hand; if it is real, ` +
|
||||
`create ${s.massLockFile} (and remove it afterwards)`);
|
||||
}
|
||||
const reviewed = REVIEWED_WEKAN.includes(ctx.version);
|
||||
if ((d.lock.length || d.unlock.length) && !reviewed) {
|
||||
ctx.note('unreviewed', `WeKan ${ctx.version} is not in REVIEWED_WEKAN: watching only (would lock ${d.lock.length}, unlock ${d.unlock.length})`);
|
||||
return { ...d, done };
|
||||
}
|
||||
if (!ctx.write) return { ...d, done };
|
||||
for (const u of d.lock) {
|
||||
if (await lockUser(users, u)) {
|
||||
log(`locked WeKan account ${u.username} (${subOf(u)}): Staff-Auth account deactivated or deleted`);
|
||||
done.push(['lock', u.username]);
|
||||
if (ctx.nudge) {
|
||||
try {
|
||||
await ctx.nudge(u._id);
|
||||
log(` WeKan disabled ${u.username} itself too: open sessions close within seconds`);
|
||||
} catch (e) {
|
||||
log(` WeKan API nudge failed (${clean(e)}): ${u.username} is locked, open sessions close at WeKan's next poll`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const u of d.unlock) {
|
||||
if (await unlockUser(users, u)) { log(`unlocked WeKan account ${u.username} (${subOf(u)}): Staff-Auth account active again`); done.push(['unlock', u.username]); }
|
||||
}
|
||||
return { ...d, done };
|
||||
}
|
||||
|
||||
// --- sign-out: Staff-Auth tokens Hydra no longer accepts ------------------------------------------------------
|
||||
// true = active, false = inactive; throws on any doubt (network, non-200, malformed answer).
|
||||
export async function introspect(s, token) {
|
||||
const r = await fetch(s.hydraAdmin + '/admin/oauth2/introspect', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ token }), signal: AbortSignal.timeout(5000), redirect: 'error' });
|
||||
if (r.status !== 200) throw new Error('Hydra introspection: HTTP ' + r.status);
|
||||
const j = await r.json();
|
||||
if (typeof j?.active !== 'boolean') throw new Error('Hydra introspection: no active field');
|
||||
return j.active;
|
||||
}
|
||||
|
||||
const SIGNOUT_PROJECTION = { username: 1, loginDisabled: 1, 'services.oidc.id': 1, 'services.oidc.accessToken': 1, 'services.resume.loginTokens': 1 };
|
||||
|
||||
// Which signed-in, enabled, linked accounts no longer hold a Staff-Auth token Hydra accepts.
|
||||
export async function signOutCandidates(users, isActive) {
|
||||
const list = await users.find({ 'services.oidc.id': { $exists: true }, loginDisabled: { $ne: true },
|
||||
'services.resume.loginTokens.0': { $exists: true } }, { projection: SIGNOUT_PROJECTION }).toArray();
|
||||
const out = [];
|
||||
for (const u of list) {
|
||||
if (!subOf(u)) continue;
|
||||
const token = u.services?.oidc?.accessToken;
|
||||
if (typeof token !== 'string' || !token) { out.push({ u, token: null }); continue; }
|
||||
if (!(await isActive(token))) out.push({ u, token });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function signOutUser(users, u, token) {
|
||||
const filter = { _id: u._id, 'services.oidc.id': u.services.oidc.id, loginDisabled: { $ne: true },
|
||||
'services.oidc.accessToken': token === null ? { $exists: false } : token };
|
||||
const r = await users.updateOne(filter, { $set: { 'services.resume.loginTokens': [] } });
|
||||
return r.modifiedCount === 1;
|
||||
}
|
||||
|
||||
// One sign-out pass: candidates confirmed by a second look confirmMs later. ctx as runPass, plus isActive(token).
|
||||
export async function runSignOutPass(ctx) {
|
||||
const { users, s } = ctx;
|
||||
const first = await signOutCandidates(users, ctx.isActive);
|
||||
if (!first.length) return { signedOut: [], candidates: [] };
|
||||
await sleep(s.confirmMs);
|
||||
const again = await signOutCandidates(users, ctx.isActive);
|
||||
const confirmed = again.filter(c => first.some(f => f.u._id === c.u._id && f.token === c.token));
|
||||
const signedOut = [];
|
||||
if (!ctx.write || !REVIEWED_WEKAN.includes(ctx.version)) return { signedOut, candidates: confirmed };
|
||||
for (const { u, token } of confirmed) {
|
||||
if (await signOutUser(users, u, token)) {
|
||||
log(`signed out WeKan account ${u.username} (${subOf(u)}): ` +
|
||||
(token === null ? 'it had sessions but no Staff-Auth sign-in' : 'its Staff-Auth sign-in ended (logout, revocation or expiry)'));
|
||||
signedOut.push(u.username);
|
||||
}
|
||||
}
|
||||
return { signedOut, candidates: confirmed };
|
||||
}
|
||||
|
||||
// --- staffauth-lockbot: a WeKan administrator with no password ------------------------------------------
|
||||
// It cannot sign in anywhere. The service gives it a login token by writing the token's hash straight into
|
||||
// the account (as Meteor itself stores them) and keeps the token in memory only; a new one replaces the old
|
||||
// at every start, every 12 hours, and whenever WeKan answers 401/403. Without a password WeKan's 2FA
|
||||
// requirement does not apply to it (models/lib/twoFactorRequirement.js: password sign-ins only).
|
||||
const METEOR_ID = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const meteorId = () => Array.from({ length: 17 }, () => METEOR_ID[randomInt(METEOR_ID.length)]).join('');
|
||||
const BOT_FILTER = { username: BOT_USERNAME, trueogLockBot: true };
|
||||
|
||||
// Create the bot, or reset it (administrator, enabled, no password, no tokens). Returns its _id.
|
||||
export async function provisionBot(users) {
|
||||
const existing = await users.findOne({ username: BOT_USERNAME });
|
||||
if (existing) {
|
||||
if (existing.trueogLockBot !== true) throw new Error(`a WeKan account named ${BOT_USERNAME} exists that this tool did not make`);
|
||||
await users.updateOne({ _id: existing._id }, {
|
||||
$set: { isAdmin: true, loginDisabled: false, 'services.resume.loginTokens': [], modifiedAt: new Date() },
|
||||
$unset: { loginDisabledReason: '', 'services.password': '' } });
|
||||
return existing._id;
|
||||
}
|
||||
const id = meteorId(), now = new Date();
|
||||
await users.insertOne({
|
||||
_id: id, createdAt: now, modifiedAt: now, username: BOT_USERNAME, trueogLockBot: true,
|
||||
emails: [{ address: 'staffauth-lockbot@wekan.invalid', verified: true }],
|
||||
services: { resume: { loginTokens: [] } },
|
||||
isAdmin: true, loginDisabled: false, authenticationMethod: 'password', teams: [], orgs: [],
|
||||
profile: { fullname: 'Staff-Auth lock bot (no password; used by trueog-wekan-authwatch)', initials: 'SA', boardView: 'board-view-swimlanes' },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function removeBot(users) {
|
||||
return (await users.deleteOne(BOT_FILTER)).deletedCount;
|
||||
}
|
||||
|
||||
// A fresh login token for the bot (replacing any other); throws if the bot is missing or disabled.
|
||||
export async function mintBotToken(users) {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const hashedToken = createHash('sha256').update(token).digest('base64');
|
||||
const r = await users.updateOne({ ...BOT_FILTER, isAdmin: true, loginDisabled: { $ne: true } },
|
||||
{ $set: { 'services.resume.loginTokens': [{ hashedToken, when: new Date() }] } });
|
||||
if (r.matchedCount !== 1) throw new Error(`WeKan account ${BOT_USERNAME} is missing, disabled or not an administrator (run the installer)`);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function http(url, opts) {
|
||||
const r = await fetch(url, { ...opts, signal: AbortSignal.timeout(5000), redirect: 'error' });
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try { body = JSON.parse(text); } catch { /* not JSON */ }
|
||||
return { status: r.status, body };
|
||||
}
|
||||
|
||||
export function makeRestClient(s, mint) {
|
||||
let token = null, mintedAt = 0;
|
||||
const fresh = async () => { token = await mint(); mintedAt = Date.now(); };
|
||||
const call = async (method, p, body) => {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (!token || Date.now() - mintedAt > 12 * 3600e3) await fresh();
|
||||
const r = await http(s.wekanUrl + p, { method, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
||||
body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
if ((r.status === 401 || r.status === 403) && attempt === 0) { token = null; continue; }
|
||||
if (r.status !== 200) throw new Error(`${method} ${p.replace(/[^/]+$/, ':id')}: HTTP ${r.status}`);
|
||||
return r.body;
|
||||
}
|
||||
};
|
||||
return {
|
||||
async disableLogin(id) {
|
||||
const b = await call('PUT', '/api/users/' + encodeURIComponent(id), { action: 'disableLogin' });
|
||||
if (b?.loginDisabled !== true) throw new Error('WeKan did not report the account disabled');
|
||||
},
|
||||
// Signs in with a new token and proves it is the bot and an administrator.
|
||||
async verify() {
|
||||
await fresh();
|
||||
const me = await call('GET', '/api/user');
|
||||
if (me?.username !== BOT_USERNAME || me?.isAdmin !== true) throw new Error('the token does not act as the admin bot');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const s = settings();
|
||||
const args = process.argv.slice(2);
|
||||
const { MongoClient } = loadMongo(s.wekanHome);
|
||||
const client = new MongoClient(mongoUrl(s.envFile), { serverSelectionTimeoutMS: 5000 });
|
||||
await client.connect();
|
||||
const users = client.db().collection('users');
|
||||
const rest = makeRestClient(s, () => mintBotToken(users));
|
||||
|
||||
if (args[0] === '--provision-bot') {
|
||||
const id = await provisionBot(users);
|
||||
await rest.verify();
|
||||
await users.updateOne({ _id: id }, { $set: { 'services.resume.loginTokens': [] } });
|
||||
await client.close();
|
||||
console.log(`${BOT_USERNAME} (${id}) is an administrator without a password and acts through the REST API`);
|
||||
console.log('WEKAN_LOCKBOT_OK');
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--remove-bot') {
|
||||
console.log(`removed ${await removeBot(users)} account(s) named ${BOT_USERNAME} made by this tool`);
|
||||
await client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = args.includes('--check');
|
||||
const seen = new Map();
|
||||
const note = (key, msg) => { if (seen.get(key) !== msg) { seen.set(key, msg); log(msg); } };
|
||||
const lastAlert = new Map();
|
||||
const alert = (msg, key = 'refused') => {
|
||||
if (Date.now() - (lastAlert.get(key) || 0) >= 600000) { lastAlert.set(key, Date.now()); log('ALERT ' + msg); }
|
||||
};
|
||||
let nudgeOn = false;
|
||||
try { await rest.verify(); nudgeOn = true; } catch (e) { note('bot', `WeKan API nudge unavailable (${clean(e)}): open sessions close at WeKan's next poll`); }
|
||||
const ctx = {
|
||||
users, s, write: !check, note, alert, version: wekanVersion(s.wekanHome),
|
||||
readStaff: () => readStaffViaPsql(s),
|
||||
nudge: id => rest.disableLogin(id),
|
||||
allowMass: () => fs.existsSync(s.massLockFile),
|
||||
isActive: token => introspect(s, token),
|
||||
};
|
||||
|
||||
if (check) {
|
||||
let refusedMsg = null;
|
||||
ctx.alert = m => { refusedMsg = m; };
|
||||
const r = await runPass(ctx);
|
||||
const so = await runSignOutPass(ctx);
|
||||
const exemptNames = (await users.find({ 'services.oidc.id': { $exists: true } }, { projection: PROJECTION }).toArray())
|
||||
.filter(u => s.exempt.includes(subOf(u))).map(u => u.username);
|
||||
await users.updateOne(BOT_FILTER, { $set: { 'services.resume.loginTokens': [] } });
|
||||
console.log(`WeKan ${ctx.version} (${REVIEWED_WEKAN.includes(ctx.version) ? 'reviewed' : 'NOT reviewed: watch only'}), ` +
|
||||
`${r.linked} linked accounts, exempt [${exemptNames}], would lock [${r.lock.map(u => u.username)}], unlock [${r.unlock.map(u => u.username)}]` +
|
||||
(refusedMsg ? `, refused: ${r.refused}` : '') + (r.exemptHits.length ? `, exempt and inactive: [${r.exemptHits.map(u => u.username)}]` : '') +
|
||||
(ctx.allowMass() ? ', MASS-LOCK GUARD OFF' : '') + `, would sign out [${so.candidates.map(c => c.u.username)}]` +
|
||||
`, API: ${nudgeOn ? 'bot signs in' : 'bot UNAVAILABLE'}`);
|
||||
await client.close();
|
||||
console.log('WEKAN_AUTHWATCH_CHECK_OK');
|
||||
return;
|
||||
}
|
||||
|
||||
let stopping = false, wake = null;
|
||||
const pause = ms => stopping ? Promise.resolve() : new Promise(r => { const t = setTimeout(r, ms); wake = () => { clearTimeout(t); r(); }; });
|
||||
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { stopping = true; if (wake) wake(); });
|
||||
const own = createHash('sha256').update(fs.readFileSync(fileURLToPath(import.meta.url))).digest('hex').slice(0, 16);
|
||||
log(`started version=${own} : WeKan ${ctx.version}${REVIEWED_WEKAN.includes(ctx.version) ? '' : ' (not reviewed: watch only)'}, ` +
|
||||
`exempt [${s.exempt}], API nudge ${nudgeOn ? 'on' : 'OFF'}`);
|
||||
let backoff = 0, lastLinked = -1, lastSignOut = 0;
|
||||
while (!stopping) {
|
||||
try {
|
||||
ctx.version = wekanVersion(s.wekanHome);
|
||||
if (Date.now() - lastSignOut >= s.signOutEveryMs) { lastSignOut = Date.now(); await runSignOutPass(ctx); }
|
||||
if (ctx.allowMass()) alert(`${s.massLockFile} exists: the mass-lock guard is OFF; remove the file when done`, 'massfile');
|
||||
const r = await runPass(ctx);
|
||||
if (r.linked !== lastLinked) { log(`watching ${r.linked} linked WeKan accounts`); lastLinked = r.linked; }
|
||||
for (const u of r.exemptHits) note('exempt:' + subOf(u), `exempt account ${u.username}: its Staff-Auth account is deactivated or deleted; not locked`);
|
||||
if (!r.refused) lastAlert.delete('refused');
|
||||
if (backoff) { log('recovered'); seen.delete('error'); }
|
||||
backoff = 0;
|
||||
await pause(s.intervalMs);
|
||||
} catch (e) {
|
||||
backoff = Math.min(backoff ? backoff * 2 : 1000, 30000);
|
||||
note('error', `pass failed, nothing changed: ${clean(e)} (retrying, backoff up to 30 s)`);
|
||||
await pause(backoff);
|
||||
}
|
||||
}
|
||||
log('stopping');
|
||||
await users.updateOne(BOT_FILTER, { $set: { 'services.resume.loginTokens': [] } }).catch(() => {});
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
main().catch(e => { log('fatal:', clean(e)); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
# Map for WebSocket upgrades.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
|
||||
default upgrade;
|
||||
'' close;
|
||||
|
||||
}
|
||||
|
||||
# Real client IP when behind Cloudflare.
|
||||
# real_ip_header CF-Connecting-IP;
|
||||
# set_real_ip_from 173.245.48.0/20;
|
||||
# set_real_ip_from 103.21.244.0/22;
|
||||
# ... (add ALL Cloudflare IPv4/IPv6 ranges)
|
||||
# include /etc/nginx/conf.d/cloudflare-real-ip.conf;
|
||||
|
||||
# HTTP: ACME + redirect to canonical HTTPS host.
|
||||
server {
|
||||
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name todo.trueog.net todo.true-og.net;
|
||||
|
||||
# Let’s Encrypt HTTP-01 challenge (served on both names).
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
default_type text/plain;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Everything else: send to canonical HTTPS host.
|
||||
return 301 https://todo.true-og.net$request_uri;
|
||||
|
||||
}
|
||||
|
||||
# Canonical HTTPS vhost.
|
||||
# Must match wekan ROOT_URL.
|
||||
server {
|
||||
|
||||
listen 443 ssl;
|
||||
#listen [::]:443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name todo.true-og.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/trueog-combined/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/trueog-combined/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# Bigger uploads.
|
||||
client_max_body_size 100m;
|
||||
|
||||
# Reverse proxy defaults for Meteor/Wekan.
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Preserve original host/scheme so Meteor builds correct absolute URLs.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Real client IP chain.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Timeouts / buffering for DDP and SockJS.
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
|
||||
# Cache immutable hashed static assets that Wekan serves.
|
||||
location ~* \.(?:js|css|woff2?|ttf|svg|eot)$ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800, must-revalidate";
|
||||
}
|
||||
|
||||
# Be explicit about Meteor's realtime endpoints.
|
||||
location /sockjs/ { proxy_pass http://127.0.0.1:3001; }
|
||||
location /websocket { proxy_pass http://127.0.0.1:3001; }
|
||||
|
||||
# Everything else.
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
|
||||
# TrueOG 2026-11-11: WeKan signs in only through Staff-Auth and redirects there automatically
|
||||
# (OIDC_REDIRECTION_ENABLED), but only after its own sign-in page has drawn. This style keeps that page
|
||||
# (.auth-layout, which only the sign-in pages have) invisible for its first 3 seconds, so people go
|
||||
# straight to Staff-Auth; if the redirect did not happen, the "Sign in with Staff-Auth" button appears.
|
||||
# sub_filter needs the uncompressed page, hence Accept-Encoding "". A proxy_set_header here replaces every
|
||||
# server-level one, so they are all repeated (WebSocket upgrade, host/scheme, real client chain).
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<style>.auth-layout,.textBelowCustomLoginLogo{animation:trueogAuthShow 0s 3s both}@keyframes trueogAuthShow{from{visibility:hidden}to{visibility:visible}}</style></head>';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# HTTPS vhost for the non-canonical domain -> redirect.
|
||||
server {
|
||||
|
||||
listen 443 ssl;
|
||||
#listen [::]:443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name todo.trueog.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/trueog-combined/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/trueog-combined/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
return 301 https://todo.true-og.net$request_uri;
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node="/home/system/.nvm/versions/node/v26.13.0/bin/node"
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/sbin/openrc-run
|
||||
description="Wekan service"
|
||||
|
||||
: ${user:="system"}
|
||||
: ${node:="/home/system/.nvm/versions/node/v24.16.0/bin/node"}
|
||||
: ${env_file:="/opt/wekan/wekan.env"}
|
||||
: ${runtime_dir:="/run/wekan"}
|
||||
: ${log_file:="/var/log/wekan/wekan.log"}
|
||||
: ${ssd:="/usr/lib/rc/sbin/start-stop-daemon"}
|
||||
|
||||
. "$env_file"
|
||||
|
||||
command="/usr/bin/setpriv"
|
||||
command_args="--reuid=$user --regid=$user --init-groups $node current/bundle/main.js"
|
||||
directory="/opt/wekan"
|
||||
pidfile="$runtime_dir/wekan.pid"
|
||||
|
||||
export HOME="/home/$user"
|
||||
export USER="$user"
|
||||
export LOGNAME="$user"
|
||||
|
||||
depend() {
|
||||
after mountall bootmisc networking ferretdb
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath -d -m 0750 -o "$user:$user" "$runtime_dir"
|
||||
checkpath -d -m 0755 -o "$user:$user" "$(dirname "$log_file")"
|
||||
checkpath -f -m 0644 -o "$user:$user" "$log_file"
|
||||
|
||||
# Remove state left by the previous tmux-based service.
|
||||
if [ -S "$runtime_dir/tmux.sock" ]; then
|
||||
rm -f "$runtime_dir/tmux.sock"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
start() {
|
||||
if is_running; then
|
||||
ewarn "Wekan is already running"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -f "$pidfile" ] && rm -f "$pidfile"
|
||||
|
||||
ebegin "Starting Wekan"
|
||||
"$ssd" --start \
|
||||
--background \
|
||||
--make-pidfile \
|
||||
--pidfile "$pidfile" \
|
||||
--chdir "$directory" \
|
||||
--exec "$command" \
|
||||
--stdout "$log_file" \
|
||||
--stderr "$log_file" \
|
||||
-- $command_args
|
||||
eend $? "Failed to start Wekan"
|
||||
}
|
||||
|
||||
stop() {
|
||||
if ! is_running; then
|
||||
rm -f "$pidfile"
|
||||
ewarn "Wekan is not running"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ebegin "Stopping Wekan"
|
||||
"$ssd" --stop \
|
||||
--pidfile "$pidfile" \
|
||||
--retry TERM/15/KILL/5
|
||||
local retval=$?
|
||||
rm -f "$pidfile"
|
||||
eend "$retval" "Failed to stop Wekan"
|
||||
}
|
||||
|
||||
status() {
|
||||
if is_running; then
|
||||
einfo "Wekan is running (PID $(cat "$pidfile"))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
einfo "Wekan is stopped"
|
||||
return 3
|
||||
}
|
||||
|
||||
is_running() {
|
||||
local pid
|
||||
|
||||
[ -r "$pidfile" ] || return 1
|
||||
pid="$(cat "$pidfile")"
|
||||
case "$pid" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
|
||||
kill -0 "$pid" 2>/dev/null || return 1
|
||||
[ "$(readlink -f "/proc/$pid/exe" 2>/dev/null)" = "$node" ]
|
||||
}
|
||||
|
||||
console() {
|
||||
touch "$log_file"
|
||||
exec tail -n 100 -f "$log_file"
|
||||
}
|
||||
|
||||
extra_commands="console"
|
||||
extra_started_commands="console"
|
||||
description_console="Follow the Wekan log"
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
#!/bin/sh
|
||||
# WeKan upgrade in steps (operator, 2026-11-10: "13.06 is the latest. update in steps if you have to for my data
|
||||
# to be safe"): 11.87 -> 11.99 -> 12.99 -> 13.06, one step per run, each backed up, rehearsed and verified.
|
||||
# doas sh /home/system/gitea-upgrade/upgrade-wekan.sh check read-only: where things stand
|
||||
# doas sh /home/system/gitea-upgrade/upgrade-wekan.sh step <version> the next step only
|
||||
# doas sh /home/system/gitea-upgrade/upgrade-wekan.sh restore <backup dir> put a step's backup back (data and release)
|
||||
# Complete output and UPGRADE_WEKAN_EXIT=<status> go to logs/upgrade-wekan-<phase>-<time>.log.
|
||||
#
|
||||
# A step:
|
||||
# 1. checks that <version> is the next one, its zip matches the pinned sha256, Node 26 exists, WeKan answers
|
||||
# 2. STOPS WeKan, so the backup is one consistent moment
|
||||
# 3. snapshots the database (every collection's count, the _ids of documents that must survive) and backs up into
|
||||
# /opt/wekan/backups/pre-<version>-<time>: a mongodump of the whole `wekan` database, a pg_dump of PostgreSQL's
|
||||
# ferretdb database (read back in full), /opt/wekan/data, wekan.env and /etc/conf.d/wekan
|
||||
# 4. rehearses the restore: the mongodump is restored into a scratch database and compared with the snapshot,
|
||||
# then the scratch database is dropped
|
||||
# 5. unpacks and checks the release; for 13.06 also applies TrueOG's setBoardTeams patch (app.js.orig-13.06 kept)
|
||||
# 6. points /etc/conf.d/wekan at Node 26 and /opt/wekan/current at the release, and starts it; from this moment the
|
||||
# new release may change the data (its one-time upgrade steps)
|
||||
# 7. waits for /schema-upgrade-status?json to report the upgrade steps finished with no error (up to 30 minutes)
|
||||
# 7b. requires that report to be for <version>, with no failed step, nothing unresolved, a clean re-check recorded
|
||||
# 8. verifies: no user, board, card, swimlane, custom field, comment, checklist, attachment, team, org or setting
|
||||
# is gone; no more broken references (card->board/list/swimlane, list/swimlane->board, comment/checklist->card,
|
||||
# checklist item->checklist) than before; lists may only vanish by 13.06's duplicate-list merge
|
||||
# Any failure BEFORE step 6 starts the old release again unchanged. A failure AFTER it stops WeKan and leaves it
|
||||
# stopped: the old release must not run on data the new one already changed. Then run `restore <backup dir>`
|
||||
# (the command is printed), which checks the backup's files, drops the database, restores it, puts files, conf.d
|
||||
# and the previous release back and verifies every collection's count and the surviving kinds' _ids exactly.
|
||||
SRC=/home/system/gitea-upgrade
|
||||
[ "$(id -u)" = 0 ] || { echo "run as root: doas sh $0 check|step <version>|restore <backup dir>" >&2; exit 1; }
|
||||
phase=${1:-}; arg=${2:-}
|
||||
case "$phase" in
|
||||
check) [ "$#" = 1 ] || { echo "usage: $0 check" >&2; exit 2; } ;;
|
||||
step|restore) [ "$#" = 2 ] || { echo "usage: $0 $phase <$( [ "$phase" = step ] && echo version || echo 'backup dir')>" >&2; exit 2; } ;;
|
||||
*) echo "usage: doas sh $0 check|step <version>|restore <backup dir>" >&2; exit 2 ;;
|
||||
esac
|
||||
if [ -z "${UPGRADE_WEKAN_LOGGED:-}" ]; then
|
||||
mkdir -p "$SRC/logs"
|
||||
LOG=$SRC/logs/upgrade-wekan-$phase-$(basename "${arg:-x}")-$(date +%F_%H-%M-%S).log
|
||||
: > "$LOG"; chmod 644 "$LOG"; chown system:system "$LOG"
|
||||
echo "started $(date -Is): sh $0 $*" >> "$LOG"
|
||||
UPGRADE_WEKAN_LOGGED=1 sh "$0" "$@" >> "$LOG" 2>&1
|
||||
rc=$?
|
||||
echo "finished $(date -Is)" >> "$LOG"
|
||||
echo "UPGRADE_WEKAN_EXIT=$rc" >> "$LOG"
|
||||
cat "$LOG"
|
||||
exit "$rc"
|
||||
fi
|
||||
set -eu
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
W=/opt/wekan
|
||||
INCOMING=$W/incoming
|
||||
CONFD=/etc/conf.d/wekan
|
||||
PIDFILE=/run/wekan/wekan.pid
|
||||
NODE26=/home/system/.nvm/versions/node/v26.13.0/bin/node
|
||||
STEPS="11.87 11.99 12.99 13.06"
|
||||
REHEARSAL_DB=wekan_restore_rehearsal
|
||||
sha_for() {
|
||||
case "$1" in
|
||||
11.99) echo 9176473ac61a8abc63224c3aa202729bec583c493ad2458964f085c62ced35f4 ;;
|
||||
12.99) echo 76468004391d4e643631691f20f786131753f41578885dc93a52accf7480cb0c ;;
|
||||
13.06) echo 83873c53362d47160e3fb9a7623460647800af6a799d7e9faae5fb3f19ef4836 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
commit_for() {
|
||||
case "$1" in
|
||||
11.99) echo e09307cda128 ;;
|
||||
12.99) echo 1f12b20291dc ;;
|
||||
13.06) echo 77239ee100aa ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
as_system() { runuser -u system -- "$@"; }
|
||||
current_version() { basename "$(readlink "$W/current")"; }
|
||||
next_after() { echo "$STEPS" | tr ' ' '\n' | awk -v c="$1" 'f { print; exit } $0 == c { f = 1 }'; }
|
||||
answering() { [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:3001/)" = 200 ]; }
|
||||
db() { # db <bundle dir> <wekan-db.js arguments...>: as system, with wekan.env loaded, the URI never in argv
|
||||
b=$1; shift
|
||||
as_system /bin/sh -c 'set -a; . /opt/wekan/wekan.env; set +a; exec "$0" "$@"' "$NODE26" "$SRC/wekan-db.js" "$b" "$@"
|
||||
}
|
||||
wekan_pids() { # every running WeKan server process (node <...>/bundle/main.js run as system)
|
||||
for p in $(pgrep -u system -f 'bundle/main\.js' || true); do
|
||||
case "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null)" in */bin/node\ current/bundle/main.js*) echo "$p" ;; esac
|
||||
done
|
||||
}
|
||||
stop_wekan() {
|
||||
rc-service wekan stop >/dev/null 2>&1 || true
|
||||
n=0
|
||||
while [ -n "$(wekan_pids)" ]; do
|
||||
if [ "$n" -eq 30 ]; then for p in $(wekan_pids); do kill -TERM "$p" 2>/dev/null || true; done; fi
|
||||
if [ "$n" -eq 45 ]; then for p in $(wekan_pids); do kill -KILL "$p" 2>/dev/null || true; done; fi
|
||||
[ "$n" -lt 60 ] || { echo "WeKan did not stop" >&2; return 1; }
|
||||
sleep 1; n=$((n + 1))
|
||||
done
|
||||
rm -f "$PIDFILE"
|
||||
rc-service wekan zap >/dev/null 2>&1 || true
|
||||
}
|
||||
start_wekan() { rc-service wekan start; }
|
||||
set_node() { # set_node <path>: change only node= in /etc/conf.d/wekan, keep anything else
|
||||
tmp=$(mktemp "$CONFD.XXXXXX")
|
||||
{ [ ! -e "$CONFD" ] || sed '/^node=/d' "$CONFD"; printf 'node="%s"\n' "$1"; } > "$tmp"
|
||||
chmod 644 "$tmp"; mv -f "$tmp" "$CONFD"
|
||||
}
|
||||
upgrade_done() { # upgrade_done <version>: 0 = its upgrade steps ran and finished clean, 1 = still running, 2 = not clean
|
||||
curl -s --max-time 10 'http://127.0.0.1:3001/schema-upgrade-status?json' | /usr/bin/python3 -I "$SRC/wekan-upgrade-status.py" "$1"
|
||||
}
|
||||
CFG=
|
||||
WORKDB=
|
||||
cleanup() {
|
||||
[ -z "$CFG" ] || rm -f "$CFG"
|
||||
if [ -n "$WORKDB" ]; then db "$W/current/bundle" drop "$WORKDB" >/dev/null 2>&1 || true; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
write_cfg() { # a 0600 mongo tools config holding the URI, readable by system only
|
||||
CFG=$(mktemp /run/wekan-tools.XXXXXX); chmod 600 "$CFG"; chown system:system "$CFG"
|
||||
as_system /bin/sh -c 'set -a; . /opt/wekan/wekan.env; set +a; exec /usr/bin/python3 -I /home/system/gitea-upgrade/wekan-tools-uri.py' > "$CFG"
|
||||
}
|
||||
|
||||
echo "== state"
|
||||
cur=$(current_version)
|
||||
echo "current release: $cur"
|
||||
echo "WeKan processes: $(wekan_pids | tr '\n' ' ')"
|
||||
echo "WeKan answers on 127.0.0.1:3001: $(answering && echo yes || echo NO)"
|
||||
echo "FerretDB: $(rc-service ferretdb status 2>&1 | tail -1)"
|
||||
echo "Node 26: $($NODE26 --version 2>/dev/null || echo MISSING)"
|
||||
echo "/etc/conf.d/wekan node: $(grep '^node=' "$CONFD" 2>/dev/null || echo 'not set (Node 24 from the init script)')"
|
||||
for v in 11.99 12.99 13.06; do
|
||||
got=$(sha256sum "$INCOMING/wekan-$v-amd64.zip" 2>/dev/null | cut -d' ' -f1 || true)
|
||||
echo "zip $v: $([ "$got" = "$(sha_for "$v")" ] && echo verified || echo "MISSING OR WRONG")"
|
||||
done
|
||||
echo "next step: $(next_after "$cur" || true)"
|
||||
if [ "$phase" = check ]; then
|
||||
echo "UPGRADE_WEKAN_CHECK_DONE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$phase" = restore ]; then
|
||||
B=$arg
|
||||
[ -s "$B/manifest.json" ] && [ -d "$B/mongodump" ] && [ -s "$B/previous-release" ] && [ -s "$B/data.tar" ] || { echo "not a complete backup: $B" >&2; exit 1; }
|
||||
prev=$(cat "$B/previous-release")
|
||||
dbname=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["database"])' "$B/manifest.json")
|
||||
[ -d "$W/releases/$prev/bundle" ] || { echo "release $prev is missing" >&2; exit 1; }
|
||||
echo "== restore $B: database $dbname, files, release $prev"
|
||||
rm -rf "$W/data.restore-tmp"; mkdir -m 700 "$W/data.restore-tmp"
|
||||
tar -xpf "$B/data.tar" -C "$W/data.restore-tmp"
|
||||
[ -d "$W/data.restore-tmp/data" ] && [ "$(ls -A "$W/data.restore-tmp" | wc -l)" = 1 ] || { echo "data.tar does not hold exactly data/" >&2; exit 1; }
|
||||
[ "$(cd "$W/data.restore-tmp" && find data | wc -l)" = "$(tar -tf "$B/data.tar" | sed 's#/$##' | sort -u | wc -l)" ] || { echo "data.tar did not extract completely" >&2; exit 1; }
|
||||
stop_wekan
|
||||
write_cfg
|
||||
db "$W/releases/$prev/bundle" dropdb "$dbname" "$B/manifest.json"
|
||||
as_system mongorestore --config="$CFG" --nsInclude="$dbname.*" --quiet "$B/mongodump"
|
||||
rm -rf "$W/data.before-restore"; mv "$W/data" "$W/data.before-restore"; mv "$W/data.restore-tmp/data" "$W/data"; rm -rf "$W/data.restore-tmp"
|
||||
if [ -e "$B/conf.d-wekan" ]; then cp -p "$B/conf.d-wekan" "$CONFD"; else rm -f "$CONFD"; fi
|
||||
ln -sfn "releases/$prev" "$W/current"
|
||||
db "$W/current/bundle" verify "$B/manifest.json" exact || { echo "the restored database differs from the backup's manifest; WeKan left stopped" >&2; exit 1; }
|
||||
start_wekan
|
||||
n=0; until answering; do [ "$n" -lt 600 ] || { echo "WeKan $prev did not answer after the restore" >&2; exit 1; }; sleep 3; n=$((n + 3)); done
|
||||
echo "restored: database, files and release $prev; previous files kept in $W/data.before-restore"
|
||||
echo "UPGRADE_WEKAN_RESTORE_OK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- step ----
|
||||
target=$arg
|
||||
[ "$(next_after "$cur")" = "$target" ] || { echo "refusing: the next step after $cur is $(next_after "$cur"), not $target" >&2; exit 1; }
|
||||
zip=$INCOMING/wekan-$target-amd64.zip
|
||||
[ "$(sha256sum "$zip" | cut -d' ' -f1)" = "$(sha_for "$target")" ] || { echo "refusing: $zip does not match its pinned sha256" >&2; exit 1; }
|
||||
[ -x "$NODE26" ] || { echo "refusing: $NODE26 is missing" >&2; exit 1; }
|
||||
answering || { echo "refusing: WeKan does not answer before the step" >&2; exit 1; }
|
||||
rc-service ferretdb status >/dev/null 2>&1 || { echo "refusing: FerretDB is not running" >&2; exit 1; }
|
||||
[ "$(df -Pk /opt | awk 'NR==2 {print $4}')" -gt 5242880 ] || { echo "refusing: less than 5 GiB free on /opt" >&2; exit 1; }
|
||||
B=$W/backups/pre-$target-$(date +%F_%H-%M-%S)
|
||||
prev_conf_saved=no
|
||||
PHASE=before-stop
|
||||
on_exit() {
|
||||
status=$?
|
||||
trap - EXIT
|
||||
cleanup
|
||||
if [ "$status" != 0 ]; then
|
||||
case "$PHASE" in
|
||||
stopped)
|
||||
echo "step failed before $target started: starting $cur again, unchanged" >&2
|
||||
if [ "$prev_conf_saved" = yes ]; then if [ -e "$B/conf.d-wekan" ]; then cp -p "$B/conf.d-wekan" "$CONFD"; else rm -f "$CONFD"; fi; fi
|
||||
ln -sfn "releases/$cur" "$W/current"
|
||||
start_wekan || true
|
||||
n=0; until answering; do [ "$n" -lt 600 ] || break; sleep 3; n=$((n + 3)); done
|
||||
echo "$cur $(answering && echo is answering again || echo 'is NOT answering: see /var/log/wekan/wekan.log')" >&2 ;;
|
||||
started)
|
||||
echo "STEP FAILED after $target started and may have changed the data: WeKan is stopped and stays stopped." >&2
|
||||
stop_wekan || true
|
||||
echo "Read the reason above. To put database, files and release back as they were before this step:" >&2
|
||||
echo " doas sh $SRC/upgrade-wekan.sh restore $B" >&2
|
||||
echo "(or, if the reason is harmless, start $target as it is: doas rc-service wekan start)" >&2 ;;
|
||||
esac
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap on_exit EXIT
|
||||
|
||||
echo "== stop WeKan $cur for a consistent backup"
|
||||
stop_wekan
|
||||
PHASE=stopped
|
||||
|
||||
echo "== backup into $B"
|
||||
install -d -m 700 -o system -g system "$B"
|
||||
echo "$cur" > "$B/previous-release"
|
||||
[ ! -e "$CONFD" ] || cp -p "$CONFD" "$B/conf.d-wekan"
|
||||
prev_conf_saved=yes
|
||||
cp -p "$W/wekan.env" "$B/wekan.env"
|
||||
db "$W/current/bundle" snapshot "$B/manifest.json"
|
||||
dbname=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["database"])' "$B/manifest.json")
|
||||
write_cfg
|
||||
as_system mongodump --config="$CFG" --db="$dbname" --out="$B/mongodump" --quiet
|
||||
want=$(/usr/bin/python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["counts"]))' "$B/manifest.json")
|
||||
got=$(find "$B/mongodump/$dbname" -name '*.bson' | wc -l)
|
||||
[ "$got" = "$want" ] || { echo "the mongodump has $got collections, the database $want" >&2; exit 1; }
|
||||
su -s /bin/sh postgres -c "pg_dump -Fc -d ferretdb" > "$B/ferretdb.pgdump"
|
||||
su -s /bin/sh postgres -c "pg_restore -f /dev/null" < "$B/ferretdb.pgdump"
|
||||
tar -cpf "$B/data.tar" -C "$W" data
|
||||
tar -tf "$B/data.tar" > /dev/null
|
||||
chown -R system:system "$B"; chmod -R go-rwx "$B"
|
||||
echo "backup: mongodump $got collections ($(du -sh "$B/mongodump" | cut -f1)), ferretdb pg_dump $(du -h "$B/ferretdb.pgdump" | cut -f1) read back in full, files $(tar -tf "$B/data.tar" | wc -l) entries, env files"
|
||||
|
||||
echo "== restore rehearsal into $REHEARSAL_DB"
|
||||
WORKDB=$REHEARSAL_DB
|
||||
db "$W/current/bundle" drop "$REHEARSAL_DB" >/dev/null
|
||||
as_system mongorestore --config="$CFG" --nsFrom="$dbname.*" --nsTo="$REHEARSAL_DB.*" --nsInclude="$dbname.*" --quiet "$B/mongodump"
|
||||
rehearsed=$(db "$W/current/bundle" counts "$REHEARSAL_DB")
|
||||
/usr/bin/python3 -c '
|
||||
import json, sys
|
||||
m = json.load(open(sys.argv[1]))["counts"]; r = json.loads(sys.argv[2])
|
||||
bad = [f"{k}: {v} vs {r.get(k)}" for k, v in m.items() if r.get(k) != v]
|
||||
if bad:
|
||||
print("REHEARSAL MISMATCH: " + "; ".join(bad)); sys.exit(1)
|
||||
print(f"rehearsal: all {len(m)} collections restored with identical counts")' "$B/manifest.json" "$rehearsed"
|
||||
db "$W/current/bundle" drop "$REHEARSAL_DB"
|
||||
WORKDB=
|
||||
|
||||
echo "== release $target"
|
||||
R=$W/releases/$target
|
||||
tmp=$(mktemp -d "$W/releases/.unpack-$target.XXXXXX"); chown system:system "$tmp"
|
||||
as_system unzip -q "$zip" -d "$tmp"
|
||||
[ ! -e "$R" ] || mv "$R" "$R.replaced-$(date +%F_%H-%M-%S)"
|
||||
mv "$tmp" "$R"
|
||||
as_system /usr/bin/python3 -c '
|
||||
import json, sys
|
||||
s = json.load(open(sys.argv[1]))
|
||||
assert s["nodeVersion"].startswith("26."), s["nodeVersion"]
|
||||
assert s.get("gitCommitHash", "").startswith(sys.argv[2]), "unexpected build " + s.get("gitCommitHash", "")
|
||||
print("release", sys.argv[3], s["meteorRelease"], "node", s["nodeVersion"], "commit", s["gitCommitHash"][:12])' "$R/bundle/star.json" "$(commit_for "$target")" "$target"
|
||||
[ -f "$R/bundle/main.js" ]
|
||||
if [ "$target" = 13.06 ]; then
|
||||
as_system /usr/bin/python3 "$SRC/wekan-patch-13.06.py" "$R/bundle/programs/server/app/app.js"
|
||||
as_system "$NODE26" --check "$R/bundle/programs/server/app/app.js"
|
||||
fi
|
||||
|
||||
echo "== start WeKan $target"
|
||||
set_node "$NODE26"
|
||||
ln -sfn "releases/$target" "$W/current"
|
||||
PHASE=started
|
||||
start_wekan
|
||||
echo "waiting for the one-time upgrade steps to finish (up to 30 minutes)"
|
||||
n=0
|
||||
while :; do
|
||||
[ -n "$(wekan_pids)" ] || { echo "WeKan $target exited" >&2; tail -n 40 /var/log/wekan/wekan.log >&2; exit 1; }
|
||||
rcode=0; report=$(upgrade_done "$target") || rcode=$?
|
||||
[ "$rcode" = 0 ] && break
|
||||
[ "$rcode" = 2 ] && { echo "$report"; echo "the upgrade steps did not finish clean" >&2; exit 1; }
|
||||
[ "$n" -lt 1800 ] || { echo "the upgrade steps did not finish within 30 minutes" >&2; exit 1; }
|
||||
sleep 5; n=$((n + 5))
|
||||
done
|
||||
echo "$report"
|
||||
answering || { echo "WeKan $target does not answer on /" >&2; exit 1; }
|
||||
echo "== verify the data"
|
||||
db "$W/current/bundle" verify "$B/manifest.json" upgrade
|
||||
PHASE=done
|
||||
trap - EXIT
|
||||
cleanup
|
||||
echo "WeKan $target is running ($(wekan_pids | tr '\n' ' ')); backup and restore command: doas sh $SRC/upgrade-wekan.sh restore $B"
|
||||
nxt=$(next_after "$target")
|
||||
if [ -n "$nxt" ]; then echo "next: open https://todo.true-og.net and check your boards, then: doas sh $SRC/upgrade-wekan.sh step $nxt"
|
||||
else echo "this was the last step: open https://todo.true-og.net and check your boards"; fi
|
||||
echo "UPGRADE_WEKAN_STEP_OK $target"
|
||||
@@ -0,0 +1,101 @@
|
||||
// WeKan database checks for upgrade-wekan.sh, read-only except where noted. The database address comes from MONGO_URL
|
||||
// and is never printed. Usage: node wekan-db.js <bundle dir> <command> [args]
|
||||
// name the database MONGO_URL selects
|
||||
// snapshot <out.json> every collection's count, and the _ids of the collections whose documents must survive
|
||||
// verify <manifest> upgrade after an upgrade: no surviving-kind document is gone, no more broken references than
|
||||
// in the snapshot, lists may only disappear if nothing points at them (13.06 merges lists)
|
||||
// verify <manifest> exact after a restore: the same collections with the same counts and the same _ids
|
||||
// dropdb <db name> <manifest> drop the WeKan database before a restore; only the manifest's database is accepted
|
||||
// counts <db name> counts of every collection of another database (the restore rehearsal)
|
||||
// drop <db name> drop that database; only names ending in _restore_rehearsal are accepted
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { MongoClient } = require(path.join(process.argv[2], "programs/server/npm/node_modules/meteor/npm-mongo/node_modules/mongodb"));
|
||||
const KEEP = ["users", "boards", "cards", "swimlanes", "customFields", "card_comments", "checklists", "checklistItems",
|
||||
"attachments", "avatars", "team", "org", "settings", "rules", "triggers", "actions", "integrations", "accountSettings"];
|
||||
const REFS = [["cards", "boardId", "boards"], ["cards", "listId", "lists"], ["cards", "swimlaneId", "swimlanes"],
|
||||
["lists", "boardId", "boards"], ["swimlanes", "boardId", "boards"], ["card_comments", "cardId", "cards"],
|
||||
["checklists", "cardId", "cards"], ["checklistItems", "checklistId", "checklists"],
|
||||
["activities", "listId", "lists"]];
|
||||
async function broken(db) { // references with a non-empty id that points at no document
|
||||
const out = {};
|
||||
for (const [from, field, to] of REFS) {
|
||||
const target = new Set((await db.collection(to).find({}, { projection: { _id: 1 } }).toArray()).map(x => String(x._id)));
|
||||
let n = 0;
|
||||
for await (const d of db.collection(from).find({}, { projection: { [field]: 1 } })) {
|
||||
const v = d[field];
|
||||
if (v !== undefined && v !== null && v !== "" && !target.has(String(v))) n++;
|
||||
}
|
||||
out[`${from}.${field}->${to}`] = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const clean = m => String(m).replace(/mongodb:\/\/[^@\s]*@/g, "mongodb://***@");
|
||||
(async () => {
|
||||
const [cmd, a1, a2] = process.argv.slice(3);
|
||||
const client = await MongoClient.connect(process.env.MONGO_URL, { serverSelectionTimeoutMS: 15000 });
|
||||
const db = client.db();
|
||||
const names = async d => (await d.listCollections({}, { nameOnly: true }).toArray()).map(c => c.name).filter(n => !n.startsWith("system.")).sort();
|
||||
const ids = async (d, n) => (await d.collection(n).find({}, { projection: { _id: 1 } }).toArray()).map(x => String(x._id)).sort();
|
||||
try {
|
||||
if (cmd === "name") { console.log(db.databaseName); return; }
|
||||
if (cmd === "snapshot") {
|
||||
const m = { database: db.databaseName, at: new Date().toISOString(), counts: {}, ids: {} };
|
||||
for (const n of await names(db)) m.counts[n] = await db.collection(n).countDocuments();
|
||||
for (const n of Object.keys(m.counts)) m.ids[n] = await ids(db, n);
|
||||
m.broken = await broken(db);
|
||||
fs.writeFileSync(a1, JSON.stringify(m), { mode: 0o600 });
|
||||
console.log(`snapshot of ${Object.keys(m.counts).length} collections: ` + JSON.stringify(Object.fromEntries(Object.entries(m.counts).filter(([k]) => ["users", "boards", "lists", "swimlanes", "cards", "customFields", "activities"].includes(k)))));
|
||||
return;
|
||||
}
|
||||
if (cmd === "verify") {
|
||||
const m = JSON.parse(fs.readFileSync(a1, "utf8"));
|
||||
if (m.database !== db.databaseName) throw new Error(`manifest is for database ${m.database}, connected to ${db.databaseName}`);
|
||||
const problems = [];
|
||||
for (const [n, before] of Object.entries(m.ids)) {
|
||||
if (a2 !== "exact" && !KEEP.includes(n) && n !== "lists") continue;
|
||||
const now = new Set(await ids(db, n));
|
||||
const gone = before.filter(i => !now.has(i));
|
||||
if (a2 === "exact") {
|
||||
if (gone.length || now.size !== before.length) problems.push(`${n}: ${before.length} before, ${now.size} now, ${gone.length} missing`);
|
||||
} else if (gone.length && n !== "lists") problems.push(`${n}: ${gone.length} of ${before.length} documents gone`);
|
||||
else if (gone.length) console.log(`lists: ${gone.length} of ${before.length} merged away (allowed only if no card points at them)`);
|
||||
}
|
||||
if (a2 === "exact") {
|
||||
const now = {};
|
||||
for (const n of await names(db)) now[n] = await db.collection(n).countDocuments();
|
||||
for (const n of new Set([...Object.keys(m.counts), ...Object.keys(now)]))
|
||||
if (m.counts[n] !== now[n]) problems.push(`collection ${n}: ${m.counts[n]} in the backup, ${now[n]} now`);
|
||||
} else {
|
||||
const b = await broken(db);
|
||||
for (const [k, v] of Object.entries(b)) if (v > ((m.broken || {})[k] || 0)) problems.push(`${k}: ${v} broken references, ${(m.broken || {})[k] || 0} before`);
|
||||
console.log("broken references (before -> now): " + Object.entries(b).map(([k, v]) => `${k} ${(m.broken || {})[k] || 0}->${v}`).join(", "));
|
||||
}
|
||||
const counts = {};
|
||||
for (const n of ["users", "boards", "lists", "swimlanes", "cards", "customFields", "activities"]) counts[n] = await db.collection(n).countDocuments();
|
||||
console.log("counts now: " + JSON.stringify(counts));
|
||||
if (problems.length) { console.log("VERIFY_FAILED " + problems.join("; ")); process.exitCode = 1; } else console.log("VERIFY_OK");
|
||||
return;
|
||||
}
|
||||
if (cmd === "counts") {
|
||||
const d = client.db(a1), out = {};
|
||||
for (const n of await names(d)) out[n] = await d.collection(n).countDocuments();
|
||||
console.log(JSON.stringify(out));
|
||||
return;
|
||||
}
|
||||
if (cmd === "dropdb") {
|
||||
const m = JSON.parse(fs.readFileSync(a2, "utf8"));
|
||||
if (a1 !== m.database || a1 !== db.databaseName) throw new Error(`refusing to drop ${a1}: not the manifest's and MONGO_URL's database`);
|
||||
await client.db(a1).dropDatabase();
|
||||
console.log("dropped " + a1 + " for the restore");
|
||||
return;
|
||||
}
|
||||
if (cmd === "drop") {
|
||||
if (!/_restore_rehearsal$/.test(a1)) throw new Error("refusing to drop " + a1);
|
||||
await client.db(a1).dropDatabase();
|
||||
console.log("dropped " + a1);
|
||||
return;
|
||||
}
|
||||
throw new Error("unknown command " + cmd);
|
||||
} finally { await client.close(); }
|
||||
})().catch(e => { console.error("DB_ERROR " + clean(e.message)); process.exit(1); });
|
||||
@@ -0,0 +1,52 @@
|
||||
"""TrueOG patches for WeKan 13.06's compiled server code (bundle/programs/server/app/app.js). Each patch is applied
|
||||
once (marker), only if its anchor is found exactly once; app.js.orig-13.06 keeps the original.
|
||||
|
||||
setBoardTeams: before members are reconciled, add every user of an active team assigned to the board, since a
|
||||
board admin who is not a site admin cannot see other users' teams in the browser. Same change as
|
||||
server/models/boards.js in trueog/wekan.
|
||||
|
||||
oidcSignup (2026-11-11, operator: "staff-auth sign ups only, disable password registration"): with registration
|
||||
disabled, Users.after.insert demanded an invitation code for every new OAuth2 account, so a first Staff-Auth
|
||||
sign-in created the account and then failed ("error-invitation-code-not-exist"). Accounts created by the OIDC
|
||||
service (services.oidc, set only by WeKan's server-side OIDC login) skip that check; Staff-Auth decides who gets
|
||||
in. Password registration stays closed. Same change as server/models/users.js in trueog/wekan."""
|
||||
import pathlib, shutil, sys
|
||||
p = pathlib.Path(sys.argv[1])
|
||||
orig = p.with_name("app.js.orig-13.06")
|
||||
PATCHES = [
|
||||
("setBoardTeams",
|
||||
"let l=e.filter(e=>e.isActive).map(e=>e.teamId),d=t.map(e=>e.userId).filter(e=>!n.members.some(t=>t&&t.userId===e)),c=new Map",
|
||||
"/*trueog-setBoardTeams*/"
|
||||
"{let r=e.filter(e=>!1!==e.isActive).map(e=>e.teamId);if(r.length>0)for(let s of await v.Meteor.users.find("
|
||||
'{"teams.teamId":{$in:r}},{fields:{_id:1}}).fetchAsync())t.some(e=>e.userId===s._id)||t.push({userId:s._id,'
|
||||
"isAdmin:!1,isActive:!0,isNoComments:!1,isCommentOnly:!1,isWorker:!1})}",
|
||||
"before"),
|
||||
("oidcSignup",
|
||||
'if("ldap"!==t.authenticationMethod&&r){let e=null;if("string"==typeof t.authenticationMethod&&"oauth2"===',
|
||||
'if("ldap"!==t.authenticationMethod&&r&&!/*trueog-oidcSignup*/(t.services&&t.services.oidc)){let e=null;'
|
||||
'if("string"==typeof t.authenticationMethod&&"oauth2"===',
|
||||
"replace"),
|
||||
]
|
||||
s = p.read_text()
|
||||
changed = []
|
||||
for name, anchor, text, how in PATCHES:
|
||||
marker = f"/*trueog-{name}*/"
|
||||
if marker in s:
|
||||
want = text + anchor if how == "before" else text
|
||||
if s.count(marker) == 1 and s.count(want) == 1:
|
||||
print(f"{name}: already patched")
|
||||
continue
|
||||
sys.exit(f"{name}: app.js has the marker but not exactly the reviewed patch; nothing written")
|
||||
if s.count(anchor) != 1:
|
||||
sys.exit(f"{name}: anchor found {s.count(anchor)} times, expected 1; nothing written")
|
||||
s = s.replace(anchor, text + anchor if how == "before" else text)
|
||||
changed.append(name)
|
||||
if not changed:
|
||||
print("app.js already has every patch")
|
||||
sys.exit(0)
|
||||
if not orig.exists():
|
||||
shutil.copy2(p, orig)
|
||||
p.chmod(0o644)
|
||||
p.write_text(s)
|
||||
p.chmod(0o444)
|
||||
print("app.js patched (" + ", ".join(changed) + "), original kept as " + orig.name)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Prints a mongo tools config line for MONGO_URL without its database path, so mongodump and mongorestore work on
|
||||
named databases; authSource keeps the URI's database (the default it implied), so authentication is unchanged."""
|
||||
import json, os, urllib.parse
|
||||
u = urllib.parse.urlsplit(os.environ["MONGO_URL"])
|
||||
q = urllib.parse.parse_qsl(u.query, keep_blank_values=True)
|
||||
db = u.path.lstrip("/")
|
||||
if db and not any(k.lower() == "authsource" for k, _ in q):
|
||||
q.append(("authSource", db))
|
||||
print("uri: " + json.dumps(urllib.parse.urlunsplit((u.scheme, u.netloc, "/", urllib.parse.urlencode(q), u.fragment))))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Reads WeKan's /schema-upgrade-status?json on stdin. Usage: wekan-upgrade-status.py <version, e.g. 13.06>
|
||||
Exit 0: the upgrade steps of that version ran and finished clean; 1: not finished yet (or no answer); 2: not clean."""
|
||||
import json, sys
|
||||
try:
|
||||
s = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
if s.get("running") or not s.get("finishedAt"):
|
||||
sys.exit(1)
|
||||
want = "v" + sys.argv[1] + ".0"
|
||||
steps = s.get("steps") or {}
|
||||
for name, st in steps.items():
|
||||
line = " upgrade step {}: {}, fixed {}, unresolved {}".format(name, st.get("status"), st.get("fixed", 0), st.get("unresolved", 0))
|
||||
if st.get("error"):
|
||||
line += ", error " + str(st.get("error"))
|
||||
print(line)
|
||||
last = (s.get("lastCheck") or {}).get("version")
|
||||
print(" version {}, finished {}, gated {}, clean re-check recorded for {}".format(s.get("appVersion"), s.get("finishedAt"), s.get("gated"), last))
|
||||
problems = []
|
||||
if s.get("appVersion") != want:
|
||||
problems.append("version is {}, expected {}".format(s.get("appVersion"), want))
|
||||
if s.get("gated"):
|
||||
problems.append("the upgrade steps were skipped (gated), not run")
|
||||
if any(st.get("status") == "error" for st in steps.values()):
|
||||
problems.append("a step failed")
|
||||
if any(st.get("unresolved") for st in steps.values()):
|
||||
problems.append("a step left unresolved items")
|
||||
if last != want:
|
||||
problems.append("no clean re-check was recorded for " + want)
|
||||
if problems:
|
||||
print(" NOT CLEAN: " + "; ".join(problems))
|
||||
sys.exit(2)
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,51 @@
|
||||
# /opt/wekan/wekan.env as deployed on the TrueOG server, with secrets replaced by <placeholders>.
|
||||
# Generated from the live file; never commit the real one (it is mode 600, owner system).
|
||||
export MONGO_URL='mongodb://<user>:<password>@127.0.0.1:27017/wekan?authSource=<db>'
|
||||
export ROOT_URL='https://todo.true-og.net'
|
||||
# Listen on loopback only: nginx proxies to 127.0.0.1:3001 (todo.true-og.net).
|
||||
# Meteor's webapp uses process.env.BIND_IP || '0.0.0.0'.
|
||||
export BIND_IP=127.0.0.1
|
||||
export PORT=3001
|
||||
export MAIL_URL='smtps://<user>:<password>@<mail host>:465/'
|
||||
export MAIL_FROM='Wekan Automated Message <admin@true-og.net>'
|
||||
export WRITABLE_PATH='/opt/wekan/data'
|
||||
export METEOR_REACTIVITY_ORDER='polling'
|
||||
# Added for WeKan 10.99 (2026-09-19):
|
||||
# Exports (PDF/Excel/JSON/CSV) are served from /api/... and refused unless WITH_API=true.
|
||||
export WITH_API=true
|
||||
# FerretDB has no oplog/change streams, so Meteor polls; calmer re-poll cadence (WeKan launcher defaults).
|
||||
export METEOR_POLLING_THROTTLE_MS=2000
|
||||
export METEOR_POLLING_INTERVAL_MS=30000
|
||||
|
||||
# Staff-Auth sign-in (2026-11-11): Hydra client "TrueOG WeKan" (/root/hydra-client-wekan.json), client_secret_post.
|
||||
# Any active Staff-Auth account may sign in; existing accounts are linked by services.oidc.id = Staff-Auth uuid.
|
||||
# Staff-Auth is the only sign-in (password login removed 2026-11-11). Deactivation/deletion locks via trueog-wekan-authwatch.
|
||||
export OAUTH2_ENABLED='true'
|
||||
export OAUTH2_LOGIN_STYLE='redirect'
|
||||
export OAUTH2_CLIENT_ID='<client id from /root/hydra-client-wekan.json>'
|
||||
export OAUTH2_SECRET='<client secret from /root/hydra-client-wekan.json>'
|
||||
export OAUTH2_SERVER_URL='https://staff-auth.true-og.net'
|
||||
export OAUTH2_AUTH_ENDPOINT='/oauth2/auth'
|
||||
export OAUTH2_TOKEN_ENDPOINT='/oauth2/token'
|
||||
export OAUTH2_USERINFO_ENDPOINT='/userinfo'
|
||||
export OAUTH2_REQUEST_PERMISSIONS='openid profile email'
|
||||
export OAUTH2_ID_MAP='sub'
|
||||
export OAUTH2_USERNAME_MAP='preferred_username'
|
||||
export OAUTH2_FULLNAME_MAP='name'
|
||||
export OAUTH2_EMAIL_MAP='email'
|
||||
|
||||
# Real client address (2026-11-11). nginx (conf.d/cloudflare-realip.conf: real_ip_header CF-Connecting-IP from
|
||||
# Cloudflare's ranges only) appends the visitor's address as the LAST X-Forwarded-For entry, so trust exactly one
|
||||
# hop. Without this every request looked like 127.0.0.1: one shared REST-login throttle key for the whole internet.
|
||||
# WeKan listens on 127.0.0.1 only (BIND_IP), so nobody can reach it without nginx writing that entry.
|
||||
export HTTP_FORWARDED_COUNT=1
|
||||
|
||||
# Operator 2026-11-11: WeKan behaves like Gitea: a signed-out visit goes straight to Staff-Auth sign-in (one
|
||||
# automatic attempt per browser tab; if it fails, the same tab shows the sign-in page with the password fallback).
|
||||
export OIDC_REDIRECTION_ENABLED=true
|
||||
|
||||
# Operator 2026-11-11: "I dont want wekan's sign in page at all, except a link to sign in with staff-auth like
|
||||
# gitea has". The password form is hidden; linked accounts have no stored password (removed, backup
|
||||
# /opt/wekan/backups/pre-nopassword-*), so the server refuses password logins as well. No fallback: if
|
||||
# Staff-Auth/Hydra is down, WeKan sign-in waits for it.
|
||||
export PASSWORD_LOGIN_ENABLED=false
|
||||
Reference in new issue
Block a user