Cowstodon
I’m currently experimenting with using Mastodon as a client for bovine, and want to explain how the setup works. This means that I keep running bovine on mymath.rocks, but can use a locally running Mastodon instance (mastodon.local) to write and read posts. In this case, Mastodon is no longer a federated ActivityPub server, but a client application. I will discuss both how to post from mastodon.local and have a federated and home timeline.
I see this entire thing as a Proof Of Concept. It shows that the current generation of FediVerse Servers could be turned into ActivityPub Clients and be used together with the same account. So this means I could be using Cowstodon to view my account helge@mymath.rocks in the Mastodon interface and CowFeed to view it in the PixelFed interface. This is a different take on Account Portability than the usually discussed Account Migration. Basically, you will only have one account, but be able to use it with different services.
If you are reading this blog post, you might realize that it is posted from a completely separate ActivityPub Client longhorn. One should note that longhorn was designed for the purpose of being an ActivityPub Client with apropriate libraries at my disposal. So it might provide a cleaner view on how an ActivityPub Client might work than what we discuss below.
Download the code
The code for this blog posts is available at bovine/examples/cowstodon. It consists of two python scripts and a patch that can be applied to Mastodon. As stated above, this code is a proof of concept and not production ready.
ActivityPub Actor Hosts
On mymath.rocks
, I have the bovine server running. It basically powers my FediVerse account helge@mymath.rocks
. There my interpretation of the ActivityPub Specification is running. This includes a full client interface, i.e. being able to get the inbox, and post to the outbox.
It feels awkward calling this an ActivityPub Server, due to many things being called ActivityPub Servers that do not implement the Client To Server specification and thus would not be a drop in replacement for bovine. So I’m going to start calling a server hosting actors, who have get/post capable inboxes and outboxes, from now on ActivityPub Actor Hosts.
There should be a second difference between an ActivityPub Actor Hosts and the common ActivityPub Servers: The former should store incoming activities and objects without any loss of data, whereas it’s ok for the second to discard stuff. For example, it is completely reasonable for Mastodon to disregard all objects of type Recipe, I send to it. However, I haven’t thought about this subject enough, to give a best practice recommendation on how to store activities and objects.
We will not directly interace with the actor host, instead we will use the bovine python library, in particular the BovineClient
class.
Setting up Mastodon
For out purposes, we will need to install a development instance of Mastodon. Following the guide, it should be as simple as
git clone git@github.com:mastodon/mastodon.git
vagrant up
vagrant ssh
cd /vagrant
foreman start
This turned out to be almost true. I had to change the ip address specified in the logfile in the line
config.vm.network :private_network, ip: "192.168.42.42", nictype: "virtio"
Second, I needed to disable the use of nfs, i.e.
# if config.vm.networks.any? { |type, options| type == :private_network }
# config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1']
# else
config.vm.synced_folder ".", "/vagrant"
# end
Afterwards, I had a running Mastodon instance, that was accessible at http://mastodon.local/. Unfortunately, this instance sometimes dies with a SIGKILL. It seems to be caused by the NodeJS Streaming API, at least the nodejs processes continue living. After switiching to running glitch-soc, the problem was still there, but seemed less.
Posting from mastodon.local
In an ideal world, I would be able to configure mastodon.local to directly post to my ActivityPub Actor Host. So when I post a message, it is send to the outbox of the actor, I want to post as, and then the server processes this request. A person knowing Ruby better than I will probably be able to implement this. Unfortunately I’m not the person, instead you get the following recipe.
We locate the code that handles determining the inboxes an Activity is send to. This is ActivityPub::DistributionWorker
in distribution_worker.rb. Here, we change the routine calculating the inboxes to
def inboxes
# @inboxes ||= StatusReachFinder.new(@status).inboxes
@inboxes = ["http://localhost:5000"]
end
We will then be running a python service on port 5000. In order to run the service in the Mastodon vagrant box, one needs to install python3.10 (or 11) from ppa:deadsnakes/ppa
repository. Furthermore, one should run
pip install bovine quart
Now, we are ready for the server script:
from quart import Quart, request
from bovine import BovineClient
app = Quart(__name__)
@app.route("/", methods=["POST"])
async def forward_to_outbox():
data = await request.get_json()
async with BovineClient.from_file("h.toml") as client:
data = rewrite_request(client, data)
await client.send_to_outbox(data)
return "success", 202
if __name__ == "__main__":
app.run()
The rewrite_request
function will be discussed later. Let’s first look at what the code does. There is a bit of scaffolding to answer post requests with the method forward_to_outbox
. This method takes the json rquest body, does some rewriting, and sends it to the outbox of the actor described in h.toml
.
So far this server has done nothing but add some authentication information to the request. The rewrite_request
method, rewrites the request to be originating from the Actor, whose configuration is contained in BovineClient
:
def rewrite_request(client, data):
data["actor"] = client.actor_id
data["cc"].append(client.information["followers"])
if "object" in data and isinstance(data["object"], dict):
data["object"]["attributedTo"] = client.actor_id
data["object"]["cc"] = data["cc"]
return data
This is necessary as otherwise the request would come from admin@mastodon.local
, which is unknown to the rest of the FediVerse.
As I stated at the beginning of this section, I believe this should be relatively straight forward to directly integrate into Mastodon. One would need to figure out where to store the information on the remote actor and a flag to use it.
Note: There is a challenge here that I should mention. When an object is added to the outbox on an ActivityPub Actor Host, it is assigned new ids. As these objects will be hosted on the Actor Host, this is the sensible thing to do, and described in the ActivityPub Specification in this way. This means that Cowstodon need to update the data on a status to include these ids.
Filing the timelines
We will now continue with filling the timelines. The python part is similar to the last one
import asyncio
import json
from bovine import BovineClient
async def proxy(config_file):
async with BovineClient.from_file(config_file) as actor:
event_source = await actor.event_source()
async for event in event_source:
data = json.loads(event.data)
if isinstance(data["actor"], dict):
data["actor"] = data["actor"]["id"]
await actor.client.post(
"http://mastodon.local/users/admin/inbox",
json.dumps(data),
headers={"Actor": data["actor"]},
)
asyncio.run(proxy("h.toml"))
First, the event_source
is a resource provided by bovine providing a stream of Activities as they arrive at the inbox. This avoids having to poll the inbox. Next, if the actor is the whole object, we replace it with the id. This is to not suprise Mastodon’s parser. Finally, we send the activity to the admin user’s inbox, adding an extra header that contains the actor.
Note: If this was actually meant to be used in public, one would want to add some filtering. First Mastodon only understands certain type of objects, see activity.rb. Second, we might only want to send objects suitable for Mastodon for it. For example an object of type Article such as this one is rendered as a stub in Mastodon. So we might want to send it to our feed reader application.
In order for Mastodon to ingest activities, we will need to modify the module SignatureVerification
in signature_verification.rb. Here, we replace
# actor = actor_from_key_id(signature_params['keyId'])
actor = actor_from_key_id(request.headers["Actor"])
so the actor performing the activity is correct. Furthermore, we change the methods verify_signature_strength
, verify_body_digest
, verify_signature
and matches_time_window
to just return true
. Again, this should be avoidable by spending some effort in modifying the signature verification mechanism.
This is not enough yet for the posts to appear, we will also need to remove the check that activities are related to local activiyies. This means in create.rb, we change the first line of the method create_status
to
return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists?
# || !related_to_local_activity?
If we now let this run, the federated timeline on mastodon.local
will start filling up. If one compares the activities actually ingested by Mastodon to the one shown on the federated timeline, one can observe all kinds of patterns. One is that pinned statuses are added to it. This means one of the first posts, I saw on the federated timeline, was from 2020, so three years ago. Also there are significant delays due to Mastodon doing a lot of processing between ingesting an activity and actually displaying it.
The home timeline
We are almost done, without modifying Mastodon. The algorithm used to fill the home timeline is different from the federated timeline, so it will be interesting to also fill this up.
For this, we need to modify fan_out_on_write_service.rb and add
admin_account = ActivityPub::TagManager.instance.uri_to_resource("http://mastodon.local/users/admin", Account)
FeedManager.instance.push_to_home(admin_account, @status)
in the method call
. My understanding is that this inserts the post manually into the home timeline. Somewhat surprisingly, it was not enough to add http://mastodon.local/users/admin
to the recipients of the Activity and Object to achieve this.
The home timeline will look quite different to the federated timeline. In the home timeline, individual messages are shown, whereas the federated timeline only shows top level elements.
Final comments
When hacking software, it is often useful to print output. This can be accomplished in Mastodon via
Rails.logger.warn("TESTING")
due to the rapid scrolling of the Mastodon log, one needs to use unique identifiers here.
Furthermore, it is surprisingly pleasant to add such behavior to Mastodon. My biggest complaint is that Mastodon in the vagrant box seems to be too heavy for my purposes, it would be nice if one could easily slim it down. However, web searches often lead to open github issues, so I didn’t pursue this.
My expectation would be that there exists a minimal docker setup that just consists of the running rails application and the postgres database. Not having to host Redis and the streaming API should simplify things. Furthermore, one should want to disable proxing all images and creating page previews in development. Instead running docker compose up, complains about missing configuration files with no clear guide how to set them up.
The real use for this is not to have a single user Cowstodon instance, but have multi user ones. This would allow to have liked minded indivuals to profit from the federation based filtering on their common federated timeline. This however is not a vision that will probably be realized in the near future.