中心化社交媒体平台的垄断地位引发了关于数据主权、算法透明度和平台治理的广泛讨论。以 Mastodon 为代表的联邦式社交媒体通过 ActivityPub 协议实现了节点间的互联互通,在去中心化与用户体验之间找到了一条可行的中间道路。本文将深入 ActivityPub 协议的核心机制,并展示如何用 Go 语言实现一个兼容联邦网络的微型博客节点。
一、去中心化社交的架构选择
1.1 三种去中心化模型
| 模型 | 代表项目 | 特点 | 缺点 |
|---|---|---|---|
| 联邦式 (Federation) | Mastodon, Pleroma, Misskey | 多独立服务器互联,用户选择节点 | 节点运营者权力较大 |
| 点对点 (P2P) | Scuttlebutt, Secure Scuttlebutt | 无服务器,直接设备间通信 | 设备离线时无法收发消息 |
| 区块链式 (Blockchain) | Steemit, Lens | 内容存储于链上,不可篡改 | 性能差,交易费用高 |
联邦式(Federation)是目前平衡最好、生态最成熟的方案。它允许任何人部署服务器实例,用户注册到信任的实例上,不同实例之间通过标准协议互通。
1.2 联邦网络的核心概念
在联邦社交网络中:
- 实例 (Instance):独立运营的服务器,如 mastodon.social、pleroma.example.com
- 用户身份:
@username@domain格式,如@alice@example.com - 联邦:实例之间通过标准协议(ActivityPub)交换内容
- 本地内容:存储在本实例的用户数据和互动
- 远程内容:通过联邦获取的其他实例用户的内容
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Instance A │◄────────────►│ Instance B │◄────────────►│ Instance C │
│ ( mastodon │ ActivityPub │ ( pleroma │ ActivityPub │ ( custom │
│ .social ) │ │ .io ) │ │ node ) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 用户 @alice@mastodon │ 用户 @bob@pleroma │ 用户 @carol@custom
│ 关注 @bob@pleroma │ 关注 @alice@mastodon │ 关注 @alice@mastodon
二、ActivityPub 协议详解
ActivityPub 是 W3C 推荐标准,基于 ActivityStreams 2.0 格式,定义了社交应用之间交换内容的协议。它包含两个核心子协议:
- ActivityPub Client-to-Server (C2S):客户端与本服务器通信
- ActivityPub Server-to-Server (S2S):服务器之间联邦通信
2.1 核心对象
ActivityPub 中的内容以 JSON-LD 格式表示,核心对象类型包括:
| 对象类型 | 说明 | 示例 |
|---|---|---|
Actor | 参与者(用户、团体、应用) | Person, Organization |
Object | 内容实体 | Note, Article, Image |
Activity | 动作 | Create, Follow, Like, Announce |
Collection | 集合 | inbox, outbox, followers, following |
2.2 Actor 对象
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice Chen",
"summary": "Go 开发者,微型博客爱好者",
"url": "https://example.com/@alice",
"inbox": "https://example.com/users/alice/inbox",
"outbox": "https://example.com/users/alice/outbox",
"followers": "https://example.com/users/alice/followers",
"following": "https://example.com/users/alice/following",
"publicKey": {
"id": "https://example.com/users/alice#main-key",
"owner": "https://example.com/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----"
}
}
每个 Actor 必须有 inbox(接收消息的入口)和 outbox(发送消息的出口)。followers 和 following 是计数和集合信息的端点。
2.3 Activity 对象
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/activities/123",
"type": "Create",
"actor": "https://example.com/users/alice",
"published": "2024-09-22T10:00:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"id": "https://example.com/posts/456",
"type": "Note",
"attributedTo": "https://example.com/users/alice",
"content": "<p>这是一篇联邦网络的短文!</p>",
"published": "2024-09-22T10:00:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/users/alice/followers"]
}
}
Create Activity 表示创建了一条 Note(短文)。to 定义了直接接收者,cc 定义了抄送对象。Public 表示公开内容。
2.4 HTTP 签名机制
服务器间通信必须通过 HTTP 签名(HTTP Signatures / Cavage Signatures)验证身份:
POST /users/bob/inbox HTTP/1.1
Host: pleroma.io
Content-Type: application/activity+json
Date: Sun, 22 Sep 2024 10:00:00 GMT
Signature: keyId="https://example.com/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="Base64(SHA256(signing_string))"
Digest: SHA-256=Base64(SHA256(body))
{ ... Activity JSON ... }
接收方通过 keyId 获取发起方的公钥,验证签名有效性,从而确认请求确实来自声称的服务器。
三、Go 实现联邦节点
3.1 Actor 路由
package fed
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
// ActorHandler 处理 ActivityPub Actor 相关请求
func (s *FederationServer) ActorHandler(c *gin.Context) {
username := c.Param("username")
user, err := s.userService.GetByUsername(c.Request.Context(), username)
if err != nil {
c.AbortWithStatus(404)
return
}
actor := map[string]interface{}{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": fmt.Sprintf("%s/users/%s", s.baseURL, username),
"type": "Person",
"preferredUsername": user.Username,
"name": user.DisplayName,
"summary": user.Bio,
"url": fmt.Sprintf("%s/@%s", s.baseURL, username),
"inbox": fmt.Sprintf("%s/users/%s/inbox", s.baseURL, username),
"outbox": fmt.Sprintf("%s/users/%s/outbox", s.baseURL, username),
"followers": fmt.Sprintf("%s/users/%s/followers", s.baseURL, username),
"following": fmt.Sprintf("%s/users/%s/following", s.baseURL, username),
"publicKey": map[string]string{
"id": fmt.Sprintf("%s/users/%s#main-key", s.baseURL, username),
"owner": fmt.Sprintf("%s/users/%s", s.baseURL, username),
"publicKeyPem": user.PublicKeyPEM,
},
}
c.Header("Content-Type", "application/activity+json")
c.JSON(200, actor)
}
3.2 Inbox 处理器
// InboxHandler 处理接收到的联邦消息
func (s *FederationServer) InboxHandler(c *gin.Context) {
username := c.Param("username")
// 1. 验证 HTTP 签名
if err := s.verifySignature(c.Request); err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid signature"})
return
}
// 2. 解析 Activity
var activity Activity
if err := c.ShouldBindJSON(&activity); err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": "invalid activity"})
return
}
// 3. 根据 Activity 类型分发处理
switch activity.Type {
case "Create":
s.handleCreateActivity(c.Request.Context(), username, activity)
case "Follow":
s.handleFollowActivity(c.Request.Context(), username, activity)
case "Like", "Announce":
s.handleInteractionActivity(c.Request.Context(), username, activity)
case "Delete":
s.handleDeleteActivity(c.Request.Context(), username, activity)
case "Undo":
s.handleUndoActivity(c.Request.Context(), username, activity)
default:
// 未知类型,记录日志但返回成功
fmt.Printf("unknown activity type: %s\n", activity.Type)
}
c.Status(202) // ActivityPub 推荐返回 202 Accepted
}
func (s *FederationServer) handleCreateActivity(ctx context.Context, username string, activity Activity) {
// 获取创建的对象
obj, err := s.resolveObject(ctx, activity.Object)
if err != nil {
fmt.Printf("resolve object failed: %v\n", err)
return
}
// 保存远程帖子到本地数据库
remotePost := &domain.RemotePost{
ID: obj.ID,
ActorID: activity.Actor,
Type: obj.Type,
Content: obj.Content,
PublishedAt: obj.Published,
To: obj.To,
CC: obj.CC,
FetchedAt: time.Now().UTC(),
}
if err := s.postService.SaveRemotePost(ctx, remotePost); err != nil {
fmt.Printf("save remote post failed: %v\n", err)
}
}
func (s *FederationServer) handleFollowActivity(ctx context.Context, username string, activity Activity) {
// 有人关注了本实例的用户
followerID := activity.Actor
followingUser, err := s.userService.GetByUsername(ctx, username)
if err != nil {
return
}
// 保存关注关系
if err := s.userService.AddFollower(ctx, followingUser.ID, followerID); err != nil {
fmt.Printf("add follower failed: %v\n", err)
return
}
// 发送 Accept Activity 响应
accept := Activity{
Context: "https://www.w3.org/ns/activitystreams",
ID: fmt.Sprintf("%s/accepts/%s", s.baseURL, generateID()),
Type: "Accept",
Actor: fmt.Sprintf("%s/users/%s", s.baseURL, username),
Object: activity.ID,
}
// 异步发送回对方的 inbox
go s.deliverActivity(followerID, accept)
}
3.3 HTTP 签名实现
package crypto
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"strings"
"time"
)
type HTTPSigner struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
keyID string
}
func NewHTTPSigner(privateKeyPEM, keyID string) (*HTTPSigner, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
// 尝试 PKCS8
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
privateKey = key.(*rsa.PrivateKey)
}
return &HTTPSigner{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
keyID: keyID,
}, nil
}
func (s *HTTPSigner) Sign(req *http.Request, body []byte) error {
// 计算 Digest
digest := sha256.Sum256(body)
digestB64 := base64.StdEncoding.EncodeToString(digest[:])
req.Header.Set("Digest", fmt.Sprintf("SHA-256=%s", digestB64))
// 生成签名字符串
signingParts := []string{
fmt.Sprintf("(request-target): %s %s", strings.ToLower(req.Method), req.URL.RequestURI()),
fmt.Sprintf("host: %s", req.Host),
fmt.Sprintf("date: %s", req.Header.Get("Date")),
fmt.Sprintf("digest: %s", req.Header.Get("Digest")),
}
signingString := strings.Join(signingParts, "\n")
// RSA 签名
hash := sha256.Sum256([]byte(signingString))
signature, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, hash[:])
if err != nil {
return err
}
// 编码签名头
sigHeader := fmt.Sprintf(
`keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"`,
s.keyID,
base64.StdEncoding.EncodeToString(signature),
)
req.Header.Set("Signature", sigHeader)
return nil
}
func VerifySignature(req *http.Request, publicKeyPEM string) error {
// 解析公钥
block, _ := pem.Decode([]byte(publicKeyPEM))
if block == nil {
return fmt.Errorf("failed to decode public key PEM")
}
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err
}
rsaPublicKey := publicKey.(*rsa.PublicKey)
// 解析 Signature 头
sigHeader := req.Header.Get("Signature")
// ... 解析 keyId, algorithm, headers, signature
// 重构签名字符串
// ... 根据 headers 列表重构
// 验证签名
// ...
return nil
}
四、联邦内容发现
4.1 WebFinger 协议
WebFinger 是发现联邦网络用户 Actor URL 的标准协议:
GET /.well-known/webfinger?resource=acct:alice@example.com HTTP/1.1
Host: example.com
HTTP/1.1 200 OK
Content-Type: application/jrd+json
{
"subject": "acct:alice@example.com",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/users/alice"
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://example.com/@alice"
}
]
}
func (s *FederationServer) WebFingerHandler(c *gin.Context) {
resource := c.Query("resource")
// 解析 acct:username@domain
var username string
fmt.Sscanf(resource, "acct:%s", &username)
username = strings.Split(username, "@")[0]
c.JSON(200, map[string]interface{}{
"subject": resource,
"aliases": []string{
fmt.Sprintf("%s/@%s", s.baseURL, username),
fmt.Sprintf("%s/users/%s", s.baseURL, username),
},
"links": []map[string]string{
{
"rel": "self",
"type": "application/activity+json",
"href": fmt.Sprintf("%s/users/%s", s.baseURL, username),
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": fmt.Sprintf("%s/@%s", s.baseURL, username),
},
},
})
}
4.2 内容获取与缓存
func (s *FederationServer) resolveObject(ctx context.Context, objectID string) (*Object, error) {
// 1. 检查本地缓存
if cached := s.cache.GetObject(objectID); cached != nil {
return cached, nil
}
// 2. 发送签名的 GET 请求
req, _ := http.NewRequestWithContext(ctx, "GET", objectID, nil)
req.Header.Set("Accept", "application/activity+json")
if err := s.signer.Sign(req, nil); err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("remote returned %d", resp.StatusCode)
}
var obj Object
if err := json.NewDecoder(resp.Body).Decode(&obj); err != nil {
return nil, err
}
// 3. 缓存结果
s.cache.SetObject(objectID, &obj, 5*time.Minute)
return &obj, nil
}
五、联邦带来的新挑战
5.1 内容审核的分布式困境
联邦网络中,每个实例有独立的内容政策。当 A 实例的用户发布违规内容并被联邦到 B 实例时,B 实例的审核决策不会传回 A 实例。
常见的应对策略:
- 实例级别屏蔽:B 实例的 moderation 团队可以屏蔽 A 实例或特定用户的内容
- 标签传播 (Content Warning):A 实例在内容中添加 CW 标签,联邦过程中保留
- 转发限制:实例可选择不联邦特定类型(如未标记的敏感内容)
5.2 Spam 与滥用防护
联邦网络的开放性使其更易受到 Spam 攻击。防护手段包括:
- 签名验证:拒绝无法验证签名的 Activity
- 实例信誉系统:基于历史行为建立实例信誉评分
- 速率限制:对联邦消息进行 IP + 实例级别限流
- 人工审核队列:新实例的内容先进入审核队列
5.3 数据一致性
联邦网络没有全局一致性保证。用户删除内容时,Delete Activity 被发送到所有已联邦的实例,但无法保证所有实例都会执行删除。
六、总结
去中心化社交媒体通过 ActivityPub 协议在独立服务器之间建立了内容和互动的交换通道。联邦式架构在保留了中心化平台良好用户体验的同时,赋予了用户选择服务器和控制数据的能力。
实现一个兼容 ActivityPub 的联邦节点并不复杂:提供标准的 Actor/Inbox/Outbox 端点,实现 HTTP 签名验证,正确处理常见的 Activity 类型。真正困难的是运营层面的问题——内容审核、Spam 防护、实例治理和用户迁移。这些社会技术问题没有纯技术解,需要社区规范和工具的共同演进。
ActivityPub 的伟大之处在于它定义了一个足够灵活的标准,让不同的实现( Mastodon、Pleroma、Misskey 乃至自定义节点)能够互联互通,同时各自保留独特的功能和界面风格。这种「各自独立,互联互通」的联邦哲学,为后社交媒体时代的网络架构指明了一条可能的路径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。