Skip to content

A simple Fediverse Server

The goal of this tutorial is to illustrate how to build a simple Fediverse server using bovine. My goal here is two fold

  • Introduce the reader to bovine
  • Introduce the reader to what is required to build a server in the Fediverse

For the second part, I will only discuss technical aspects not being encapsulated in bovine. In order to illustrate the tutorial, I will use my Mastodon test account with Fediverse handle @themilkman@mas.to. This tutorial will build a simple server using bovine. If you are interested in a full blown implementation, take a look at bovine-herd.

As a warning, we will spend the first three sections with setup. First seeing what the objects, we create look like on Mastodon and then recreating them. There are two sources of complexity here:

  1. Fediverse servers need to authenticate their requests with each other. For this we need a public/private key pair
  2. The Fediverse does not use the actor id (e.g. https://mas.to/users/themilkman) as identifier for actors, but a Fediverse handle (e.g. @themilkman@mas.to), which can be resolved via Webfinger

This means one needs to do quite a bit of ground work, before one can start communicating with ActivityPub.

Info

This tutorial uses quart to build the server. It is my main to make at least bovine independent of quart, and have the dependencies on it in bovine-herd. If you find an issue with something depending on quart, please file an issue.

Understanding the parts what makes the Fediverse work

In this part, we will use curl to understand @themilkman@mas.to. First step, we look up the account using webfinger. Webfinger is a way to request information about a resource from a server.

curl https://mas.to/.well-known/webfinger?resource=acct:themilkman@mas.to | python -m json.tool

{
    "subject": "acct:themilkman@mas.to",
    "aliases": [
    "https://mas.to/@themilkman",
    "https://mas.to/users/themilkman"
    ],
    "links": [
    {
        "rel": "http://webfinger.net/rel/profile-page",
        "type": "text/html",
        "href": "https://mas.to/@themilkman"
    },
    {
        "rel": "self",
        "type": "application/activity+json",
        "href": "https://mas.to/users/themilkman"
    },
    {
        "rel": "http://ostatus.org/schema/1.0/subscribe",
        "template": "https://mas.to/authorize_interaction?uri={uri}"
    }
    ]
}

We are interested here in the entry of type “application/activity+json”, which we again request using curl

curl -H accept:application/activity+json https://mas.to/users/themilkman | python -mjson.tool

{
    "@context": [
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/v1",
        { "...": "...." }
    ],
    "id": "https://mas.to/users/themilkman",
    "type": "Person",
    "inbox": "https://mas.to/users/themilkman/inbox",
    "outbox": "https://mas.to/users/themilkman/outbox",
    "preferredUsername": "themilkman",
    "name": "milkman",
    "publicKey": {
        "id": "https://mas.to/users/themilkman#main-key",
        "owner": "https://mas.to/users/themilkman",
        "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----\n"
    }, 
    "...": "...."
}

where I’ve omitted parts of the output that will not be important to us. With these two examples, I can now describe what you see:

  • The webfinger endpoints allows us to look up the actor id.
  • Looking up the actor id gives us the actor object
  • The actor object contains the name in the Fediverse handle as preferredUsername
  • The actor object contains a public key, which is used to authenticate activities as coming from this actor
  • For all this to work, we need a domain name

Collecting the parts to make everything work

First, we acquire a domain name pointing to localhost with ngrok:

ngrok http 5000

and record the https domain name of the form: https://your_uuid.your_region.ngrok.io. You will set hostname variable below to it.

Next, we set up a virtual environment and install the required packages

python -mvenv venv
. venv/bin/activate
pip install quart tomli_w bovine

quart is an asynchronous server framework in python.

We are now ready to collect all the necessary information and write it in a config file “config.toml”. This step is necessary as, we do not want to generate a new public/private key pair on every execution. I will use “milkdrinker” as my username. To do all this, save the next script as generate.py and then run via python generate.py.

import bovine
import tomli_w

public_key, private_key = bovine.crypto.generate_rsa_public_private_key()

hostname = "your_uuid.your_region.ngrok.io"

config = {
    "handle_name": "milkdrinker",
    "hostname": hostname,
    "public_key": public_key,
    "private_key": private_key,
}

with open("config.toml", "wb") as fp:
    tomli_w.dump(config, fp, multiline_strings=True)

If we now look at the file config.toml, we see that the generate_rsa_public_private_key method created the two keys in their PEM encoding.

Building the necessary objects

We can now start to create the necessary python objects to run the server. We will save this as config.py. We start by loading the previously defined file and define the actor id

import bovine
import tomllib
import json

with open("config.toml", "rb") as fp:
    config = tomllib.load(fp)

handle_name = config["handle_name"]
hostname = config["hostname"]

actor_id = f"https://{hostname}/{handle_name}"

Next, we will build our webfinger response using a helper from bovine

webfinger_response = bovine.utils.webfinger_response_json(
    f"acct:{handle_name}@{hostname}", actor_id
)

if __name__ == "__main__":
    print(json.dumps(webfinger_response, indent=2))

Running this script, we get the output of the minimal response, we need for webfinger, i.e.

python config.py

{
    "subject": "acct:milkdrinker@your_uuid.your_region.ngrok.io",
    "links": [
        {
            "href": "https://your_uuid.your_region.ngrok.io/milkdrinker",
            "rel": "self",
            "type": "application/activity+json"
        }
    ]
}

Next, we will build our actor object with

actor_object = bovine.activitystreams.Actor(
    id=actor_id,
    preferred_username=handle_name,
    name="The Milk Drinker",
    inbox=f"https://{hostname}/inbox",
    outbox=actor_id,
    public_key=config["public_key"],
    public_key_name="main-key",
).build()

if __name__ == "__main__":
    print(json.dumps(actor_object, indent=2))

The resulting object actor_object is the json representation as described here. The server, we eventually build will return this object on requests to actor_id. The additional output to running the script looks like

{
    "@context": [
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/v1"
    ],
    "id": "https://your_uuid.your_region.ngrok.io/milkdrinker",
    "type": "Person",
    "publicKey": {
        "id": "https://your_uuid.your_region.ngrok.io/milkdrinker#main-key",
        "owner": "https://your_uuid.your_region.ngrok.io/milkdrinker",
        "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBI....\n-----END PUBLIC KEY-----\n"
    },
    "inbox": "https://your_uuid.your_region.ngrok.io/inbox",
    "outbox": "https://your_uuid.your_region.ngrok.io/milkdrinker",
    "preferredUsername": "milkdrinker",
    "name": "The Milk Drinker"
}

Finally, we will build a BovineActor object, this is what will allow us to make requests. It is created via

actor = bovine.BovineActor(
    actor_id=actor_id,
    public_key_url=f"{actor_id}#main-key",
    secret=config["private_key"], 
)

import secrets

def make_id():
    return f"https://{hostname}/" + secrets.token_urlsafe(6)

The actor is not shown as it is used to make requests instead of answering them. We will be using make_id to assign ids to objects, we create. This is necessary as ActivityPub objects should have unique ids.

The Server and sending a first message

With the setup out of the way, we can now finally get to run a Fediverse serve. We will proceed in two steps. First, we will publish our actor to the Fediverse and send messages. Second, we will work on receiving messages. The server is given by:

from quart import Quart
import config

app = Quart(__name__)


@app.get("/.well-known/webfinger")
async def webfinger():
    return config.webfinger_response


@app.get("/" + config.handle_name)
async def get_actor():
    return config.actor_object

if __name__ == "__main__":
    app.run(port=5000)

We note that this server just replies with static content, to both the webfinger request and the request for the actor. One can start this server by running

python app.py

This server is enough to make your account searchable by its Fediverse handle milkdrinker@your_uuid.your_region.ngrok.io on say Mastodon. I will be sending a message to the account @themilkman@mas.to. In order to simplify handling the asynchronous execution, I recommend executing the following commands in ipython or equivalent:

>>> import json
>>> from bovine.activitystreams import factories_for_actor_object
>>> from bovine.clients import lookup_account_with_webfinger
>>> from config import actor, actor_object, make_id

>>> await actor.init()

>>> remote = await lookup_account_with_webfinger(actor.session, 'themilkman@mas.to')
>>> remote_inbox = (await actor.get(remote))['inbox']

Now, we are ready to write a note and the surrounding create object. See Example 3 in Overview of the ActivityPub Specification for more information on the format.

>>> activity_factory, object_factory = factories_for_actor_object(actor_object)

>>> mention = {"type": "Mention", "href": remote, "name": "name"}
>>> note = object_factory.note(id=make_id(), to={remote}, 
        content="Hello. I'm doing the bovine tutorial on ActivityPub", 
        tag=mention).build()
>>> create = activity_factory.create(note, id=make_id()).build()
>>> print(json.dumps(create, indent=2))

{
    "@context": "https://www.w3.org/ns/activitystreams",
    "type": "Create",
    "actor": "https://your_uuid.your_region.ngrok.io/milkdrinker",
    "to": [
        "https://mas.to/users/themilkman"
    ],
    "id": "https://your_uuid.your_region.ngrok.io/h12C6ybY",
    "object": {
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Note",
        "attributedTo": "https://your_uuid.your_region.ngrok.io/milkdrinker",
        "to": ["https://mas.to/users/themilkman"],
        "id": "https://your_uuid.your_region.ngrok.io/Jj90godf",
        "content": "Hello. I'm doing the bovine tutorial on ActivityPub",
        "tag": {
            "type": "Mention",
            "href": "https://mas.to/users/themilkman",
            "name": "name"
        }
    }
}

It is necessary to set the ids in order for Mastodon to accept the message. Similarly, it is necessary to mention the receiving account in addition to having it in to in order for Mastodon to display it as a “Direct Message”.

>>> await actor.post(remote_inbox, create)

You can now check your account to see if you received the message. It is worth pointing out here that bovine took care of signing the message using the private key in the background.

Receiving messages

If we now attempt to reply to the above message, it will land in the digital nirvana, as we haven’t implemented the inbox endpoint yet. This will be more complicated, as we wish to verify the signature Mastodon is sending us.

Due to the public key being stored in the actor object, we will also need to retrieve it to verify the signature. The implementation below has the disadvantage of doing no caching of the public key. Also the console is generally not viewed as the best place to store data.

import json
import bovine
from quart import request

async def fetch_public_key(url):
    result = await config.actor.get(url)

    print(f"Retrieved actor from {url} as:")
    print(json.dumps(result, indent=2))
    print()

    public_key = result["publicKey"]

    return public_key["publicKeyPem"], public_key["owner"]

verify = bovine.crypto.build_validate_http_signature(fetch_public_key)

@app.post("/inbox")
async def post_inbox():
    assert await verify(request)

    data = await request.get_json()
    print("Received in inbox")
    print(json.dumps(data, indent=2))
    print()
    return "success", 202

@app.before_serving
async def startup():
    await config.actor.init()

After adding this code to server.py before the main block, you should be able to receive a reply message. Also you might see the Mastodon instance sending you “Delete” activities for accounts being deleted. These will fail to validate.

You might have noticed that the fetch_public_key method returns the key’s owner as a second argument. This is then passed as a result of the verify method and could be used to verify that the request’s actor is the same as the person signing the request.

Info: If you leave your server running long enough, you will most likely see two types of activity

  • Delete Activities for Actors arriving from instances you had contact with
  • Fediverse statistics crawlers. These usually query the nodeinfo endpoint to collect statistics on the number of users in the Fediverse

These two types of activities form the background noise of the Fediverse.

Following and being followed

We will now look at the last bit of mechanic that makes the Fediverse what it is: Followers. Sending a follow request can be done via

>>> follow = activity_factory.follow(remote, id=make_id()).build()
>>> await actor.post(remote_inbox, follow)

Depending on your settings, you may have to accept this follow request manually. The accept message should now show up in your inbox. As noted before the inbox is given by the output of the server started via python app.py

{
    "@context": "https://www.w3.org/ns/activitystreams",
    "id": remote_accept_id,
    "type": "Accept",
    "actor": remote,
    "object": {
        "id": local_follow_id,
        "type": "Follow",
        "actor": actor_url,
        "object": remote
    }
}

Our remote account is now nice and follows the account on our bovine powered server

{
    "@context": "https://www.w3.org/ns/activitystreams",
    "id": remote_id,
    "type": "Follow",
    "actor": remote,
    "object": actor_url
}

For this we copy the remote_id and do

>>> accept = activity_factory.accept(remote_id, to={remote}, id=make_id()).build()
>>> await actor.post(remote_inbox, accept)

When we now check the profile of the actor with our Fediverse account, we should see that following it was successful. Furthermore, now followers posts are delivered to the account, e.g.

>>> note = object_factory.note(id=make_id(), to={remote},
    content="Thanks for following me. I'm done now and will soon delete myself.").build()
>>> create = activity_factory.create(note, id=make_id()).build()
>>> await actor.post(remote_inbox, create)

Cleaning up

As a final step, we should delete our test account, so we don’t pollute databases unnecessarily.

>>> delete = activity_factory.delete(actor_object, id=make_id()).build()
>>> await actor.post(remote_inbox, delete)

Afterword

I hope that this was useful. Please remember to perform the last delete step, I don’t want Bovine to be known for orphaned actors. If you are feeling excited and think you can quickly expand this to the Fediverse application of your dreams. Yes, you can!! However, you should spend some time thinking if it would be even easier to use an existing Fediverse application and extending it. The goal of other parts of the bovine project is to provide tools that can be composed, to make this possible.

If you are looking for inspiration on what to build or what is possible, check out pixels. The source can be found here. pixels can be considered as one of the simplest standalone Fediverse applications.