ActivityPub 協定的基本教學(上)

(最近更新:2022-11-23,勘誤)

序言

隨着馬斯克入主推特後對內激進整飭的作爲,許多人想到避難目標 Mastodon。但是也不一定要入駐 Mastodon,因為 Mastodon 是利用去中心化 ActivityPub 協定跨伺服器傳遞訊息的,所以只要支援 ActivityPub 的 SNS 平臺,比如 Pleroma、MissKey 都可以使用。

另外鑑於有些人反映 Mastodon 的效率太慢,其實也可以自己製作支援 ActivityPub 的微網誌服務軟體。我之前本來想要做一組SNS,但是發現這個 side-project 會嚴重侵蝕自己的作息控制和身心健康,所以擱置了。

但是鑑於這個去中心化協定獲得的關注度很高,而網路上幾乎沒有華語的教學,從而沒有辦法百花齊放,推進這個技術的生態圈,洵為可惜,加上自己曾經參與一篇 Timmot 的教學的回饋,所以就用該教學來編譯成華語,希望能夠推動大家對這個協議的興趣。

如果這個教學有錯誤處請告訴我。另筆者不負責參考本教學執行的後果,還請注意。

教學簡介

教學的 Python 程式碼改自 timmot 的 ActivityPub 教學,將 原教學使用的 Flask 後端框架翻譯成 Django 這個 Python 網頁後端框架的版本,使用 MIT 授權

其中會先教到如何使用 Nginx 伺服器驅動一個新設置的 Django 專案,再用這個專案改成能夠傳輸 ActivityPub json 訊息之軟體。

先備條件

以下教學適用環境如下:

會 Python 和 Django 的基礎知識。

  • Ubuntu 20.04(我是在 Docker 裏面測試,但是實機應該也可以照樣用)
  • Python 3.10.6
  • Django 4.1.3
  • 一組浮動 IP
  • No-IP Linux DUC Stable version 2.1.9
  • certbot 1.32.0

基礎軟體安裝

Nginx HTTP 伺服器

安裝 nginx:

1
2
$ sudo apt update
$ sudo apt install nginx

確認一下 systemtctl 有沒有開啓 nginx,沒有則開啓:

1
$ sudo systemctl start nginx

這時候打開瀏覽器,進入 127.0.0.1 這個代表自己電腦的 IP,看到下列文字,代表成功了:

1
2
3
 Welcome to nginx!
If you see this page, the nginx web server is successfully installed and working. Further configuration is required.
...

Python 程式語言

因為 Django 是 Python 語言的後端框架,所以需要安裝 Python,和安裝 Python 套件的工具 pip:

1
$ sudo apt install python3-pip python-3

Django 安裝、建置專案

安裝 virtualenv、建立環境目錄

爲了隔離開發環境的依賴模組,我們把 Python 套件用 virtualenv 安裝在自定的目錄裏面,避免動到系統全局的套件安裝,所以先安裝 virtualenv:

1
$ pip3 install virtualenv #安裝 virtualenv 

在家目錄或其他適當的地方建立新目錄 my_folder,並在裏面建立自己的環境目錄my_env,套用環境:

1
2
3
4
5
user@host:~$ mkdir my_folder
user@host:~$ cd my_folder
user@host:~/my_folder$ virtualenv my_env #建立自己的環境目錄
user@host:~/my_folder$ source my_env/bin/activate #進入自己的環境
(my_env) user@host:~/my_folder$

在環境目錄內安裝 Django

最後灌 Django 框架到環境目錄內:

1
(my_env) user@host:~/my_folder$ pip3 install django

建立自己的測試專案

用 django-admin 建立測試專案資料夾 activitypub_test

1
(my_env) user@host:~/my_folder$ django-admin startproject activitypub_test

安裝時區資料 tzdata

爲了正確處理時區資訊,需要安裝 tzdata:

1
pip3 install tzdata

Gunicorn

和 uWSGI 一樣,提供伺服器軟體和 Python Django 網頁後端框架溝通的橋樑:

1
(my_env) user@host:~/my_folder$ pip3 install gunicorn

進去自己的專案目錄,然後測試,出現類似下列的字樣,可以 Ctrl-z 離開:

1
2
3
4
5
6
(my_env) user@host:~/my_folder$ cd activitypub_test
(my_env) user@host:~/my_folder/activitypub_test$ gunicorn --bind 0.0.0.0:8000 activitypub_test.wsgi
[2022-11-19 09:14:29 +0000] [54] [INFO] Starting gunicorn 20.1.0
[2022-11-19 09:14:29 +0000] [54] [INFO] Listening at: http://0.0.0.0:8000 (54)
[2022-11-19 09:14:29 +0000] [54] [INFO] Using worker: sync
...

爲了讓系統起動時能夠自動開啓 gunicorn,需要建立下列兩個 Systemd 的設定檔案:

  • /etc/systemd/system/gunicorn.socket
1
2
3
4
5
6
7
8
[Unit]
Description=gunicorn socket

[Socket]
ListenStream=/run/gunicorn.sock

[Install]
WantedBy=sockets.target
  • /etc/systemd/system/gunicorn.service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Unit]
Description=gunicorn daemon
Requires=gunicorn.socket
After=network.target

[Service]
User=user
Group=www-data
WorkingDirectory=/home/my_folder/activitypub_test
ExecStart=/home/my_folder/my_env/bin/gunicorn \
--access-logfile - \
--workers 3 \
--bind unix:/run/gunicorn.sock \
activitypub_test.wsgi:application

[Install]
WantedBy=multi-user.target

啓動 gunicorn:

1
sudo systemctl start gunicorn

Nginx 設定

建立該設定檔 /etc/nginx/sites-available/activitypub_test.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
server {
listen 80;
server_name 127.0.0.1;

# https://docs.djangoproject.com/en/dev/howto/static-files/#serving-static-files-in-production
# location /static { # STATIC_URL
# root /var/www/html/;
# }


location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Scheme $scheme;
proxy_pass http://127.0.0.1:8000;
}
}



建立設定檔連結給 nginx 使用,測試設定檔,然後重啓服務:

1
2
3
ln -s /etc/nginx/sites-available/activitypub_test.conf /etc/nginx/sites-enabled
nginx -t # 測試設定檔有沒有問題
sudo systemctl restart nginx # 重啓 nginx 服務

嘗試連到 127.0.0.1,應該會出現這個結果,代表正常運作:

設定防火牆:

1
2
sudo ufw delete allow 8000
sudo ufw allow 'Nginx Full'

浮動 IP 轉址(DDNS)設定

因為我們使用浮動 IP,另外 ActivityPub 協定需要域名,所以需要動態轉址。以下採用 No-IP 的服務設定。

假設創立新賬戶,申請到 activitypub-test.ddns.net 這個網址後,到No-ip 的用戶端下載頁 下載。

解壓縮之後,編譯解壓縮:

1
2
3
4
tar xvf noip-duc-linux.tar.gz
cd noip-2.1.9-1
make
make install

make install 這個階段裏,依指示輸入之後。就設定動態轉址完畢。

啓用 noip2 客戶端程式:

1
/usr/local/bin/noip2

修改 Nginx 設定檔的第 3 行server_name 127.0.0.1;server_name activitypub-test.ddns.net;,如下:

1
2
3
4
server {
listen 80;
server_name activitypub-test.ddns.net;
...

然後將 Nginx 重啓:

1
sudo nginx -s reload

到專案目錄裏面,開啓settings.py

1
2
cd ~/my_folder/activitypub_test/activitypub_test/
nano settings.py

ALLOWED_HOSTS = [] 這行添加我們新申請的域名,變成:ALLOWED_HOSTS = ['activitypub-test.ddns.net']
存檔然後離開。

最後重啓 gunicorn:

1
sudo systemctl restart gunicorn

連進去 activitypub-test.ddns.net 就應該可以看到 Django 測試頁面了。

設定 HTTPS 憑證等(用 Certbot)

安裝方法

1
2
3
sudo apt install snapd # 安裝 Snap 套件管理程式
sudo snap install --classic certbot # 安裝 certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot # 建立執行檔連結到 /usr/bin/

執行 certbot,取得 HTTPS 證書:

1
sudo certbot --nginx

會得到證書和私鑰的連結:

1
2
3
4
5
...
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/activitypub-test.ddns.net/fullchain.pem
Key is saved at: /etc/letsencrypt/live/activitypub-test.ddns.net/privkey.pem
...

/etc/nginx/sites-available/activitypub_test.conf 這個 nginx 也會被修改,增加 HTTPS 的功能。

產生公私鑰

因為 HTTP Signature 需要公私鑰,所以需要產生一組:

1
2
3
mkdir ~/my_folder/my_keys; cd ~/my_folder/my_keys # 建立新目錄並進入
openssl genrsa -out private.pem 2048 # 產生私鑰
openssl rsa -in private.pem -outform PEM -pubout -out public.pem # 產生公鑰

建構 ActivityPub 伺服器

  • 閱讀之前可以參考ActivityPub協定的介紹(英文)
  • 這裏我們假設一個用戶,id 叫 alice,我們可以這樣寫伺服器:

先匯入必要的函式庫、函數等

先進入~/my_folder/activitypub_test/專案頁面。
開啓 activitypub_test/views.py,匯入必要的函式庫:

1
2
3
4
5
6
7
8
9
10
 from django.http import HttpResponse # 回傳一般的 HTTP 的回應 (response)
from django.http import HttpResponseNotFound # 回傳 404 回應
from django.views.decorators.csrf import csrf_exempt #不檢查 csrf token
import json # 處理 json 用
import os.path # 處理路徑用

'''設定基本用戶 alice 的變數和域名'''
default_user_name = "alice" # id 名稱
default_shown_name = "Alice" # 顯示名稱
domain = "activitypub-test.ddns.net" # 域名

製作可以回傳用戶基本資料的 json

開啓 activitypub_test/urls.py,新增一個網頁規則:

1
2
3
4
5
6
7
8
9
from django.contrib import admin
from django.urls import path
from . import views

urlpatterns = [
path('admin/', admin.site.urls),
path('users/<username>', views.user_info), # 回傳用戶資料用

]

開啓activitypub_test/views.py,建立user_info函數,決定回傳的內容。如果 username 是 alice,回傳其個人資訊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 回傳使用者資料
def user_info(request, username):
# 使用者公鑰的檔案路徑,這裏使用本站 https 的公鑰
home_folder = os.path.expanduser("~")
public_key_path = os.path.join(home_folder, "my_folder/my_keys/public.pem")

public_key = open(public_key_path, 'r').read() # 輸入公鑰

'''若是輸入的 user_name 不是 default_user_name,回傳找不到。'''
if username != default_user_name:
return HttpResponseNotFound("the user name doesn't exist.")

else:
# 個人資料的 dict
user_info_dict = {
"@context": [
"https://www.w3.org/ns/activitystreams", # 應該是協定的部分
"https://w3id.org/security/v1",
],
"id": f"https://{domain}/users/{username}", # 指向使用者的頁面
"inbox": f"https://{domain}/users/{username}/inbox", # 收信匣
"outbox": f"https://{domain}/users/{username}/outbox", # 寄信匣
"type": "Person",
"name": default_shown_name, # 使用者顯示名稱
"preferredUsername": username, # 使用者的 id 名稱
# 公鑰部分:
"publicKey": {
"id": f"https://{domain}/users/{username}#main-key",
"id": f"https://{domain}/users/{username}",
"publicKeyPem": public_key
}
}

user_info_json = json.dumps(user_info_dict) # 轉成 json

# 將 json 轉成 response,然後設定 content-type,最後回傳
user_info_json_response = HttpResponse(user_info_json,
content_type = 'application/activity+json')
return user_info_json_response

建立 Webfinger 資訊

Webfinger 是辨識使用者或其他實體的方法,採用一組獨特的 URI。位址形式有點像是 email。

activitypub_test/urls.pyurlpatterns 改成:

1
2
3
4
urlpatterns = [
path('admin/', admin.site.urls),
path('users/<username>', views.user_info),
path('.well-known/webfinger', views.webfingr), # webfinger 用

開啓activitypub_test/views.py,建立webfinger函數,決定回傳的 webfinger 資訊。如果 username 是 alice,回傳其個人資訊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 傳遞 webfinger 資料
def webfinger(request):
webfinger = request.GET.get('resource') # 得到用 GET 方法傳來的 resource(位址)

'''將 'alice' 轉成 webfinger 位址
形式如 acct:alice@activitypub_test.ddns.org'''
account = "acct:" + default_user_name + "@" + domain

# 如果查詢的不是 alice 的位址,則回傳找不到
if webfinger != account:
return HttpResponseNotFound("can't find the user in the site.")

# 如果查詢的是 alice,回傳個人資料的 json
webfinger_dict = {
"subject": account,
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": f"https://{domain}/users/{default_user_name}"
}
]
}

# 回傳 json
return HttpResponse(json.dumps(webfinger_dict),
content_type = "application/jrd+json")

建立 inbox(收信匣)

雖然也可以建立 outbox,但是這裏爲求方便,僅說明建立 inbox,以接受其他人傳來的 json 訊息。

ctivitypub_test/urls.pyurlpatterns 改成:

1
2
3
4
5
6
urlpatterns = [
path('admin/', admin.site.urls),
path('users/<username>', views.user_info),
path('.well-known/webfinger', views.webfinger),
path('users/<username>/inbox', views.inbox), # 收信匣
]

開啓activitypub_test/views.py,建立ibox函數,當做收信匣。另外我們把收到的信息存在 inbox_log.txt 裏面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@csrf_exempt # 不要進行 csrf token 檢查
def inbox(request, username):
# 預設傳訊息的 inbox 使用 POST 方法
if request.method == 'POST':
log = open('inbox_log.txt', 'a') # 打開 inbox_log.txt 這個 log

# 如果不是 alice,回傳找不到
if username != default_user_name:
HttpResponseNotFound("can't get the inbox")

header = request.headers # request 的 header
data = request.body # request 的內容

# 將 request 的內容寫在 log 裏面,然後關掉:
text = f'message:\n{header} \n{data}\n'
log.write(text)
log.close()

# 最後回傳 OK
return HttpResponse("OK")
#其他情況回傳找不到
else:
return HttpResponseNotFound("can't get the inbox")

如何寄送跟隨請求?

可參後文

參考