Files
pangolin/server/proto/agent/v1/agent.proto
T
wangjia f03d2dc8a6 feat(agent): node agent — enroll/mTLS, heartbeat, command stream, sing-box 用户表 (tsk__R8M4jEw43JR)
与控制面同仓同 go.mod,新增节点 agent 实现:

- proto/agent/v1/agent.proto + internal/pb/agentv1:冻结的控制面↔agent gRPC 契约
  (Enroll/Register/Heartbeat/Subscribe/Ack/ReportUsage)。仓库尚无 protoc 流水线,
  暂以手写 Go 类型 + JSON gRPC codec 实现,与 proto 1:1 对应,待 protoc 接入即可替换。
- internal/agentd:
  - enroll.go:首启生成 EC 密钥+CSR,持 bootstrap token 调 Enroll 换 90d 节点证书
    (CN=node_uuid),落 /etc/pangolin-agent/,此后 mTLS。
  - conn.go(agent.go)+creds.go:mTLS 主动拨号 + 指数退避重连;重连携带 last_command_id;
    Register 取 ConfigSnapshot 全量配置覆盖本地。
  - heartbeat.go:30s 上报 peer/带宽/CPU + config_version;need_full_resync→全量同步。
  - command.go:消费 Subscribe,Upsert/Revoke/Rotate/ApplyConfig/Lifecycle 幂等处理后
    Ack(at-least-once,按 command_id 去重)。
  - singbox.go+render.go:内存用户表 + 落盘 state.json(仅 dp_uuid+expires_at);任何变更
    渲染完整 sing-box 配置(REALITY users[uuid,flow] + Hy2 users[派生口令])→ 500ms 去抖
    合并 → systemd 重启。
  - ttl.go:凭证 TTL 定时移除并上报。
  - usage.go:按 dp_uuid 聚合上报,绝无 user_id/email/目的地址。
  - derive.go:Hy2 口令 = HMAC-SHA256(key, dp_uuid),与控制面同源派生。
- cmd/agent:入口(flag/env 配置)。
- infra/cloud-init/{node.yaml.tmpl,install-node.sh,README.md}:一段式安装,下载锁定版本
  二进制并校验 SHA-256,systemd 拉管,首启即 Enroll/Register。shellcheck -S warning 通过。

测试(bufconn mock 控制面,无需 docker):Enroll→Register→Heartbeat 全流转;Upsert/Revoke
渲染正确;Rotate 宽限期新旧并存到点移除;TTL 自动移除并上报;断流重连 last_command_id
续发不丢不重;need_full_resync 触发重注册;state.json 恢复;去抖合并;扫描确认无身份字段。
go test -race ./internal/agentd/... ./internal/pb/... 通过;go vet ./... 通过。

落实 doc/04 §2 节点无状态化与 doc/06 §3 数据面红线(节点仅见 dp_uuid)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:26:58 +08:00

213 lines
7.5 KiB
Protocol Buffer

// Package pangolin.agent.v1 is the frozen control-plane <-> node-agent contract.
//
// CONNECTION DIRECTION: the node agent ALWAYS dials the control plane (never the
// reverse). Every RPC except Enroll requires a verified mTLS client certificate
// whose Subject CN equals the node UUID (see server/internal/mtls). Enroll is the
// single bootstrap exception, authenticated by a one-time bootstrap token instead.
//
// DATA-PLANE RED LINE (doc/06 §3, doc/04 §2): messages on this contract carry ONLY
// the opaque data-plane credential id (dp_uuid) and aggregate counters. They MUST
// NOT carry user_id, email, device id, destination address, SNI or any DNS query.
//
// NOTE ON CODE GENERATION: this repository has no protoc/buf toolchain wired yet
// (Makefile `generate` is a stub). Until task #5's proto pipeline lands, the Go
// types and gRPC stubs in server/internal/pb/agentv1 are hand-written to mirror
// this file 1:1 and are wire-encoded with a JSON gRPC codec. When protoc is
// introduced this file is the source of truth; field numbers are reserved here so
// the generated code stays compatible.
syntax = "proto3";
package pangolin.agent.v1;
option go_package = "github.com/wangjia/pangolin/server/internal/pb/agentv1;agentv1";
// AgentService is implemented by the control plane and consumed by node agents.
service AgentService {
// Enroll exchanges a one-time bootstrap token + CSR for a node client
// certificate (CN = node_uuid, 90d). Exempt from mTLS — this is the only RPC an
// un-enrolled agent may call. Idempotent only within the token TTL.
rpc Enroll(EnrollRequest) returns (EnrollResponse);
// Register is called after (re)connecting over mTLS. It returns the authoritative
// full ConfigSnapshot so the agent can converge local state.
rpc Register(RegisterRequest) returns (ConfigSnapshot);
// Heartbeat reports liveness + load every ~30s and the agent's local
// config_version. The control plane flags need_full_resync when it detects drift.
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
// Subscribe is a server-streaming feed of Commands. The agent passes the last
// command_id it durably processed so the control plane can resume without gaps
// (at-least-once; commands are idempotent and Ack'd individually).
rpc Subscribe(SubscribeRequest) returns (stream Command);
// Ack confirms a command was applied. Safe to retry.
rpc Ack(AckRequest) returns (AckResponse);
// ReportUsage uploads per-dp_uuid aggregate byte/minute counters. No identities.
rpc ReportUsage(UsageReport) returns (UsageAck);
}
// ─── enroll ──────────────────────────────────────────────────────────────────
message EnrollRequest {
string bootstrap_token = 1; // one-time, injected via cloud-init
bytes csr_pem = 2; // PKCS#10 CSR, agent-generated key never leaves node
string agent_version = 3;
}
message EnrollResponse {
string node_uuid = 1; // authoritative; becomes the cert CN
bytes cert_pem = 2; // signed leaf, 90d, EKU=clientAuth
bytes ca_pem = 3; // CA to pin for server validation
int64 not_after_unix = 4;
}
// ─── register / config ─────────────────────────────────────────────────────────
message RegisterRequest {
string node_uuid = 1;
string agent_version = 2;
int64 local_config_version = 3; // 0 on a fresh node
}
// Protocol selects which inbound(s) a credential is provisioned on.
enum Protocol {
PROTOCOL_UNSPECIFIED = 0;
PROTOCOL_REALITY = 1; // VLESS+REALITY inbound, user = {uuid, flow}
PROTOCOL_HY2 = 2; // Hysteria2 inbound, user = {password}
PROTOCOL_BOTH = 3; // both inbounds share the same dp_uuid
}
// Credential is the ONLY per-subscriber object a node ever sees.
message Credential {
string dp_uuid = 1; // opaque data-plane id; REALITY user uuid
Protocol protocol = 2;
string flow = 3; // e.g. "xtls-rprx-vision"
int64 expires_at_unix = 4; // agent-side TTL; 0 = no expiry
}
// ConfigSnapshot is the full authoritative node state.
message ConfigSnapshot {
int64 config_version = 1;
repeated Credential credentials = 2;
// Inbound parameters (node-wide, not per-user).
RealityInbound reality = 3;
Hy2Inbound hy2 = 4;
// Baseline command id already reflected in this snapshot; the agent resumes
// Subscribe from here.
int64 last_command_id = 5;
}
message RealityInbound {
int32 listen_port = 1;
string private_key = 2; // REALITY server private key (base64)
string short_id = 3;
string server_name = 4; // masquerade SNI handshake target
string handshake_server = 5;
int32 handshake_port = 6;
}
message Hy2Inbound {
int32 listen_port = 1;
string masquerade = 2;
string cert_path = 3;
string key_path = 4;
}
// ─── heartbeat ───────────────────────────────────────────────────────────────
message HeartbeatRequest {
string node_uuid = 1;
int64 config_version = 2;
int32 online_peers = 3;
int64 bandwidth_up_bps = 4;
int64 bandwidth_down_bps = 5;
double cpu_percent = 6;
int64 timestamp_unix = 7;
}
message HeartbeatResponse {
bool need_full_resync = 1; // agent must Register + overwrite local state
int64 server_time_unix = 2;
}
// ─── command stream ──────────────────────────────────────────────────────────
enum CommandType {
COMMAND_TYPE_UNSPECIFIED = 0;
COMMAND_TYPE_UPSERT = 1; // add/update a credential
COMMAND_TYPE_REVOKE = 2; // remove a credential
COMMAND_TYPE_ROTATE_CREDENTIAL = 3; // new replaces old, old kept until grace
COMMAND_TYPE_APPLY_CONFIG = 4; // replace inbound params / full snapshot
COMMAND_TYPE_LIFECYCLE = 5; // drain / resume / shutdown
}
enum LifecycleAction {
LIFECYCLE_ACTION_UNSPECIFIED = 0;
LIFECYCLE_ACTION_DRAIN = 1;
LIFECYCLE_ACTION_RESUME = 2;
LIFECYCLE_ACTION_SHUTDOWN = 3;
}
message Command {
int64 command_id = 1; // monotonically increasing per node
CommandType type = 2;
UpsertPayload upsert = 3;
RevokePayload revoke = 4;
RotatePayload rotate = 5;
ConfigSnapshot apply_config = 6;
LifecyclePayload lifecycle = 7;
}
message UpsertPayload {
Credential credential = 1;
}
message RevokePayload {
string dp_uuid = 1;
}
message RotatePayload {
string old_dp_uuid = 1;
Credential new_credential = 2;
int64 grace_until_unix = 3; // old credential kept active until this time
}
message LifecyclePayload {
LifecycleAction action = 1;
}
message SubscribeRequest {
string node_uuid = 1;
int64 last_command_id = 2; // resume point; 0 = from the beginning
}
message AckRequest {
string node_uuid = 1;
int64 command_id = 2;
}
message AckResponse {}
// ─── usage ───────────────────────────────────────────────────────────────────
message UsageEntry {
string dp_uuid = 1; // never a user id
int64 bytes_up = 2;
int64 bytes_down = 3;
int64 session_minutes = 4;
}
message UsageReport {
string node_uuid = 1;
int64 window_start_unix = 2;
int64 window_end_unix = 3;
repeated UsageEntry entries = 4;
}
message UsageAck {}