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>
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package agentd
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/mtls"
|
|
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/grpc/test/bufconn"
|
|
)
|
|
|
|
const testNodeUUID = "node-test-uuid"
|
|
|
|
// mockCP is an in-memory control plane implementing AgentServiceServer.
|
|
type mockCP struct {
|
|
ca *mtls.CA
|
|
|
|
mu sync.Mutex
|
|
snapCreds map[string]*agentv1.Credential // converged credential set for Register
|
|
configVer int64
|
|
lastCmdID int64
|
|
issued []*agentv1.Command // backlog for resume-after-drop
|
|
acked map[int64]bool
|
|
registerN int
|
|
heartbeatN int
|
|
subReqs []int64
|
|
usage []*agentv1.UsageReport
|
|
resyncOnce bool
|
|
enrollToken string
|
|
|
|
liveCh chan *agentv1.Command
|
|
dropCh chan struct{}
|
|
}
|
|
|
|
func newMockCP(t *testing.T) *mockCP {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
ca, err := mtls.NewCA(mtls.CAConfig{
|
|
KeyPath: dir + "/ca.key",
|
|
CertPath: dir + "/ca.crt",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new CA: %v", err)
|
|
}
|
|
return &mockCP{
|
|
ca: ca,
|
|
snapCreds: map[string]*agentv1.Credential{},
|
|
acked: map[int64]bool{},
|
|
enrollToken: "test-token",
|
|
liveCh: make(chan *agentv1.Command),
|
|
dropCh: make(chan struct{}, 1),
|
|
}
|
|
}
|
|
|
|
func (m *mockCP) Enroll(_ context.Context, req *agentv1.EnrollRequest) (*agentv1.EnrollResponse, error) {
|
|
if req.BootstrapToken != m.enrollToken {
|
|
return nil, status.Error(codes.Unauthenticated, "bad bootstrap token")
|
|
}
|
|
certPEM, err := m.ca.SignCSR(req.CSRPEM, testNodeUUID)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "sign csr: %v", err)
|
|
}
|
|
return &agentv1.EnrollResponse{
|
|
NodeUUID: testNodeUUID,
|
|
CertPEM: certPEM,
|
|
CAPEM: m.ca.CAPEM(),
|
|
NotAfterUnix: time.Now().Add(90 * 24 * time.Hour).Unix(),
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockCP) Register(_ context.Context, _ *agentv1.RegisterRequest) (*agentv1.ConfigSnapshot, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.registerN++
|
|
creds := make([]*agentv1.Credential, 0, len(m.snapCreds))
|
|
for _, c := range m.snapCreds {
|
|
creds = append(creds, c)
|
|
}
|
|
sort.Slice(creds, func(i, j int) bool { return creds[i].DpUUID < creds[j].DpUUID })
|
|
return &agentv1.ConfigSnapshot{
|
|
ConfigVersion: m.configVer,
|
|
Credentials: creds,
|
|
Reality: &agentv1.RealityInbound{
|
|
ListenPort: 11443, PrivateKey: "pk", ShortID: "deadbeef",
|
|
ServerName: "www.apple.com", HandshakeServer: "www.apple.com", HandshakePort: 443,
|
|
},
|
|
Hy2: &agentv1.Hy2Inbound{ListenPort: 443, Masquerade: "https://www.bing.com", CertPath: "/c", KeyPath: "/k"},
|
|
LastCommandID: m.lastCmdID,
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockCP) Heartbeat(_ context.Context, _ *agentv1.HeartbeatRequest) (*agentv1.HeartbeatResponse, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.heartbeatN++
|
|
resync := false
|
|
if m.resyncOnce {
|
|
m.resyncOnce = false
|
|
resync = true
|
|
}
|
|
return &agentv1.HeartbeatResponse{NeedFullResync: resync, ServerTimeUnix: time.Now().Unix()}, nil
|
|
}
|
|
|
|
func (m *mockCP) Ack(_ context.Context, req *agentv1.AckRequest) (*agentv1.AckResponse, error) {
|
|
m.mu.Lock()
|
|
m.acked[req.CommandID] = true
|
|
m.mu.Unlock()
|
|
return &agentv1.AckResponse{}, nil
|
|
}
|
|
|
|
func (m *mockCP) ReportUsage(_ context.Context, req *agentv1.UsageReport) (*agentv1.UsageAck, error) {
|
|
m.mu.Lock()
|
|
m.usage = append(m.usage, req)
|
|
m.mu.Unlock()
|
|
return &agentv1.UsageAck{}, nil
|
|
}
|
|
|
|
func (m *mockCP) Subscribe(req *agentv1.SubscribeRequest, stream agentv1.AgentService_SubscribeServer) error {
|
|
m.mu.Lock()
|
|
m.subReqs = append(m.subReqs, req.LastCommandID)
|
|
backlog := make([]*agentv1.Command, 0)
|
|
for _, c := range m.issued {
|
|
if c.CommandID > req.LastCommandID {
|
|
backlog = append(backlog, c)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
for _, c := range backlog {
|
|
if err := stream.Send(c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for {
|
|
select {
|
|
case <-stream.Context().Done():
|
|
return stream.Context().Err()
|
|
case <-m.dropCh:
|
|
return status.Error(codes.Unavailable, "simulated stream drop")
|
|
case c := <-m.liveCh:
|
|
if err := stream.Send(c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── mock helpers (test goroutine) ───────────────────────────────────────────
|
|
|
|
// seedCred adds a credential to the converged Register snapshot before start.
|
|
func (m *mockCP) seedCred(c *agentv1.Credential) {
|
|
m.mu.Lock()
|
|
m.snapCreds[c.DpUUID] = c
|
|
if m.configVer == 0 {
|
|
m.configVer = 1
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// push issues a live command AND folds it into the Register snapshot, so a later
|
|
// reconnect's Register stays consistent with the command stream.
|
|
func (m *mockCP) push(cmd *agentv1.Command) {
|
|
m.applyToSnapshot(cmd, true) // updates snapCreds + lastCmdID
|
|
m.mu.Lock()
|
|
m.issued = append(m.issued, cmd)
|
|
m.mu.Unlock()
|
|
m.liveCh <- cmd
|
|
}
|
|
|
|
// enqueueOffline appends a command to the resume backlog WITHOUT touching the
|
|
// Register snapshot — so the only way the agent learns it is via Subscribe resume.
|
|
func (m *mockCP) enqueueOffline(cmd *agentv1.Command) {
|
|
m.mu.Lock()
|
|
// Intentionally does NOT advance lastCmdID: this command is not yet reflected
|
|
// in the Register snapshot, so the agent must learn it via Subscribe resume.
|
|
m.issued = append(m.issued, cmd)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (m *mockCP) applyToSnapshot(cmd *agentv1.Command, bumpVer bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
switch cmd.Type {
|
|
case agentv1.CommandTypeUpsert:
|
|
m.snapCreds[cmd.Upsert.Credential.DpUUID] = cmd.Upsert.Credential
|
|
case agentv1.CommandTypeRevoke:
|
|
delete(m.snapCreds, cmd.Revoke.DpUUID)
|
|
}
|
|
if cmd.CommandID > m.lastCmdID {
|
|
m.lastCmdID = cmd.CommandID
|
|
}
|
|
if bumpVer {
|
|
m.configVer++
|
|
}
|
|
}
|
|
|
|
func (m *mockCP) drop() { m.dropCh <- struct{}{} }
|
|
func (m *mockCP) registerCount() int { m.mu.Lock(); defer m.mu.Unlock(); return m.registerN }
|
|
func (m *mockCP) heartbeatCount() int { m.mu.Lock(); defer m.mu.Unlock(); return m.heartbeatN }
|
|
func (m *mockCP) isAcked(id int64) bool { m.mu.Lock(); defer m.mu.Unlock(); return m.acked[id] }
|
|
func (m *mockCP) subReqList() []int64 {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return append([]int64(nil), m.subReqs...)
|
|
}
|
|
|
|
// ─── test harness ─────────────────────────────────────────────────────────────
|
|
|
|
func startMock(t *testing.T, m *mockCP) func(ctx context.Context) (*grpc.ClientConn, error) {
|
|
t.Helper()
|
|
lis := bufconn.Listen(1024 * 1024)
|
|
srv := grpc.NewServer()
|
|
agentv1.RegisterAgentServiceServer(srv, m)
|
|
go func() { _ = srv.Serve(lis) }()
|
|
t.Cleanup(func() { srv.Stop() })
|
|
return func(ctx context.Context) (*grpc.ClientConn, error) {
|
|
return grpc.NewClient("passthrough:///bufnet",
|
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
|
return lis.DialContext(ctx)
|
|
}),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
}
|
|
}
|
|
|
|
func fastConfig(t *testing.T) Config {
|
|
cfg := testConfig(t)
|
|
cfg.BootstrapToken = "test-token"
|
|
cfg.HeartbeatInterval = 25 * time.Millisecond
|
|
cfg.UsageInterval = time.Hour
|
|
cfg.TTLScanInterval = time.Hour
|
|
cfg.DebounceWindow = 10 * time.Millisecond
|
|
cfg.BackoffMin = 10 * time.Millisecond
|
|
cfg.BackoffMax = 40 * time.Millisecond
|
|
return cfg
|
|
}
|
|
|
|
func eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if fn() {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timeout waiting for: %s", msg)
|
|
}
|
|
|
|
func readRender(path string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// TestIntegrationFullFlow exercises Enroll → Register → Heartbeat → Subscribe/Ack,
|
|
// command application (upsert/revoke), render correctness, heartbeat-triggered
|
|
// resync, and reconnect resume via last_command_id.
|
|
func TestIntegrationFullFlow(t *testing.T) {
|
|
m := newMockCP(t)
|
|
m.seedCred(&agentv1.Credential{DpUUID: "boot", Protocol: agentv1.ProtocolBoth})
|
|
dial := startMock(t, m)
|
|
|
|
cfg := fastConfig(t)
|
|
a := New(cfg,
|
|
WithDialer(Dialer(dial)),
|
|
WithEnrollDialer(EnrollDialer(dial)),
|
|
WithRestarter(&fakeRestarter{}),
|
|
)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
runErr := make(chan error, 1)
|
|
go func() { runErr <- a.Run(ctx) }()
|
|
|
|
// 1) Enrollment persisted the cert/key/ca and the node UUID is set.
|
|
eventually(t, 2*time.Second, func() bool {
|
|
_, err1 := os.Stat(cfg.CertPath())
|
|
_, err2 := os.Stat(cfg.KeyPath())
|
|
_, err3 := os.Stat(cfg.CAPath())
|
|
return err1 == nil && err2 == nil && err3 == nil && a.NodeUUID() == testNodeUUID
|
|
}, "enrollment to persist cert/key/ca and set node UUID")
|
|
|
|
// 2) Initial Register snapshot rendered with the seeded credential.
|
|
eventually(t, 2*time.Second, func() bool {
|
|
return strings.Contains(readRender(cfg.SingboxConfigPath), `"uuid": "boot"`)
|
|
}, "initial snapshot credential 'boot' to be rendered")
|
|
|
|
// 3) Heartbeats flowing.
|
|
eventually(t, 2*time.Second, func() bool { return m.heartbeatCount() > 0 }, "heartbeats")
|
|
|
|
// 4) Stream a command: upsert 'extra'. Applied + Ack'd + rendered.
|
|
m.push(&agentv1.Command{
|
|
CommandID: 1, Type: agentv1.CommandTypeUpsert,
|
|
Upsert: &agentv1.UpsertPayload{Credential: &agentv1.Credential{DpUUID: "extra", Protocol: agentv1.ProtocolBoth}},
|
|
})
|
|
eventually(t, 2*time.Second, func() bool {
|
|
return strings.Contains(readRender(cfg.SingboxConfigPath), `"uuid": "extra"`) && m.isAcked(1)
|
|
}, "upsert 'extra' applied and acked")
|
|
|
|
// 5) Stream a revoke for 'boot'. Removed from render + Ack'd.
|
|
m.push(&agentv1.Command{
|
|
CommandID: 2, Type: agentv1.CommandTypeRevoke,
|
|
Revoke: &agentv1.RevokePayload{DpUUID: "boot"},
|
|
})
|
|
eventually(t, 2*time.Second, func() bool {
|
|
r := readRender(cfg.SingboxConfigPath)
|
|
return !strings.Contains(r, `"uuid": "boot"`) && m.isAcked(2)
|
|
}, "revoke 'boot' applied and acked")
|
|
|
|
// 6) Reconnect resume: enqueue an OFFLINE command (only reachable via backlog),
|
|
// then drop the stream. After reconnect the agent must resume from
|
|
// last_command_id and receive id=3 without redelivering 1/2.
|
|
m.enqueueOffline(&agentv1.Command{
|
|
CommandID: 3, Type: agentv1.CommandTypeUpsert,
|
|
Upsert: &agentv1.UpsertPayload{Credential: &agentv1.Credential{DpUUID: "resumed", Protocol: agentv1.ProtocolBoth}},
|
|
})
|
|
m.drop()
|
|
eventually(t, 3*time.Second, func() bool {
|
|
return strings.Contains(readRender(cfg.SingboxConfigPath), `"uuid": "resumed"`) && m.isAcked(3)
|
|
}, "offline command 3 delivered after reconnect resume")
|
|
|
|
// The second Subscribe must have resumed from a non-zero high-water mark.
|
|
reqs := m.subReqList()
|
|
if len(reqs) < 2 {
|
|
t.Fatalf("expected at least 2 Subscribe calls (reconnect), got %v", reqs)
|
|
}
|
|
if reqs[len(reqs)-1] < 2 {
|
|
t.Errorf("reconnect Subscribe last_command_id = %d, want >= 2 (resume, no redelivery)", reqs[len(reqs)-1])
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-runErr:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("agent did not shut down after context cancel")
|
|
}
|
|
}
|
|
|
|
// TestIntegrationHeartbeatResync verifies a need_full_resync heartbeat forces a
|
|
// reconnect + re-Register so the node converges on the authoritative snapshot.
|
|
func TestIntegrationHeartbeatResync(t *testing.T) {
|
|
m := newMockCP(t)
|
|
m.seedCred(&agentv1.Credential{DpUUID: "boot", Protocol: agentv1.ProtocolBoth})
|
|
m.resyncOnce = true
|
|
dial := startMock(t, m)
|
|
|
|
a := New(fastConfig(t),
|
|
WithDialer(Dialer(dial)),
|
|
WithEnrollDialer(EnrollDialer(dial)),
|
|
WithRestarter(&fakeRestarter{}),
|
|
)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = a.Run(ctx) }()
|
|
|
|
eventually(t, 3*time.Second, func() bool { return m.registerCount() >= 2 }, "re-Register after need_full_resync")
|
|
}
|