// 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; // data_plane_healthy: agent 本地探测 sing-box(clash_api)是否健康。false → 控制面把 // 该节点判为 down(列表置灰 + connect 拦截),即便 agent 自身在线。 bool data_plane_healthy = 8; } 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 {}