Documentation
¶
Overview ¶
delivery.go — 跨站投递(联邦前台化,2026-11-01)。
形态(设计稿:docs/specs/2026-11-01-federation-frontend-delivery-design.md):
访客 ──① 在发起柜(A 柜)前台填「对方柜 URL」+ 选文件
A 柜 ──② 校验目标 ∈ 管理员登记的「可投递对端」
A 柜 ──③ 带共享口令调对方柜(B 柜)铸投递槽位
A 柜 ──④ 把文件**流式**转发到 B 柜的访客投递端点
B 柜落库 ⇒ **B 柜的 share 域铸 6 位取件码**
A 柜 ──⑤ 把 B 的 6 位码回给访客(访客转告取件人)
取件人 ──⑥ 在 **B 柜** 用该 6 位码取件
三条不变性(改代码前先读):
- **码由对方柜铸**:A 柜只透传 B 柜返回的 6 位码 ⇒ 码空间天然是对方的 (取件人站在对方柜取件)⇒ 不需要跨柜共享码空间。
- **出站 URL 只来自 admin 配置**(conf.FederationConfig.DeliveryPeers), 不是访客任意输入 ⇒ SSRF 面从「任意 URL」收窄到「admin 配的 URL」; 私网对端按**对端粒度**放行(allow_private),不动全局 SSRF 开关。
- **口令即权限**(同现有联邦的「口令 bearer」信任级):比对用常数时间 比较,防时序侧信道。
归属:本文件属联邦域(跨实例协作),出站面与铸槽位面都在这里; 访客投递的重管道(multipart 解析)留在 transport/http/handler 层。
directory.go — 联邦节点名录透传(2026-10-14 邀请信箱配套;2026-10-22 由 直传包搬入)。
名录端点无鉴权(零敏感,公开信息),registry URL 由宿主从管理端 SystemConfig 注入(已过 SSRF 校验)。直传未启用/二进制缺失不挡名录查询—— 名录可见性只取决于 registry 可达。
归属:名录是对端站点列表,属联邦域,不属直传域——本文件不依赖 p2pc 运行时。
命名(2026-10-22 L3):PeerDirectory = 联邦节点名录,**无条件**构造 (可见性只取决于 registry 可达,与联邦运行时是否启用无关;见 invite.go 文件头的三类服务命名约定)。
Package federation 实现 PigeonBox 的 P2P 联邦面(跨实例口令路由 + 邀请交接)。
三个角色与构造条件(2026-10-22 命名统一) ¶
本包导出三个服务,**构造条件不同**,这是设计使然、不是遗漏:
Node 本机节点运行时:节点身份(ed25519)、注册中心心跳、分享公告。
仅在 federation.enabled 时构造;配置不合法时 bootstrap 降级
为非联邦模式(单站功能不受影响)。
PeerDirectory 在线名录:列出联邦内可发现的对端。**无条件构造**——联邦关闭时
名录仍要能读出「为什么不可用」,且注册中心地址来自配置而非运行态。
InviteMailbox 邀请信箱:待收邀请的出箱/留痕。**无条件构造**——收件箱在联邦
关闭时也必须可用(邀请可以先到、联邦后开)。
与设备直传的关系 ¶
2026-10-22 Wave B' 起,Box 侧**不再编排设备直传**(原 p2px 域已整包删除), 直传本体归独立应用 PigeonBeam。本包只保留联邦面(口令路由 + 邀请信封交接)。
Package federation 将本站点接入 PigeonBox P2P 联邦(github.com/pigeonbox/p2p)。
职责(M2,设计文档 docs/specs/2026-10-04-p2p-registry-service-design.md §5):
- 节点注册/心跳:Ed25519 签名租约,断线自动重注册 + 全量补公告(自愈);
- 口令联邦路由:share 生命周期钩子公告/撤销 SHA-256(code)→本节点;
- 解析代理:GET /api/v1/federation/resolve 转查 registry(浏览器免跨域)。
信任模型:本包只做"发现",取件方直连源节点、源节点本地校验口令——零跨节点信任。 签名负载格式与 p2p 仓 README 契约逐字节一致(见 client.go 常量注释)。 依赖方向:本包不 import 生态任何兄弟模块(叶子约束,core CI dep guard)。
本包三类服务按**构造条件**分家(2026-10-22 L3 命名收口,读者一眼可辨):
Service 联邦运行时(注册/心跳/公告/解析)——**仅联邦启用时**构造
(NewService 在 !cfg.Enabled 时报错,由装配层降级为非联邦模式)。
InviteMailbox 联邦邀请收件箱——**无条件**构造:跨实例交接的凭证与状态机,
与联邦运行时无关(联邦关闭时公开收件端点仍可用)。
PeerDirectory 联邦节点名录——**无条件**构造:可见性只取决于 registry 可达。
后两者的实例由装配层(bootstrap/wiring.go)构造,直传编排层只消费 (setter 注入)——删掉直传编排层时联邦能力不受影响(Wave B')。
invite.go — 联邦信封管理(跨实例推送邀请;2026-10-22 由直传包搬入)。
语义:邀请 = 口令 bearer(与手工交接同信任级——口令本身即授权,通道只是 递送);收件箱内存态(cap 20 / TTL 10 分钟 = p2pc 配对窗口,进程重启丢弃 可接受,口令同样已失效);出站推送目标 URL 走 SSRF 守卫(与 registry_url 同策略,私网目标需 security.ssrf.allow_private_networks)。
归属:信封是跨实例交接的凭证与状态机,属联邦域,不属直传域——本文件不 依赖 p2pc 运行时(无 spawn / 子进程 / 任务环),Wave B 删直传包时 本包原样留存。
命名(2026-10-22 L3):本包三类服务按"构造条件"分家,读者一眼可辨——
- Service:联邦运行时(注册/心跳/公告),**仅联邦启用时**构造;
- InviteMailbox:联邦邀请收件箱,**无条件**构造(联邦关闭时仍可用, 它是跨实例交接的凭证与状态机,与联邦运行时无关);
- PeerDirectory:联邦节点名录,**无条件**构造(可见性只取决于 registry 可达,不取决于联邦是否启用)。
sync.go — 联邦邀请的定时同步(core#32)。
背景:联邦的「邀请推送」此前**只有手动触发**(POST /admin/p2p/invite-push ⇒ 实际 POST 对方站 /api/v1/p2p/invite),core/app/federation/ 下零调度面。 本文件补上**周期自动同步**面:定时把待推的邀请送给已配置的对端,并把 对端推来的邀请收进待收列表。
## 与既有语义一致(issue 硬要求:定时是补充不是替代)
手动推送走的是同一条链路(PushInvite → 对方站 POST /api/v1/p2p/invite), 本调度器只是**周期地**驱动同一份逻辑 ⇒ 手动端点保留且行为不变。
## 幂等(issue 硬要求:重启后不重复推送)
两道幂等闸:
- 收侧:InviteMailbox.AddInvite 对同 code 去重(收件箱既有语义)。
- 发侧:Sync 器自身维护「已推送过的 code」集合(内存态)⇒ 同一进程内 周期重跑不重复推送;进程重启后集合丢失,但收侧闸仍生效(对方站 收件箱同码去重)⇒ 端到端不重复。
## 前置事实(2026-11-01 核代码,纠正 issue 原文两处前提)
① issue 称「core/app/federation/ 下零 cron / Ticker」——**不成立**:
federation.go 的 Node.loop 已有 30min 心跳 ticker(注册续租 + 全量 重公告)。本文件加的是**邀请同步**调度面,与心跳面职责不同(心跳管 registry 租约,同步管邀请递送),不重复造轮子。
② issue 称「定时任务必须只在 admin 副本跑(否则 N 个副本重复推送)」——
**前提不成立**:conf.FederationAllowedInMode 规定联邦**只在 standalone 模式可用**(多副本下节点身份是进程级 Ed25519,语义未定义)⇒ 多副本 部署里联邦运行时根本不启动,不存在 N 副本重复推送。 但「只在 admin 面跑」仍是本调度器**必须**满足的约束(与仓内其它 后台任务同一道门):public 副本不跑 ⇒ 也不依赖上面的架构性禁用。
## 依赖方向
本包不 import 生态任何兄弟模块(叶子约束,core CI dep guard)。
Index ¶
- Constants
- func DeliveryPeers() []conf.FederationDeliveryPeer
- func MatchDeliveryPeer(rawURL string) (conf.FederationDeliveryPeer, bool)
- func NormalizePeerURL(rawURL string) (string, error)
- func PushInvite(ctx context.Context, targetURL, code, fromName, note string) error
- func VerifyDeliveryToken(got string) bool
- type DeliveryClient
- type DeliveryFile
- type DeliveryResult
- type DirectoryPeer
- type Invite
- type InviteMailbox
- type InviteSnapshot
- type Node
- func (s *Node) Enabled() bool
- func (s *Node) Healthy() bool
- func (s *Node) NodeID() string
- func (s *Node) RegistryURLs() []string
- func (s *Node) Resolve(code string) (*ResolveInfo, error)
- func (s *Node) ShareCreated(code string, expiresAt *time.Time)
- func (s *Node) ShareDeleted(code string)
- func (s *Node) Start()
- func (s *Node) Stop()
- type PeerDirectory
- type PushFunc
- type RegistryURLProvider
- type ResolveInfo
- type SyncConfig
- type Syncer
Constants ¶
const ( // DefaultSyncInterval 缺省同步周期(10min)。 // // 取值理由:邀请信封 TTL 是 10min(inviteTTL)⇒ 周期与 TTL 同量级时, // 一个周期内推送的邀请在对端还有效;再长则对端收件箱已过期,重复推送 // 无意义(白跑)。 DefaultSyncInterval = 10 * time.Minute )
const (
// DeliveryTokenHeader 共享口令 bearer 的请求头名(A 柜 → B 柜)。
DeliveryTokenHeader = "X-Federation-Token"
)
Variables ¶
This section is empty.
Functions ¶
func DeliveryPeers ¶ added in v0.32.44
func DeliveryPeers() []conf.FederationDeliveryPeer
DeliveryPeers 当前生效的可投递对端(conf 快照读)。
读 conf 快照(copy-on-write 原子换指针)⇒ 配置热应用(管理台保存)后 立即生效,且不会读到撕裂的字段组合。
func MatchDeliveryPeer ¶ added in v0.32.44
func MatchDeliveryPeer(rawURL string) (conf.FederationDeliveryPeer, bool)
MatchDeliveryPeer 按归一化后的基址匹配已登记的对端。
⚠️ **必须命中**才允许投递(未命中 ⇒ 明确拒绝且**不发任何出站请求**): 访客填一个未登记的 URL 时,A 柜当场拒,而不是「发出去后发现错」。
func NormalizePeerURL ¶ added in v0.32.44
NormalizePeerURL 归一化对端基址(scheme/host 小写、去默认端口、去尾斜杠、 去 query/fragment)。
归一化的意义:访客在前台填的 URL 与管理员登记的 URL 只要指向同一个基址 就该命中("http://B:12345/" ≡ "http://b:12345")⇒ 否则访客会因为多打一个 斜杠而投递失败。
⚠️ 不接受带 userinfo(http://user:pass@host/)的基址:凭证不该出现在 「对方柜地址」里,且它会让 URL 形态判据变得含糊。
func PushInvite ¶ added in v0.27.0
PushInvite 把口令推送到目标实例的邀请收件端点(P3 免口令交接)。 targetURL = 目标实例基址(如 https://b.example.com); 实际 POST <target>/api/v1/p2p/invite,10s 超时。
func VerifyDeliveryToken ¶ added in v0.32.44
VerifyDeliveryToken 比对共享口令(常数时间)。
语义:**任一**登记对端的口令匹配即通过(不要求知道调用方是谁——口令即 权限,与现有联邦的「口令 bearer」信任级一致;双向登记的前提由 「A 只投给登记过的 B」保证)。
⚠️ 用 subtle.ConstantTimeCompare(常数时间)而不是 ==:普通字符串比较 会因首个不同字节的位置不同而耗时不同 ⇒ 可被时序侧信道逐字节试探口令。 循环里**不提前 break**(命中后继续比完),避免「命中位置」也变成时序信号。
Types ¶
type DeliveryClient ¶ added in v0.32.44
DeliveryClient 跨站投递出站客户端(无条件构造;与联邦是否启用无关—— 投递面是「柜子之间」的通道,不依赖联邦运行时)。
func NewDeliveryClient ¶ added in v0.32.44
func NewDeliveryClient() *DeliveryClient
NewDeliveryClient 构造出站客户端。
⚠️ **不跟随重定向**:对端回一个 302 到内网地址就能借本站的出站面绕过 SSRF 判定(出站 URL 虽受控,但重定向目标不受控)⇒ 3xx 一律当失败。
func (*DeliveryClient) CreateSlot ¶ added in v0.32.44
func (c *DeliveryClient) CreateSlot(ctx context.Context, peer conf.FederationDeliveryPeer, fromName, note, title string) (string, error)
CreateSlot 向对端铸一个访客投递槽位(口令 bearer 授权)。
返回对端铸的**寄件码 token**(投递链接令牌;不是 6 位取件码——此时还没有 文件,对方柜不可能铸出取件码)。
func (*DeliveryClient) ForwardUpload ¶ added in v0.32.44
func (c *DeliveryClient) ForwardUpload(ctx context.Context, peer conf.FederationDeliveryPeer, token string, files []DeliveryFile) (*DeliveryResult, error)
ForwardUpload 把文件**流式**转发到对端的访客投递端点。
⚠️ **不得整文件读进内存**:写侧 goroutine 用 io.Pipe + multipart.Writer + io.Copy 把文件内容一段段推进管道,请求体因此是 **chunked**(长度未知) ⇒ 内存占用与文件大小无关(A 柜只是中转,不落盘、不缓冲)。
返回对方柜铸的 6 位取件码(A 柜只透传,不铸码)。
type DeliveryFile ¶ added in v0.32.44
type DeliveryFile struct {
// Name 原始文件名(转发时作为 multipart 的 filename)。
Name string
// Size 申报大小(字节;仅用于上限预检,不参与内容判断)。
Size int64
// Open 打开内容读取器(调用方负责 Close)。
Open func() (io.ReadCloser, error)
}
DeliveryFile 待转发的一个文件。
Open 惰性打开(转发时才打开文件句柄)⇒ 调用方可以在**解析完表单之后** 再决定是否转发(未登记对端 ⇒ 一个出站请求都不发)。
type DeliveryResult ¶ added in v0.32.44
type DeliveryResult struct {
// FileCount 对方柜落盘的文件数。
FileCount int
// PickupCode 对方柜铸的 6 位取件码(**在对方柜使用**)。
PickupCode string
}
DeliveryResult 转发结果(对方柜铸的取件码)。
type DirectoryPeer ¶ added in v0.27.0
type DirectoryPeer struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
URL string `json:"url"` // 对外基址(NAT 后可为空)
BoxPub string `json:"box_pub"` // X25519 密封公钥 hex
OnlineSince int64 `json:"online_since"` // 租约起始 unix 秒
}
DirectoryPeer 名录对端(与 p2pd GET /v1/directory 条目同形)。
type InviteMailbox ¶ added in v0.27.0
type InviteMailbox struct {
// contains filtered or unexported fields
}
InviteMailbox 联邦信封收件箱(内存态;不依赖 p2pc 运行时)。
无条件构造(联邦关闭时仍可用——收件箱是跨实例交接的凭证与状态机,不是 联邦运行时的附属物;见文件头命名约定)。
clock 为构造期注入的时钟(nil=time.Now);构造后不再变更,故读路径无需 加锁(与搬迁前直传包的 nowFn 语义一致)。
func NewInviteMailbox ¶ added in v0.27.0
func NewInviteMailbox(clock func() time.Time) *InviteMailbox
NewInviteMailbox 构造收件箱(clock 可注入;nil=真实时钟)。
func (*InviteMailbox) AddInvite ¶ added in v0.27.0
func (m *InviteMailbox) AddInvite(code, fromName, note string) (Invite, error)
AddInvite 邀请入箱(口令格式校验 + 同码去重 + 满箱拒绝)。
func (*InviteMailbox) ExpiresIn ¶ added in v0.27.0
func (m *InviteMailbox) ExpiresIn(inv Invite) int64
ExpiresIn 某条信封的剩余有效期(秒;已过期返回 0)。
供**已出箱**信封的展示方使用(认领端点返回信封时算剩余时间)—— 收件箱只对在箱条目给快照,出箱后条目已不在列表里,无处可查。 时钟同收件箱(构造期注入的时钟优先,测试用假时钟一致)。
func (*InviteMailbox) ListInvites ¶ added in v0.27.0
func (m *InviteMailbox) ListInvites() []InviteSnapshot
ListInvites 未过期邀请(新到在前)。
func (*InviteMailbox) TakeInvite ¶ added in v0.27.0
func (m *InviteMailbox) TakeInvite(id string) (Invite, error)
TakeInvite 认领(出箱);不存在/已过期报错。
type InviteSnapshot ¶ added in v0.27.0
type InviteSnapshot struct {
ID string `json:"id"`
Code string `json:"code"`
FromName string `json:"from_name"`
Note string `json:"note"`
ReceivedAt time.Time `json:"received_at"`
ExpiresIn int64 `json:"expires_in"` // 秒
}
InviteSnapshot 对外快照(含过期倒计时,前端渲染用)。
type Node ¶ added in v0.27.1
type Node struct {
// contains filtered or unexported fields
}
Node 本机联邦节点运行时:节点身份(ed25519)+ 注册中心心跳 + 分享公告。 实现 share.FederationNotifier 窄接口(ShareCreated/ShareDeleted), 由 bootstrap 注入 share service。
命名(2026-10-22):原名 Service 太含糊——同包另有两个无条件构造的 角色(PeerDirectory 在线名录 / InviteMailbox 邀请信箱),「哪个是 federation 的主体」看不出来。改 Node 后三个角色一目了然:
Node 本机节点运行时(**联邦启用时才构造**) PeerDirectory 在线名录(无条件构造——联邦关闭时名录仍要能读配置) InviteMailbox 邀请信箱(无条件构造——收件箱在联邦关闭时也必须可用)
func NewNode ¶ added in v0.27.1
func NewNode(cfg conf.FederationConfig, nodeName string) (*Node, error)
NewNode 构造并加载/生成节点身份密钥。仅在 federation.enabled 时调用; 配置不合法返回错误,由 bootstrap 降级为非联邦模式(不影响单站功能)。
func (*Node) Healthy ¶ added in v0.27.1
Healthy 最近一次 registry 心跳是否成功(未启动/尚未跑首个周期时为 false)。 供管理面(MCP federation_status 等)展示联邦健康度。
func (*Node) RegistryURLs ¶ added in v0.27.1
RegistryURLs 主备 registry 基址列表(配置逗号分隔项的副本)。
func (*Node) Resolve ¶ added in v0.27.1
func (s *Node) Resolve(code string) (*ResolveInfo, error)
Resolve 转查 registry(读路径依次主→备,首个命中即返回)。 全部未接入返回 (nil, nil)——调用方(resolve 代理 handler)以 available:false 回给前端,不区分"未启用/不存在"(降探测面)。
func (*Node) ShareCreated ¶ added in v0.27.1
ShareCreated share 生命周期钩子:新分享公告到联邦(异步,registry 抖动 不拖慢建分享;失败由心跳循环补发)。熵门槛不过则静默跳过——短数字码 不出站,防 registry 侧在线枚举(安全模型见 p2p README)。
func (*Node) ShareDeleted ¶ added in v0.27.1
ShareDeleted share 生命周期钩子:撤销公告(best-effort,索引立即移除; 撤销失败时 registry 条目按公告 TTL 自动过期)。
type PeerDirectory ¶ added in v0.27.0
type PeerDirectory struct {
// contains filtered or unexported fields
}
PeerDirectory 联邦节点名录客户端。
func NewPeerDirectory ¶ added in v0.27.0
func NewPeerDirectory(provider RegistryURLProvider) *PeerDirectory
NewPeerDirectory 构造名录客户端(provider 由宿主注入;须非 nil)。
func (*PeerDirectory) Peers ¶ added in v0.27.0
func (d *PeerDirectory) Peers(ctx context.Context) ([]DirectoryPeer, error)
Peers 拉取在线名录(admin 目录对端选择器消费)。 直传未启用/二进制缺失不挡名录查询——名录可见性只取决于 registry 可达。
type RegistryURLProvider ¶ added in v0.27.0
RegistryURLProvider 注册中心基址提供者;ok=false 表示该来源不提供值 (段不存在 / 值为空)。
由宿主注入(配置读取面在本包之外——app 域不得互 import,配置读取在 bootstrap 装配层),本包只认"基址字符串"。
type ResolveInfo ¶
type ResolveInfo struct {
NodeID string `json:"node_id"`
URL string `json:"url"`
Name string `json:"name"`
ExpiresAt time.Time `json:"expires_at"`
SizeHint int64 `json:"size_hint"`
}
ResolveInfo 解析结果:口令在联邦内的源节点。
type SyncConfig ¶ added in v0.32.45
type SyncConfig struct {
// Enabled 是否启用定时同步(默认 false ⇒ 零行为,与升级前一致)。
Enabled bool `mapstructure:"enabled" json:"enabled"`
// IntervalSeconds 同步周期(秒)。0 = 缺省 DefaultSyncInterval;
// 小于 60 按 60 夹(minSyncInterval)。
IntervalSeconds int `mapstructure:"interval_seconds" json:"interval_seconds"`
// Targets 已配置的对端站点(每项一个基址,如 https://box.example.com)。
//
// 出站 URL **只来自本配置**(管理员显式登记),不是访客/调用方任意输入
// ⇒ SSRF 面从「任意 URL」收窄到「admin 配的 URL」(与 core#29 跨站投递
// 的信任模型同口径)。每个目标发请求前仍复跑 SSRF 校验。
Targets []string `mapstructure:"targets" json:"targets"`
// Codes 待推送的邀请口令(12 位字符 + 2 连字符,XXXX-XXXX-XXXX)。
//
// 语义:这些口令是**本站已创建、要递给对端**的取件码。调度器周期把它们
// 推给 Targets 里的每个对端 ⇒ 对端访客用该码到对端柜取件。
//
// ⚠️ 熵门槛:联邦公告对低熵口令(6 位数字)是禁止出站的(见
// announceAllowed)——但那是**registry 公告**面的约束;这里是把口令交给
// **管理员显式登记的对端站**(口令 bearer 即授权,信任级同手工交接)。
// 目标站地址由管理员配置 ⇒ 不是把口令散给任意第三方。
Codes []string `mapstructure:"codes" json:"codes"`
}
SyncConfig 联邦邀请定时同步配置(落在 SystemConfig 的 p2p 段)。
func (SyncConfig) Interval ¶ added in v0.32.45
func (c SyncConfig) Interval() time.Duration
Interval 实际同步周期(配置归一:0⇒缺省,<下限⇒夹到下限)。
func (SyncConfig) NormalizedCodes ¶ added in v0.32.45
func (c SyncConfig) NormalizedCodes() []string
NormalizedCodes 归一后的口令列表(去空白、去重、保序)。
func (SyncConfig) NormalizedTargets ¶ added in v0.32.45
func (c SyncConfig) NormalizedTargets() []string
NormalizedTargets 归一后的目标基址列表(去空白、去尾斜杠、去重、保序)。
type Syncer ¶ added in v0.32.45
type Syncer struct {
// contains filtered or unexported fields
}
Syncer 联邦邀请的周期同步器。
依赖注入(便于测试与装配解耦):
- push:把一个口令推到某个对端基址(生产用 PushInvite)。
- receive:把对端推来的口令收进待收列表(生产用 InviteMailbox.AddInvite)。
幂等:已推送的 (target, code) 组合记在 sent 集合里,同进程内不重复推送。
func NewSyncer ¶ added in v0.32.45
func NewSyncer( push func(ctx context.Context, target, code, fromName, note string) error, receive func(code, fromName, note string) error, ) *Syncer
NewSyncer 构造同步器(push/receive 任一为 nil ⇒ 该方向短路)。
func (*Syncer) Receive ¶ added in v0.32.45
Receive 收下对端推来的口令(落入待收列表)。
幂等由收件箱本身保证(同 code 去重);这里仍返回错误面供上层判读。