# SofaPub: A minimally functional ActivityPub Server
```bash
$ sofapub
A minimally functional ActivityPub implementation
Usage: sofapub <COMMAND>
Commands:
setup
server
get
post
webfinger
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
```
I had the thought earlier this evening that it sure would be nice to have a tool that I could use to make properly signed requests to remote ActivityPub servers so that I could evaluate their responses and build interfaces to work effectively. This project stems from that thought. The initial working prototype was built this evening while I was sitting on the sofa.
## TLS
I used `certbot` to generate certificates for my test domain name (sofa.jdt.dev) manually. This is the command I used.
```
certbot certonly --manual -d sofa.jdt.dev --agree-tos --preferred-challenges dns-01 --server https://acme-v02.api.letsencrypt.org/directory --register-unsafely-without-email --rsa-key-size 4096 --config-dir certbot/config --work-dir certbot/work --logs-dir certbot/logs
```
Change that for your own domain name, obviously. You'll need to be ready to update your DNS settings to add in a verification TXT record for LetsEncrypt.
## Networking
You'll need to forward port `443/tcp` to port `8086` (the default I chose) on your server. You'll also need to configure your DNS appropriately to point your domain name at the external address you're forwarding from.
## Setup
This is the setup command I used (adjust for your purposes). This will create the RSA certificates.
```
cargo run --bin sofapub setup \
--username justin \
--display-name "Justin Thomas" \
--summary "This is my test account" \
--domain sofa.jdt.dev \
--tls-private-key-path /opt/certbot/config/live/sofa.jdt.dev/privkey.pem \
--tls-certificate-path /opt/certbot/config/live/sofa.jdt.dev/fullchain.pem
```
The configuration is stored in `$HOME/.sofapub` and a suitable directory structure will be created there to support the server's operations.
## Operating
`RUST_LOG=debug cargo run --bin sofapub server` will start the SofaPub server. You can also use `cargo install --path .` to install the `sofapub` executable to your local `cargo/.bin` directory. That should be included in your `PATH` already.
Messages sent to `inbox` will be captured in the `~/.sofapub/data/inbox` folder with server-generated UUID filenames. Here is what a Follow request from my user at infosec.exchange looks like:
```
$ RUST_LOG=debug sofapub server
[2023-09-02T01:05:32Z DEBUG server] igniting Configuration
[2023-09-02T01:05:32Z INFO rocket::launch] 🔧 Configured for debug.
[2023-09-02T01:05:32Z INFO rocket::launch::_] address: 0.0.0.0
[2023-09-02T01:05:32Z INFO rocket::launch::_] port: 8086
[2023-09-02T01:05:32Z INFO rocket::launch::_] workers: 16
[2023-09-02T01:05:32Z INFO rocket::launch::_] max blocking threads: 512
[2023-09-02T01:05:32Z INFO rocket::launch::_] ident: Rocket
[2023-09-02T01:05:32Z INFO rocket::launch::_] IP header: X-Real-IP
[2023-09-02T01:05:32Z INFO rocket::launch::_] limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB
[2023-09-02T01:05:32Z INFO rocket::launch::_] temp dir: /var/folders/z6/y2vfsg3j739fbx4hwzf_m7700000gn/T/
[2023-09-02T01:05:32Z INFO rocket::launch::_] http/2: true
[2023-09-02T01:05:32Z INFO rocket::launch::_] keep-alive: 5s
[2023-09-02T01:05:32Z INFO rocket::launch::_] tls: enabled w/o mtls
[2023-09-02T01:05:32Z INFO rocket::launch::_] shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s
[2023-09-02T01:05:32Z INFO rocket::launch::_] log level: normal
[2023-09-02T01:05:32Z INFO rocket::launch::_] cli colors: true
[2023-09-02T01:05:32Z INFO rocket::launch] 📬 Routes:
[2023-09-02T01:05:32Z INFO rocket::launch::_] (inbox_post) POST /inbox
[2023-09-02T01:05:32Z INFO rocket::launch::_] (profile) GET /<handle>
[2023-09-02T01:05:32Z INFO rocket::launch::_] (activity_pub) GET /users/<_username>
[2023-09-02T01:05:32Z INFO rocket::launch::_] (webfinger) GET /.well-known/webfinger?<resource> application/jrd+json
[2023-09-02T01:05:32Z INFO rocket::launch] 📡 Fairings:
[2023-09-02T01:05:32Z INFO rocket::launch::_] Shield (liftoff, response, singleton)
[2023-09-02T01:05:32Z INFO rocket::launch::_] SofaPub Configuration (ignite)
[2023-09-02T01:05:32Z INFO rocket::shield::shield] 🛡 Shield:
[2023-09-02T01:05:32Z INFO rocket::shield::shield::_] X-Frame-Options: SAMEORIGIN
[2023-09-02T01:05:32Z INFO rocket::shield::shield::_] X-Content-Type-Options: nosniff
[2023-09-02T01:05:32Z INFO rocket::shield::shield::_] Permissions-Policy: interest-cohort=()
[2023-09-02T01:05:32Z WARN rocket::launch] 🚀 Rocket has launched from https://0.0.0.0:8086
[2023-09-02T01:05:39Z DEBUG rustls::server::hs] decided upon suite TLS13_AES_256_GCM_SHA384
[2023-09-02T01:05:39Z INFO rocket::server] POST /inbox application/activity+json:
[2023-09-02T01:05:39Z INFO rocket::server::_] Matched: (inbox_post) POST /inbox
[2023-09-02T01:05:39Z INFO rocket::server::_] Outcome: Success
[2023-09-02T01:05:39Z INFO rocket::server::_] Response succeeded.
^C[2023-09-02T01:05:43Z WARN rocket::server] Received SIGINT. Requesting shutdown.
[2023-09-02T01:05:43Z INFO rocket::server] Shutdown requested. Waiting for pending I/O...
[2023-09-02T01:05:43Z DEBUG rustls::conn] Sending warning alert CloseNotify
[2023-09-02T01:05:43Z INFO rocket::server] Graceful shutdown completed successfully.
$ ls -la ~/.sofapub/data/inbox/
total 24
drwxr-xr-x@ 6 justin staff 192 Sep 1 18:05 .
drwxr-xr-x@ 7 justin staff 224 Sep 1 17:55 ..
-rw-r--r--@ 1 justin staff 227 Sep 1 18:03 1b7a3159-dcac-49f8-b687-b1defa06ff79.json
-rw-r--r--@ 1 justin staff 360 Sep 1 18:05 f7c0ca70-49a9-4901-8928-f7bae48b8d1b.json
$ cat ~/.sofapub/data/inbox/*.json | jq
{
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://infosec.exchange/users/jdt",
"id": "https://infosec.exchange/a9941fe3-1051-490c-8cb8-8793a1a9bcf3",
"object": "https://sofa.jdt.dev/users/justin",
"type": "Follow"
}
{
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://infosec.exchange/users/jdt",
"id": "https://infosec.exchange/users/jdt#follows/2830232/undo",
"object": {
"actor": "https://infosec.exchange/users/jdt",
"id": "https://infosec.exchange/a9941fe3-1051-490c-8cb8-8793a1a9bcf3",
"object": "https://sofa.jdt.dev/users/justin",
"type": "Follow"
},
"type": "Undo"
}
```
Technically, I issued the Follow earlier, and the messages in the log above are showing you the Undo. Nonetheless, you can see both messages are captured by SofaPub for your review.
## Demonstration
Assuming you have SofaPub up and running and you can query your user from another instance successfully, you should be able to use the included tools to interrogate and interact with ActivityPub instances in interesting ways. Here is one example where I use `sofapub_get` to request profile data from a server that requires request signing for all operations (firefish.social).
First I use the included `webfinger` tool to retrieve the WebFinger record for my user at Firefish.
```
$ sofapub webfinger @jdt@firefish.social | jq
{
"subject": "acct:jdt@firefish.social",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://firefish.social/users/9j54zro9qetnjhza"
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://firefish.social/@jdt"
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://firefish.social/authorize-follow?acct={uri}"
}
]
}
```
Next I use the `self` `href` value with a type of `application/activity+json` to try to retrieve the record with `curl`. This would work on most Mastodon systems (which do not require request signing by default).
```
$ curl -H "Accept: application/activity+json" https://firefish.social/users/9j54zro9qetnjhza
Unauthorized
```
Bummer. No worries, though. Here I process the request through `sofapub get` which uses my private RSA key in my configuration to sign the request. My `sofapub server` is running, to allow the target server to retrieve my public key to verify the signature.
```
$ sofapub get https://firefish.social/users/9j54zro9qetnjhza | jq
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"movedToUri": "as:movedTo",
"sensitive": "as:sensitive",
"Hashtag": "as:Hashtag",
"quoteUri": "fedibird:quoteUri",
"quoteUrl": "as:quoteUrl",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"featured": "toot:featured",
"discoverable": "toot:discoverable",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"misskey": "https://misskey-hub.net/ns#",
"_misskey_content": "misskey:_misskey_content",
"_misskey_quote": "misskey:_misskey_quote",
"_misskey_reaction": "misskey:_misskey_reaction",
"_misskey_votes": "misskey:_misskey_votes",
"_misskey_talk": "misskey:_misskey_talk",
"isCat": "misskey:isCat",
"fedibird": "http://fedibird.com/ns#",
"vcard": "http://www.w3.org/2006/vcard/ns#"
}
],
"type": "Person",
"id": "https://firefish.social/users/9j54zro9qetnjhza",
"inbox": "https://firefish.social/users/9j54zro9qetnjhza/inbox",
"outbox": "https://firefish.social/users/9j54zro9qetnjhza/outbox",
"followers": "https://firefish.social/users/9j54zro9qetnjhza/followers",
"following": "https://firefish.social/users/9j54zro9qetnjhza/following",
"featured": "https://firefish.social/users/9j54zro9qetnjhza/collections/featured",
"sharedInbox": "https://firefish.social/inbox",
"endpoints": {
"sharedInbox": "https://firefish.social/inbox"
},
"url": "https://firefish.social/@jdt",
"preferredUsername": "jdt",
"name": null,
"summary": null,
"icon": null,
"image": null,
"tag": [],
"manuallyApprovesFollowers": false,
"discoverable": true,
"publicKey": {
"id": "https://firefish.social/users/9j54zro9qetnjhza#main-key",
"type": "Key",
"owner": "https://firefish.social/users/9j54zro9qetnjhza",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyzcWqxqXMH+tsPhIIZhU\nc9kHQHN+quayOAw+FtdX7bNmo+fY2Bndy5wRHymdcF/fFIXxCfeN6aO0FqBsPCrt\nO7XBsRkHi4LPSaZN730q+Q/FZmf6SVy943WWf8LgXOkt2VjJRO52w0seGrPR1/Dd\nB/6rFTDOVWUUyASL8+E1X2yQJ/veHRFrwpLPwYnfjJypCzhd2z3++y1PzjeHwygE\nHJx7EIcmFsiw7F+xDkEY4RWA/vV7bTajsij1P+DRkJJN+eNoK8y58Oxx2hf20tko\nf4cFnuawyLCRquixNlTHNqHxXR87nLEMP4rZrjuOjf5aIG7kxbyBnNuPtZ8ASrb/\nyprlsWkhkOY22K+XwvTWGDyo8Fduxh5ntWUB97fV1gDzD2NoN2bhXA4giUGCbo5V\nTj2Sbgsvk/DrF21whQjCJvVCThZwKfX7hZaaTWljNE1UEOTyS16WM+1i/ZWlOE5V\nQ7IbgImC+0rsbE9XeQaBJK5OhrOgO1nUeQkR4DwSaSORWuLf5xewJ6ZYxT474M+l\nytmrvCJFLUriDqFk8zjr6gon7fLt2yKagLaEU5DXduJQRMkpJ4hajpuZE2YNQwGj\n+ZNtpc6+OYfSbyxo2D/+HfVf1emQ1tSKo/w0chLzbokpIEFuR/4pmg1Etr5XG3XY\nP2nwPARDmUE/dzJ9Avq8Qy8CAwEAAQ==\n-----END PUBLIC KEY-----\n"
},
"isCat": false
}
```