f03d2dc8a6
与控制面同仓同 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>
213 lines
7.5 KiB
Protocol Buffer
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 {}
|