去中心化笔记与 ActivityPub 联邦协议

探讨去中心化社交媒体的技术实现,深入 ActivityPub 联邦协议的工作原理,并以 Go 语言实现一个兼容 Mastodon 的微型博客节点。

中心化社交媒体平台的垄断地位引发了关于数据主权、算法透明度和平台治理的广泛讨论。以 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(发送消息的出口)。followersfollowing 是计数和集合信息的端点。

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 乃至自定义节点)能够互联互通,同时各自保留独特的功能和界面风格。这种「各自独立,互联互通」的联邦哲学,为后社交媒体时代的网络架构指明了一条可能的路径。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniblog」更多文章

  1. 内容审核与个性化推荐系统设计
  2. 轻社交媒体产品设计方法论
  3. 微型博客实时流与 WebSocket 技术