Owner decision 2026-10-04: SecureMode on once the pasture passes with it.
- Both clean pasture passes were run over all six peers:
- normally: 246 passed, 0 failed;
- with Federation__SecureMode=true: every federation check passed. The only failures were four checks expecting an
unsigned GET to get 404 or 410 where SecureMode answers 401. Those checks now go through `unserved` and
`gone_unsigned` (lib/interop.sh), which expect 401 when SecureMode is on.
- Circle posts now reach their member on GoToSocial and Mastodon, and survive Mastodon's signed refetch, as does a
followers-only post. The GoToSocial expected failure is gone.
- appsettings.Production.json turns SecureMode on.
- The deploy now checks that an unsigned GET of @thepra answers 401 and that a browser is redirected. It reads
@thepra's discoverability through the Mastodon API, since the actor is no longer readable unsigned.
- docs/INTEROP.md (Mastodon, GoToSocial), CLAUDE.md and ROADMAP updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
65 lines
4.5 KiB
Bash
65 lines
4.5 KiB
Bash
# Shared by interop.sh and the scenarios: check helpers, PrivaPub personas and tokens, the statistics check.
|
|
P=http://127.0.0.1:6971
|
|
work=$(mktemp -d); trap 'rm -rf "$work"' EXIT
|
|
pass=0; fail=0; expected=0
|
|
ok() { echo " ok $*"; pass=$((pass+1)); }
|
|
ko() { echo " FAIL $*"; fail=$((fail+1)); }
|
|
xf() { echo " xf $* (expected to fail until a later phase)"; expected=$((expected+1)); }
|
|
j() { python3 -c "import sys,json
|
|
try: d=json.load(sys.stdin)
|
|
except Exception: d=None
|
|
$1" 2>/dev/null; }
|
|
until_true() { local tries=$1; shift; for _ in $(seq 1 "$tries"); do if eval "$@"; then return 0; fi; sleep 2; done; return 1; }
|
|
site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
|
|
# fetches one of PrivaPub's own https URIs (ids, scribbles) from the workstation, through Caddy
|
|
pfetch() { curl -sk --connect-to privapub.test:443:127.0.0.1:6443 "$@"; }
|
|
|
|
# make_png <path>: an 8x8 red PNG, for uploads
|
|
# the status an unsigned ActivityPub GET of a PrivaPub document gets
|
|
pstatus() { pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$1"; }
|
|
# SecureMode (PRIVAPUB_ENV="Federation__SecureMode=true") answers every unsigned GET but the instance actor's 401, so a
|
|
# post's author answering 401 means it is on; then 401 is what "not served" and "gone" look like to an unsigned reader
|
|
secure_mode() { [ "$(pstatus "${1%%/scribbles/*}")" = "401" ]; }
|
|
unserved() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 404 ]; fi; }
|
|
gone_unsigned() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 410 ]; fi; }
|
|
make_png() { python3 -c "
|
|
import struct,zlib
|
|
w=h=8
|
|
raw=b''.join(b'\x00'+bytes([200,60,60])*w for _ in range(h))
|
|
png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw)),(b'IEND',b'')])
|
|
open('$1','wb').write(png)"; }
|
|
|
|
ROOT_USER=pastureroot; ROOT_PASS='Pasture-Pass-1!'
|
|
privapub_root() {
|
|
local root
|
|
root=$(curl -s -X POST $P/clientapi/user/signup -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}")
|
|
[ -n "$(echo "$root" | j "print(d['token'])")" ] || root=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}")
|
|
echo "$root" | j "print(d['token'])"
|
|
}
|
|
|
|
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it,
|
|
# through the same OAuth flow the deploy's smoke check uses (tools/smoke/oauth.sh).
|
|
privapub_token() {
|
|
local persona=$1 jwt
|
|
jwt=$(privapub_root)
|
|
curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
|
-d "{\"userName\":\"$persona\",\"name\":\"$persona of PrivaPub\",\"biography\":\"testing federation\"}"
|
|
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "read write follow" | cut -d' ' -f1
|
|
}
|
|
|
|
# stats_check <host> <software>: the admin statistics name the peer's software and count traffic both ways.
|
|
stats_check() {
|
|
local host=$1 software=$2 admin found
|
|
podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true
|
|
admin=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}" | j "print(d['token'])")
|
|
until_true 30 'found=$(curl -s -H "Authorization: Bearer $admin" "$P/clientapi/admin/statistics/hosts/$host?days=1"); [ "$(echo "$found" | j "print((d[\"instance\"] or {}).get(\"software\"))")" = "$software" ]' \
|
|
&& ok "statistics describe $host as $software" || ko "statistics do not describe $host as $software"
|
|
found=$(curl -s -H "Authorization: Bearer $admin" "$P/clientapi/admin/statistics/hosts/$host?days=1")
|
|
[ "$(echo "$found" | j "print(any(k.startswith('in:') for day in d['days'] for k in day['counters']))")" = "True" ] \
|
|
&& ok "statistics count what $host sent" || ko "no inbound statistics for $host"
|
|
[ "$(echo "$found" | j "print(any(k.startswith('out:') and ':ok' in k for day in d['days'] for k in day['counters']))")" = "True" ] \
|
|
&& ok "statistics count what we delivered to $host" || ko "no outbound statistics for $host"
|
|
[ "$(echo "$found" | j "print('$ROOT_USER' not in json.dumps(d['events']) and 'alice' not in json.dumps(d['events']))")" = "True" ] \
|
|
&& ok "statistics for $host name no account" || ko "statistics for $host name an account"
|
|
}
|