diff --git a/CLAUDE.md b/CLAUDE.md index ab3b015..4fcf844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -607,6 +607,14 @@ tools/pasture/run.sh down # removes e announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and has Mastodon leave again, so the town sees no relayed posts. 16 checks. +- **Hubzilla (11.4.1, `peers/hubzilla.sh`):** the hlhd image (php-fpm and nginx on 8080) on the shared MySQL + (database `hubzilla`, its schema loaded from the image), `.htconfig.php` written to `.state/hubzilla` and mounted, a + cron sidecar running `Zotlabs/Daemon/Master.php Cron` each minute. Its addons `pubcrawl` (ActivityPub) and `statistics` + (NodeInfo) are installed with `util/addons`; hzuser (the hub's administrator) and hzfriend are made through its own PHP + (`hz_eval`), as accounts written the way its registration queue would and channels with the role `public` (the older + role names grant connections nothing), the ActivityPub app installed as each channel. Its API (`api/z/1.0`) posts and + edits with HTTP Basic (email and password); liking and dropping go through its web pages signed in (`hz_web`). + `scenarios/hubzilla.sh`, 20 checks and one known gap (G-0010, its unfollow). - **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit), with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with diff --git a/FEDERATION.md b/FEDERATION.md index 61e467a..1de809e 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -44,6 +44,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Ghost 6.67** with its ActivityPub service 1.2.14 - **BookWyrm 0.9.3** - **Vernissage 1.43.0** +- **Hubzilla 11.4.1** with its pubcrawl addon - **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 30a6dde..3967eab 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -854,6 +854,27 @@ without a port, before it gives out its OAuth client. never as the relay's boost), and nobody's home; a persona's public post goes to the relays, nothing less public, and reaches Mastodon through both (forwarded on its proof, and announced). +### Hubzilla 11.4.1 (pubcrawl) + +- **Channels speak ActivityPub only with the "Activitypub Protocol" app** installed for them (the pubcrawl addon on + the hub first). Accounts on Hubzilla 11 come only from its registration queue; the pasture writes the account row as + that would (salt, `whirlpool(salt . password)`). +- **A channel's role decides what its connections may do.** Hubzilla 11 grants what a role's `perms_connect` lists only + for the roles `public`, `personal`, `group` and `custom`; the older names its role list still shows (`social`, + `social_restricted`, ...) grant a new connection nothing, so it drops everything that connection sends ("store: no + permission") while answering 200. +- **Threads are its owner's:** a comment by another channel in a thread goes to the owner, who passes it on to the + thread's audience as the commenter's own Create, with the commenter's FEP-8b32 proof, and adds each activity to the + thread's `context` (FEP-171b, `Add{Create}`). PrivaPub takes the forwarded Create on its proof; the `Add` is dropped, + the Create having said it already. +- **Signs everything with a proof** (FEP-8b32) and verifies HTTP signatures; NodeInfo comes from its `statistics` + addon. +- **An unfollow changes nothing there** (G-0010): `Activity::unfollow` deletes the setting `system.their_perms`, which + Hubzilla 11 keeps one permission per row under `their_perms`, so the follower stays. +- **Pasture evidence (2026-10-06, `tools/pasture/scenarios/hubzilla.sh`):** 20 checks pass and one gap is expected + (G-0010): follows both ways, posts both ways, comments both ways, a third channel's comment passed on by the thread's + owner, likes both ways, edits and deletions both ways, statistics. + ### Smithereen 1.0.3 - **Walls:** a post is a `Note`; one written on someone else's wall carries the wall (`sm:wall`, FEP-400e) as its diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index f828a39..3e730fa 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,11 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +hubzilla.test { + tls internal + reverse_proxy pasture-hubzilla:8080 +} + vernissage.test { tls internal reverse_proxy pasture-vernissage:8080 diff --git a/tools/pasture/peers/hubzilla.sh b/tools/pasture/peers/hubzilla.sh new file mode 100644 index 0000000..791e9f9 --- /dev/null +++ b/tools/pasture/peers/hubzilla.sh @@ -0,0 +1,125 @@ +# Hubzilla 11.4.1: channels with nomadic identity, speaking its own Zot6 and, through its pubcrawl addon, ActivityPub. +# The hlhd image (php-fpm and nginx on 8080) on the shared MySQL (database hubzilla, its schema from the image), its +# configuration written here and mounted as .htconfig.php, its store in a volume. Its queue runs in processes the web +# container spawns and in a cron sidecar (Zotlabs/Daemon/Master.php Cron each minute). It trusts Caddy's CA through the +# bundle mounted as its system store. Accounts and channels are made through its own PHP (util/ and cli_startup), and a +# channel speaks ActivityPub only once the "Activitypub Protocol" app is installed for it. Its API (api/z/1.0) takes +# HTTP Basic authentication by the account's email and password. +HUBZILLA_IMAGE=${HUBZILLA_IMAGE:-docker.io/hlhd/hubzilla:v11.4.1} +HUBZILLA_PASSWORD=Hubzilla-Pasture-1 +. "$here/peers/shared.sh" + +hz_php() { podman exec -u www-data -w /var/www/html pasture-hubzilla php "$@"; } +# hz_eval : PHP run inside Hubzilla, its CLI start done (the database, the configuration, the classes) +hz_eval() { podman exec -i -u www-data -w /var/www/html pasture-hubzilla php -r "require_once('include/cli_startup.php'); cli_startup(); $1"; } + +hubzilla_config() { + cat < "$here/.state/hubzilla/htconfig.php" + podman volume exists pasture-hubzilla-store || podman volume create --label pasture=1 pasture-hubzilla-store >/dev/null + podman run -d --replace --name pasture-hubzilla --network $net --label pasture=1 \ + -v "$here/.state/hubzilla/htconfig.php:/var/www/html/.htconfig.php:z,ro" -v pasture-hubzilla-store:/var/www/html/store \ + -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" "$HUBZILLA_IMAGE" >/dev/null + podman exec -u root pasture-hubzilla sh -c 'mkdir -p /var/www/html/store/[data]/smarty3 && chown -R www-data /var/www/html/store' + # its schema, once + if [ "$(podman exec pasture-mysql mysql -upasture -ppasture -N hubzilla -e "show tables like 'account'" 2>/dev/null)" != "account" ]; then + podman exec pasture-hubzilla cat /var/www/html/install/schema_mysql.sql | podman exec -i pasture-mysql mysql -upasture -ppasture hubzilla 2>/dev/null + fi + for _ in $(seq 1 60); do + site hubzilla.test -s -o /dev/null -w '%{http_code}' https://hubzilla.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 2 + done + podman run -d --replace --name pasture-hubzilla-cron --network $net --label pasture=1 --volumes-from pasture-hubzilla \ + -u www-data -w /var/www/html --entrypoint sh "$HUBZILLA_IMAGE" -c 'while true; do php Zotlabs/Daemon/Master.php Cron; sleep 60; done' >/dev/null + hubzilla_settle + echo "hubzilla: https://hubzilla.test:6443" +} + +# ActivityPub and NodeInfo on, hzuser and hzfriend +hubzilla_settle() { + hz_php util/addons install pubcrawl >/dev/null 2>&1 || true + # NodeInfo is the statistics addon's + hz_php util/addons install statistics >/dev/null 2>&1 || true + # (the first account is the hub's administrator, as Hubzilla asks) + hubzilla_user hzuser 4096 + hubzilla_user hzfriend +} + +# hubzilla_user [roles]: an account (@hubzilla.test, the pasture's password; Hubzilla 11 makes accounts +# only from its registration queue, so the row is written as that would, the password salted and hashed with whirlpool) +# with a public channel of that name that speaks ActivityPub and takes followers without asking. Its role is "public": +# Hubzilla 11 grants a connection what a role's perms_connect lists only for public, personal, group and custom, and the +# older names its role list still offers ("social") grant nothing, so nobody could post to it +hubzilla_user() { + hz_eval " +require_once('include/account.php'); require_once('include/channel.php'); +use Zotlabs\Lib\Apps; +\$email = '$1@hubzilla.test'; +\$r = q(\"select account_id from account where account_email = '%s'\", dbesc(\$email)); +if (! \$r) { + \$salt = random_string(32); + q(\"insert into account (account_parent, account_salt, account_password, account_email, account_language, account_created, account_flags, + account_roles, account_level, account_expires, account_service_class) values (0, '%s', '%s', '%s', 'en', '%s', 0, %d, 5, '%s', '')\", + dbesc(\$salt), dbesc(hash('whirlpool', \$salt . '$HUBZILLA_PASSWORD')), dbesc(\$email), dbesc(datetime_convert()), intval(${2:-0}), + dbesc(DBA::\$dba->get_null_date())); + \$r = q(\"select account_id from account where account_email = '%s'\", dbesc(\$email)); +} +\$account_id = \$r[0]['account_id']; +q('update account set account_flags = 0 where account_id = %d', intval(\$account_id)); +\$c = channelx_by_nick('$1'); +if (! \$c) { + \$x = create_identity(['account_id' => \$account_id, 'nickname' => '$1', 'name' => '$1', 'permissions_role' => 'public', 'publish' => 1]); + if (! \$x['success']) { echo 'channel: ' . \$x['message'] . PHP_EOL; exit(1); } + \$c = channelx_by_nick('$1'); +} +// (its apps list only what a signed-in channel may have: the import runs as the channel, its session set as +// local_channel() reads it) +session_id('cli' . \$c['channel_id']); +\$_SESSION = ['authenticated' => 1, 'uid' => \$c['channel_id'], 'account_id' => \$c['channel_account_id']]; +App::\$channel = \$c; +Apps::import_system_apps(); +if (! Apps::addon_app_installed(\$c['channel_id'], 'pubcrawl')) + Apps::app_install(\$c['channel_id'], 'Activitypub Protocol'); +echo 'ok ' . \$c['channel_id'] . PHP_EOL; +" +} + +# hz_web : a request to Hubzilla's web pages as , signed in through its login form (what its +# API has no route for: liking, editing, dropping) +hz_web() { + local nick=$1 jar="$here/.state/hubzilla/$1.cookies"; shift + if ! site hubzilla.test -s -b "$jar" https://hubzilla.test:6443/network 2>/dev/null | grep -qi logout; then + rm -f "$jar" + site hubzilla.test -s -o /dev/null -c "$jar" -X POST https://hubzilla.test:6443/login -d auth-params=login \ + -d "main_login_username=$nick@hubzilla.test" --data-urlencode "main_login_password=$HUBZILLA_PASSWORD" + fi + site hubzilla.test -s -b "$jar" -c "$jar" "$@" +} diff --git a/tools/pasture/scenarios/hubzilla.sh b/tools/pasture/scenarios/hubzilla.sh new file mode 100644 index 0000000..ee18208 --- /dev/null +++ b/tools/pasture/scenarios/hubzilla.sh @@ -0,0 +1,88 @@ +# Hubzilla 11.4.1 (pubcrawl, its ActivityPub addon): follows both ways; posts both ways; comments both ways; likes both +# ways; a comment by hzfriend in hzuser's thread, which Hubzilla passes on to alice as the thread's owner; edits and +# deletions both ways; the unfollow; statistics. Hubzilla's API (api/z/1.0) posts, edits and reads; what it has no route +# for is done through its own PHP (connecting) or its web pages signed in (liking, dropping; peers/hubzilla.sh), and +# what it holds is read from its MySQL (database hubzilla, the item table by mid). +. "$here/peers/hubzilla.sh" +HZ=https://hubzilla.test:6443 +# hzc : Hubzilla's API as +hzc() { local nick=$1; shift; site hubzilla.test -s -u "$nick@hubzilla.test:$HUBZILLA_PASSWORD" "$@"; } +hz_sql() { podman exec pasture-mysql mysql -upasture -ppasture hubzilla -Nse "$1" 2>/dev/null; } +# hz_item [nick]: the id of the item Hubzilla holds under that ActivityPub id in a channel (hzuser's) +hz_item() { hz_sql "select id from item where mid = '$1' and uid = (select channel_id from channel where channel_address = '${2:-hzuser}') and item_deleted = 0 limit 1"; } +hz_connect() { hz_eval "use Zotlabs\\Lib\\Connect; \$c = channelx_by_nick('$1'); \$r = Connect::connect(\$c, '$2'); echo \$r['success'] ? 'connected' : \$r['message'];"; } +p_home_id() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((o['id'] for o in ((s.get('reblog') or s) for s in d) if '$1' in (o['content'] or '')), ''))"; } +p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; } +p_replies_have() { [ "$(curl -s -H "$PH" "$P/api/v1/statuses/$1/context" | j "print(any('$2' in s['content'] for s in d['descendants']))")" = "True" ]; } + +echo "hubzilla" +[ -n "$(hzc hzuser "$HZ/api/z/1.0/verify" | j "print(d.get('channel_address') or '')")" ] && ok "Hubzilla's API as hzuser" \ + || { ko "Hubzilla's API ($(hzc hzuser "$HZ/api/z/1.0/verify" | head -c 200))"; return 1; } +PT=$(privapub_token alice_hubzilla) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_hubzilla" || { ko "PrivaPub token for alice_hubzilla"; return 1; } +alice_uri="$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['url'])" | sed 's|/@|/peasants/|')" +run=$(date +%s) + +echo " follows" +hz_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=hzuser@hubzilla.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$hz_on_p" ] && ok "PrivaPub resolves hzuser" || ko "PrivaPub cannot resolve hzuser" +# (a Hubzilla made anew keeps its addresses: alice follows again, so both follows below are new) +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$hz_on_p/unfollow" +hz_connect hzuser "$alice_uri" >/dev/null +until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d[\"followers_count\"])")" -ge 1 ]' \ + && ok "hzuser follows alice" || ko "Hubzilla's follow never reached alice" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$hz_on_p/follow" +until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$hz_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows hzuser (Accept arrived)" || ko "Hubzilla's Accept never arrived" +# (hzfriend connects to hzuser now: a channel comments only on what reached it, and only once the other side has let it) +hz_connect hzfriend hzuser@hubzilla.test >/dev/null + +echo " posts" +hzc hzuser -o /dev/null -X POST "$HZ/api/z/1.0/item/update" --data-urlencode "body=a Hubzilla post $run" +until_true 60 '[ -n "$(p_home_id "a Hubzilla post $run")" ]' && ok "hzuser's post reaches alice's home" || ko "hzuser's post never reached alice" +hz_post=$(p_home_id "a Hubzilla post $run") +hz_post_uri=$(p_status "$hz_post" uri) +p_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post for Hubzilla $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 60 '[ -n "$(hz_item "$p_post_uri")" ]' && ok "alice's post reaches hzuser" || ko "alice's post never reached Hubzilla" + +echo " comments" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@hzuser@hubzilla.test a PrivaPub comment $run&in_reply_to_id=$hz_post&visibility=public" +until_true 60 '[ "$(hz_sql "select count(*) from item where body like '"'%a PrivaPub comment $run%'"' and thr_parent = '"'$hz_post_uri'"'")" -ge 1 ]' \ + && ok "alice's comment threads under hzuser's post" || ko "alice's comment never reached Hubzilla" +hzc hzuser -o /dev/null -X POST "$HZ/api/z/1.0/item/update" --data-urlencode "body=a Hubzilla comment $run" -d "parent=$(hz_item "$p_post_uri")" +until_true 60 'p_replies_have "$p_post_id" "a Hubzilla comment $run"' && ok "hzuser's comment threads under alice's post" || ko "hzuser's comment missing on PrivaPub" +# hzfriend, who alice does not follow, comments in hzuser's thread: Hubzilla, the thread's owner, passes it on to alice +until_true 30 '[ -n "$(hz_item "$hz_post_uri" hzfriend)" ]' >/dev/null +hzc hzfriend -o /dev/null -X POST "$HZ/api/z/1.0/item/update" --data-urlencode "body=a word from hzfriend $run" -d "parent=$(hz_item "$hz_post_uri" hzfriend)" +until_true 60 'p_replies_have "$hz_post" "a word from hzfriend $run"' \ + && ok "hzfriend's comment in hzuser's thread reaches alice, passed on" || ko "hzfriend's comment never reached alice" + +echo " likes" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$hz_post/favourite" +until_true 60 '[ "$(hz_sql "select count(*) from item where verb = '"'Like'"' and thr_parent = '"'$hz_post_uri'"' and item_deleted = 0")" -ge 1 ]' \ + && ok "alice's like lands on Hubzilla" || ko "alice's like never reached Hubzilla" +hz_web hzuser -o /dev/null "$HZ/like/$(hz_item "$p_post_uri")?verb=like" +until_true 60 '[ "$(p_status "$p_post_id" favourites_count)" = "1" ]' && ok "hzuser's like counts on PrivaPub" || ko "hzuser's like never counted" + +echo " edits and deletions" +hzc hzuser -o /dev/null -X POST "$HZ/api/z/1.0/item/update" -d "post_id=$(hz_item "$hz_post_uri")" --data-urlencode "body=a Hubzilla post $run, edited" +until_true 60 'p_status "$hz_post" content | grep -q "edited"' && ok "hzuser's edit reaches PrivaPub" || ko "hzuser's edit never reached PrivaPub" +curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d "status=a PrivaPub post for Hubzilla $run, edited" +until_true 60 '[ "$(hz_sql "select count(*) from item where mid = '"'$p_post_uri'"' and body like '"'%edited%'"'")" -ge 1 ]' \ + && ok "alice's edit reaches Hubzilla" || ko "alice's edit never reached Hubzilla" +hz_web hzuser -o /dev/null "$HZ/item/drop/$(hz_item "$hz_post_uri")" +until_true 60 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$hz_post")" = "404" ]' \ + && ok "hzuser's deletion reaches PrivaPub" || ko "hzuser's deleted post still on PrivaPub" +curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$p_post_id" +until_true 60 '[ -z "$(hz_item "$p_post_uri")" ]' && ok "alice's deletion reaches Hubzilla" || ko "alice's deleted post still on Hubzilla" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$hz_on_p/unfollow" +# (Hubzilla takes the Undo, verified, but its unfollow deletes a setting Hubzilla 11 no longer keeps: G-0010) +until_true 20 '[ "$(hz_sql "select count(*) from abconfig where xchan = '"'$alice_uri'"' and cat = '"'their_perms'"' and v = '"'1'"' and chan = (select channel_id from channel where channel_address = '"'hzuser'"')")" = "0" ]' \ + && ok "alice's unfollow reaches Hubzilla" || xf "Hubzilla keeps alice following hzuser after her Undo{Follow} (G-0010)" + +echo " statistics" +stats_check hubzilla.test hubzilla diff --git a/tools/pasture/town/gaps.json b/tools/pasture/town/gaps.json index f7358fb..f473317 100644 --- a/tools/pasture/town/gaps.json +++ b/tools/pasture/town/gaps.json @@ -137,5 +137,19 @@ "status": "closed", "note": "Closed by the owner's decision of 2026-10-06: a persona's actor names its wall (sm:wall, FEP-400e), so Smithereen sends PrivaPub what is written on its users' walls (Add{Note}, shown to their followers here as on that wall), and its users may write on a persona's wall when they follow it. Checked live: Smithereen 1.0.3 scenario, 40 checks.", "closed": "2026-10-06" + }, + { + "id": "G-0010", + "title": "Hubzilla keeps an ActivityPub follower after its Undo{Follow}: the follower's permissions stay granted", + "match": { + "feature": "unfollow", + "observer": "hubzilla" + }, + "kind": "peer", + "phase": "upstream", + "code": "hubzilla 11.4.1 Zotlabs/Lib/Activity.php unfollow(): AbConfig::Delete($channel, $xchan, 'system', 'their_perms'), while Hubzilla 11 keeps a connection's permissions one per row under the category their_perms", + "opened": "2026-10-06", + "status": "open", + "note": "The Undo is verified and handled; nothing changes. PrivaPub drops what Hubzilla goes on sending, as from an account no persona follows." } ]