From 5721bfd7967b854934aa374a4543000239eaad16 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 00:19:56 +0200 Subject: [PATCH] Pasture: snac2 2.95 snac2 joins the pasture, built from its tag (images/snac2); scenarios/snac.sh drives its Mastodon API and reads its own files, since its timelines lag behind what it has taken in: follows, posts, replies, likes, boosts, a poll, edits, deletions, direct messages, the unfollow and statistics, 27 checks, with no change to PrivaPub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 4 + FEDERATION.md | 1 + docs/INTEROP.md | 13 +++ tools/pasture/Caddyfile | 5 ++ tools/pasture/images/snac2/Containerfile | 10 +++ tools/pasture/peers/snac.sh | 40 +++++++++ tools/pasture/scenarios/snac.sh | 108 +++++++++++++++++++++++ 7 files changed, 181 insertions(+) create mode 100644 tools/pasture/images/snac2/Containerfile create mode 100644 tools/pasture/peers/snac.sh create mode 100644 tools/pasture/scenarios/snac.sh diff --git a/CLAUDE.md b/CLAUDE.md index cefa6b5..c7d2397 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -561,6 +561,10 @@ tools/pasture/run.sh down # removes e checks. - **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks. +- **snac2 (2.95):** built from its tag (`images/snac2`, the project publishes no image), its data in the + `pasture-snac` volume from `snac init` and `snac adduser`; the password comes from `snac resetpwd` and the token from + its login form and a code grant. Its API lags behind what it has taken in, so `scenarios/snac.sh` reads its files + (`snac_stored`, `snac_in_timeline`). 27 checks. - **Mitra (5.9.1):** its image on the shared Postgres (database `mitra`), as `mitra.test`, with the pasture's bundle as the system's roots; mitrauser comes from its CLI and its token from a password grant. Its search resolves an account elsewhere only without `type`. `scenarios/mitra.sh`, 28 checks. diff --git a/FEDERATION.md b/FEDERATION.md index edc3665..03318af 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -34,6 +34,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **NodeBB 4.16.1** - **Smithereen 1.0.3** - **Mitra 5.9.1** +- **snac2 2.95** - **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from - in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**, **Pleroma 2.10.2** diff --git a/docs/INTEROP.md b/docs/INTEROP.md index e1f9da4..f891037 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -753,6 +753,19 @@ PeerTube's own instance account announces each new video too, which we drop: nob follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name, without a port, before it gives out its OAuth client. +### snac2 2.95 + +- Keeps everything in files and works through what it receives one message at a time, sometimes minutes behind; its + Mastodon API's timelines lag behind what it has taken in, and give an object a new id when it changes. The scenario + reads snac's own files instead (`object/.json`, the user's `private.idx`). +- Its API takes a poll only as form fields (`poll[options][]`), has no conversations, and gets its token from its own + login form (`/oauth/x-snac-login`) and a code grant. A `Delete` takes the post out of the timeline and keeps the + object. +- Its single-threaded server answered one delivery with a 502 through Caddy; PrivaPub's retry 22 seconds later went in. +- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/snac.sh`):** 27 checks pass, with no change to PrivaPub: + follows both ways, posts, replies, likes and boosts both ways, a poll and alice's vote, edits and deletions both ways, + direct messages both ways, the unfollow, statistics. + ### Mitra 5.9.1 - Signs its deliveries with RSA (draft-cavage) and adds an FEP-8b32 proof made with its Ed25519 key (FEP-521a), which diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 6b0d4e6..89ec13c 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,11 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +snac.test { + tls internal + reverse_proxy pasture-snac:8001 +} + mitra.test { tls internal reverse_proxy pasture-mitra:8383 diff --git a/tools/pasture/images/snac2/Containerfile b/tools/pasture/images/snac2/Containerfile new file mode 100644 index 0000000..bedc58e --- /dev/null +++ b/tools/pasture/images/snac2/Containerfile @@ -0,0 +1,10 @@ +# snac2 2.95 from its tag: the project publishes no image +FROM docker.io/library/alpine:3.22 AS builder +RUN apk -U --no-progress --no-cache add git curl-dev build-base +RUN git clone --depth 1 --branch 2.95 https://codeberg.org/grunfink/snac2 /build && cd /build && make \ + && make PREFIX=/out/usr/local PREFIX_MAN=/out/usr/local/share/man install + +FROM docker.io/library/alpine:3.22 +RUN apk -U --no-progress --no-cache add libcurl tzdata +COPY --from=builder /out / +EXPOSE 8001 diff --git a/tools/pasture/peers/snac.sh b/tools/pasture/peers/snac.sh new file mode 100644 index 0000000..ffd7e1b --- /dev/null +++ b/tools/pasture/peers/snac.sh @@ -0,0 +1,40 @@ +# snac2 2.95 (grunfink): a small ActivityPub server in C that keeps everything in files, with a Mastodon API. Built from +# its tag (images/snac2), as snac.test; its libcurl reads the system's roots, over which the pasture's bundle goes. Its +# data directory is made by `snac init` and snacuser by `snac adduser`; the API token comes from its own login form +# (/oauth/x-snac-login) and a code grant. +SNAC_IMAGE=${SNAC_IMAGE:-localhost/pasture-snac2:2.95} + +snac_up() { + podman image exists "$SNAC_IMAGE" || podman build -q -t "$SNAC_IMAGE" "$here/images/snac2" >/dev/null + local st="$here/.state/snac" + mkdir -p "$st" + podman volume exists pasture-snac || podman volume create --label pasture=1 pasture-snac >/dev/null + if ! podman run --rm -v pasture-snac:/data $SNAC_IMAGE test -e /data/data/server.json; then + printf '0.0.0.0\n8001\nsnac.test\n\n\n' | podman run --rm -i -v pasture-snac:/data $SNAC_IMAGE snac init /data/data >/dev/null + podman run --rm -v pasture-snac:/data $SNAC_IMAGE snac adduser /data/data snacuser /dev/null 2>&1 + fi + # a password the pasture knows ("New password for user snacuser is ...") + [ -s "$st/password" ] || podman run --rm -v pasture-snac:/data $SNAC_IMAGE snac resetpwd /data/data snacuser 2>&1 "$st/password" + podman run -d --replace --name pasture-snac --label pasture=1 --network $net -v pasture-snac:/data \ + -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" $SNAC_IMAGE snac httpd /data/data >/dev/null + for _ in $(seq 1 30); do + site snac.test -s -o /dev/null -w '%{http_code}' https://snac.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 1 + done + snac_token > "$st/snacuser.token" + echo "snac: https://snac.test:6443" +} + +# snac_token: an API token for snacuser, through an app, its login form and a code grant +snac_token() { + local app id secret code + app=$(site snac.test -s -X POST https://snac.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow') + id=$(echo "$app" | python3 -c 'import json, sys; print(json.load(sys.stdin)["client_id"])') + secret=$(echo "$app" | python3 -c 'import json, sys; print(json.load(sys.stdin)["client_secret"])') + code=$(site snac.test -s -X POST https://snac.test:6443/oauth/x-snac-login --data-urlencode login=snacuser \ + --data-urlencode "passwd=$(cat "$here/.state/snac/password")" --data-urlencode redir=urn:ietf:wg:oauth:2.0:oob --data-urlencode "cid=$id") + site snac.test -s -X POST https://snac.test:6443/oauth/token --data-urlencode grant_type=authorization_code --data-urlencode "code=$code" \ + --data-urlencode "client_id=$id" --data-urlencode "client_secret=$secret" --data-urlencode redirect_uri=urn:ietf:wg:oauth:2.0:oob \ + | python3 -c 'import json, sys; print(json.load(sys.stdin).get("access_token", ""))' +} diff --git a/tools/pasture/scenarios/snac.sh b/tools/pasture/scenarios/snac.sh new file mode 100644 index 0000000..6cbc632 --- /dev/null +++ b/tools/pasture/scenarios/snac.sh @@ -0,0 +1,108 @@ +# snac2 2.95: follows both ways, posts, replies, likes, boosts, a poll, an edit and a deletion both ways, direct +# messages, the unfollow, statistics. snac keeps everything in files and federates in plain ActivityPub. Driven through +# its Mastodon API as snacuser, with the token peers/snac.sh got. +MI=https://snac.test:6443 +MIT=$(cat "$here/.state/snac/snacuser.token" 2>/dev/null) +mi() { site snac.test -s -H "Authorization: Bearer $MIT" "$@"; } +mi_json() { local method=$1 path=$2 body=$3; mi -X "$method" "$MI$path" -H 'Content-Type: application/json' -d "$body"; } +mi_status_of() { mi "$MI/api/v1/statuses/${1:-none}"; } +# snac_stored : the expression over snac's own copy of an object, read from its files (`o`, None when +# it holds none). Its API's timelines lag behind what it has taken in, and give an object a new id when it changes +snac_stored() { + local md5 + md5=$(printf '%s' "$1" | md5sum | cut -c1-32) + podman exec pasture-snac cat "/data/data/object/${md5:0:2}/$md5.json" 2>/dev/null | j "o = d; print($2)" +} +# snac_in_timeline : whether the object is in snacuser's timeline (its private.idx) +snac_in_timeline() { podman exec pasture-snac grep -q "$(printf '%s' "$1" | md5sum | cut -c1-32)" /data/data/user/snacuser/private.idx; } +p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if '$1' in ((s.get('reblog') or s)['content'] or '')), ''))"; } +p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; } + +echo "snac" +[ "$(mi "$MI/api/v1/accounts/verify_credentials" | j "print(d['username'])")" = "snacuser" ] && ok "snac token for snacuser" || { ko "snac token"; return 1; } +PT=$(privapub_token alice_snac) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_snac" || { ko "PrivaPub token for alice_snac"; return 1; } +run=$(date +%s) + +echo " follows" +alice_on_mi=$(mi "$MI/api/v2/search?q=alice_snac%40privapub.test&resolve=true" | j "print(d['accounts'][0]['id'])") +[ -n "$alice_on_mi" ] && ok "snac resolves alice" || ko "snac cannot resolve alice" +mi -o /dev/null -X POST "$MI/api/v1/accounts/$alice_on_mi/follow" +until_true 45 '[ "$(mi "$MI/api/v1/accounts/relationships?id[]=$alice_on_mi" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "snacuser follows alice (Accept arrived)" || ko "alice's Accept never reached snac" +mi_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=snacuser@snac.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$mi_on_p" ] && ok "PrivaPub resolves snacuser" || ko "PrivaPub cannot resolve snacuser" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$mi_on_p/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$mi_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows snacuser (Accept arrived)" || ko "snac's Accept never arrived" + +echo " posts" +mi_post=$(mi_json POST /api/v1/statuses "{\"status\":\"a snac post $run\",\"visibility\":\"public\"}") +mi_post_id=$(echo "$mi_post" | j "print(d['id'])") +until_true 45 '[ -n "$(p_home_has "a snac post $run")" ]' && ok "snacuser's post reaches alice's home" || ko "snacuser's post never reached alice" +mi_on_p_post=$(p_home_has "a snac post $run") +p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub post for snac $run&visibility=public") +p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])") +until_true 45 '[ "$(mi "$MI/api/v1/timelines/home?limit=40" | j "print(any(s[\"uri\"] == \"$p_post_uri\" for s in d))")" = "True" ]' \ + && ok "alice's post reaches snacuser's home" || ko "alice's post never reached snac" +p_on_mi=$(mi "$MI/api/v1/timelines/home?limit=40" | j "print(next(s['id'] for s in d if s['uri'] == '$p_post_uri'))") + +echo " replies" +mi_json POST /api/v1/statuses "{\"status\":\"@alice_snac@privapub.test a snac reply $run\",\"in_reply_to_id\":\"$p_on_mi\",\"visibility\":\"public\"}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(any(\"a snac reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "snacuser's reply threads under alice's post" || ko "snacuser's reply missing on PrivaPub (replying to ${p_on_mi:-nothing})" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@snacuser@snac.test a PrivaPub reply $run&in_reply_to_id=$mi_on_p_post&visibility=public" +until_true 45 '[ "$(mi "$MI/api/v1/statuses/$mi_post_id/context" | j "print(any(\"a PrivaPub reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "alice's reply threads under snacuser's post" || ko "alice's reply missing on snac" + +echo " likes and boosts" +mi -o /dev/null -X POST "$MI/api/v1/statuses/$p_on_mi/favourite" +until_true 45 '[ "$(p_status $p_post_id favourites_count)" = "1" ]' && ok "snacuser's like counts on PrivaPub" || ko "snacuser's like never counted" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$mi_on_p_post/favourite" +until_true 45 '[ "$(mi_status_of $mi_post_id | j "print(d[\"favourites_count\"])")" = "1" ]' && ok "alice's like counts on snac" || ko "alice's like never counted on snac" +mi -o /dev/null -X POST "$MI/api/v1/statuses/$p_on_mi/reblog" +until_true 45 '[ "$(p_status $p_post_id reblogs_count)" = "1" ]' && ok "snacuser's repost is a boost on PrivaPub" || ko "snacuser's repost never counted" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$mi_on_p_post/reblog" +until_true 45 '[ "$(mi_status_of $mi_post_id | j "print(d[\"reblogs_count\"])")" = "1" ]' && ok "alice's boost counts on snac" || ko "alice's boost never counted on snac" + +echo " polls" +# (its API reads a poll from form fields only) +mi_poll=$(mi -X POST "$MI/api/v1/statuses" -d "status=a snac poll $run&visibility=public&poll[options][]=hay&poll[options][]=clover&poll[expires_in]=3600") +until_true 45 '[ -n "$(p_home_has "a snac poll $run")" ]' && ok "snacuser's poll reaches alice" || ko "snacuser's poll never reached alice" +p_poll=$(curl -s -H "$PH" "$P/api/v1/statuses/$(p_home_has "a snac poll $run")" | j "print(d['poll']['id'] if d.get('poll') else '')") +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$p_poll/votes" -d 'choices[]=1' +until_true 45 '[ "$(mi_status_of $(echo "$mi_poll" | j "print(d[\"id\"])") | j "print(d[\"poll\"][\"votes_count\"])")" = "1" ]' \ + && ok "alice's vote counts on snac" || ko "alice's vote never counted on snac" + +echo " edits and deletions" +mi_json PUT "/api/v1/statuses/$mi_post_id" "{\"status\":\"a snac post $run, edited\"}" >/dev/null +until_true 45 '[ "$(p_status $mi_on_p_post content | grep -c edited)" = "1" ]' && ok "snacuser's edit reaches PrivaPub" || ko "snacuser's edit never arrived" +curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d "status=a PrivaPub post for snac $run, edited" +# (snac works through what it receives one message at a time, sometimes minutes behind) +until_true 120 '[ "$(snac_stored "$p_post_uri" "o is not None and \"edited\" in o[\"content\"]")" = "True" ]' && ok "alice's edit reaches snac" || ko "alice's edit never reached snac ($(snac_stored "$p_post_uri" "o and o['content'][-60:]"))" +gone=$(mi_json POST /api/v1/statuses "{\"status\":\"a snac post to delete $run\",\"visibility\":\"public\"}" | j "print(d['id'])") +until_true 45 '[ -n "$(p_home_has "a snac post to delete $run")" ]' +gone_on_p=$(p_home_has "a snac post to delete $run") +mi -o /dev/null -X DELETE "$MI/api/v1/statuses/$gone" +until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$gone_on_p")" = "404" ]' \ + && ok "snacuser's deletion reaches PrivaPub" || ko "snacuser's deleted post still shows on PrivaPub" +curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$p_post_id" +# (snac keeps the object and takes it out of snacuser's timeline) +until_true 45 '! snac_in_timeline "$p_post_uri"' \ + && ok "alice's deletion reaches snac" || ko "alice's deleted post still on snac" + +echo " direct messages" +mi_json POST /api/v1/statuses "{\"status\":\"@alice_snac@privapub.test a snac secret $run\",\"visibility\":\"direct\"}" >/dev/null +until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a snac secret $run"' && ok "snacuser's DM arrives" || ko "snacuser's DM never arrived" +dm_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@snacuser@snac.test a PrivaPub secret $run&visibility=direct" | j "print(d['uri'])") +until_true 120 '[ "$(snac_stored "$dm_uri" "o is not None and \"a PrivaPub secret\" in o[\"content\"]")" = "True" ]' \ + && ok "alice's DM reaches snac" || ko "alice's DM never reached snac" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$mi_on_p/unfollow" +until_true 45 '[ "$(mi "$MI/api/v1/accounts/relationships?id[]=$alice_on_mi" | j "print(d[0][\"followed_by\"])")" = "False" ]' \ + && ok "alice's unfollow reaches snac" || ko "snac still counts alice as a follower" + +echo " statistics" +stats_check snac.test snac