package nodes import ( "context" "log/slog" "net" "strconv" "time" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/wangjia/pangolin/server/internal/mtls" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) // defaultRealityListenPort is the fallback REALITY inbound port when a node's // endpoint carries no explicit port. Matches the client default in // httpapi.BuildClientConfig. const defaultRealityListenPort = 11443 // realityHandshakePort is the port the REALITY inbound dials on the masquerade // site for its TLS handshake (always 443 — a real HTTPS endpoint). const realityHandshakePort = 443 // endpointPort extracts the numeric port from a "host:port" endpoint, returning // the fallback when the endpoint has no parseable port. func endpointPort(endpoint string, fallback int) int32 { _, portStr, err := net.SplitHostPort(endpoint) if err != nil { return int32(fallback) } p, err := strconv.Atoi(portStr) if err != nil || p <= 0 || p > 65535 { return int32(fallback) } return int32(p) } // Handler implements agentv1.AgentServiceServer. // Construct via NewHandler after wiring the individual components. type Handler struct { ca *mtls.CA tokens *mtls.BootstrapTokenManager hub *Hub store NodeStore load *LoadCache } // NewHandler creates a Handler with all dependencies injected. func NewHandler( ca *mtls.CA, tokens *mtls.BootstrapTokenManager, hub *Hub, store NodeStore, load *LoadCache, ) *Handler { return &Handler{ ca: ca, tokens: tokens, hub: hub, store: store, load: load, } } // Enroll is the one-time node enrollment RPC. // // Flow: consume bootstrap token (one-shot, returns Unauthenticated if invalid) → // sign CSR with CN = nodeUUID → return cert + CA cert. // The agent generates the key on-node so the private key never leaves it. func (h *Handler) Enroll(ctx context.Context, req *agentv1.EnrollRequest) (*agentv1.EnrollResponse, error) { if req.BootstrapToken == "" { return nil, status.Error(codes.Unauthenticated, "missing bootstrap_token") } if len(req.CSRPEM) == 0 { return nil, status.Error(codes.InvalidArgument, "missing csr_pem") } // ConsumeToken is atomic GETDEL: second call always fails. nodeUUID, err := h.tokens.ConsumeToken(ctx, req.BootstrapToken) if err != nil { return nil, status.Errorf(codes.Unauthenticated, "invalid bootstrap token: %v", err) } certPEM, err := h.ca.SignCSR(req.CSRPEM, nodeUUID) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "sign CSR: %v", err) } return &agentv1.EnrollResponse{ NodeUUID: nodeUUID, CertPEM: certPEM, CAPEM: h.ca.CAPEM(), NotAfterUnix: time.Now().Add(90 * 24 * time.Hour).Unix(), }, nil } // Register returns the current configuration snapshot for the caller node. // // The node UUID is taken from the verified mTLS certificate CN (injected by // mtls.UnaryServerInterceptor); the req.NodeUUID field is informational only. // Returns NotFound when no nodes table row exists for the UUID. func (h *Handler) Register(ctx context.Context, req *agentv1.RegisterRequest) (*agentv1.ConfigSnapshot, error) { nodeUUID, ok := mtls.NodeUUIDFromContext(ctx) if !ok { return nil, status.Error(codes.Unauthenticated, "missing node identity in context") } node, err := h.store.NodeByUUID(ctx, nodeUUID) if err != nil { return nil, status.Errorf(codes.Internal, "node lookup: %v", err) } if node == nil { return nil, status.Errorf(codes.NotFound, "node %q not in node catalogue", nodeUUID) } configVer, err := h.store.ConfigVersion(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "config version: %v", err) } creds, err := h.store.CredentialsForNode(ctx, nodeUUID) if err != nil { return nil, status.Errorf(codes.Internal, "credentials: %v", err) } snap := &agentv1.ConfigSnapshot{ ConfigVersion: configVer, Credentials: creds, } // Populate inbound configs from the nodes row. // reality_prk is the PRIVATE key the agent's VLESS inbound needs; // reality_pbk is the PUBLIC key sent to clients in the connect config. // // The inbound must listen on the same port the client connects to (parsed // from endpoint, matching httpapi.BuildClientConfig) and masquerade as the // node's reality_sni, dialing that host:443 for the TLS handshake — without // these fields the rendered sing-box config has listen_port 0 / empty // handshake and is rejected. listenPort := endpointPort(node.Endpoint, defaultRealityListenPort) if key := node.RealityPRK; key != "" { snap.Reality = &agentv1.RealityInbound{ ListenPort: listenPort, PrivateKey: key, ShortID: node.RealityShortID, ServerName: node.RealitySNI, HandshakeServer: node.RealitySNI, HandshakePort: realityHandshakePort, } } else if node.RealityPBK != "" { // Fallback for nodes seeded before migration 000011: use pbk field. snap.Reality = &agentv1.RealityInbound{ ListenPort: listenPort, PrivateKey: node.RealityPBK, ShortID: node.RealityShortID, ServerName: node.RealitySNI, HandshakeServer: node.RealitySNI, HandshakePort: realityHandshakePort, } } if node.Hy2Port.Valid { snap.Hy2 = &agentv1.Hy2Inbound{ ListenPort: node.Hy2Port.Int32, } } return snap, nil } // Heartbeat records the node's current load metrics and signals whether a full // resync is required (the node's config_version is behind the server's). func (h *Handler) Heartbeat(ctx context.Context, req *agentv1.HeartbeatRequest) (*agentv1.HeartbeatResponse, error) { nodeUUID, ok := mtls.NodeUUIDFromContext(ctx) if !ok { return nil, status.Error(codes.Unauthenticated, "missing node identity in context") } // Best-effort load write; a failure here must not break the heartbeat loop. if err := h.load.Set(ctx, nodeUUID, NodeLoad{ OnlineCount: int64(req.OnlinePeers), BandwidthUpBps: req.BandwidthUpBps, BandwidthDownBps: req.BandwidthDownBps, CPUPercent: req.CPUPercent, DataPlaneHealthy: req.DataPlaneHealthy, }); err != nil { slog.Warn("nodes.Handler.Heartbeat: load write failed", "node_uuid", nodeUUID, "err", err) } configVer, err := h.store.ConfigVersion(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "config version: %v", err) } // Request full resync only when the node reports a positive version that is // strictly behind the server (req.ConfigVersion == 0 means "freshly registered"). needResync := req.ConfigVersion > 0 && req.ConfigVersion < configVer return &agentv1.HeartbeatResponse{ NeedFullResync: needResync, ServerTimeUnix: time.Now().Unix(), }, nil } // Subscribe streams pending and future commands to the caller node. // // On each call the handler: // 1. Registers with the hub (before replaying to avoid a delivery gap). // 2. Replays all unacked commands with id > req.LastCommandID from the // Redis queue — ensuring continuity after a reconnect. // 3. Pumps newly arriving commands until the stream context is cancelled. func (h *Handler) Subscribe(req *agentv1.SubscribeRequest, stream agentv1.AgentService_SubscribeServer) error { ctx := stream.Context() nodeUUID, ok := mtls.NodeUUIDFromContext(ctx) if !ok { return status.Error(codes.Unauthenticated, "missing node identity in context") } // Register BEFORE replaying to avoid losing commands that arrive in between. ch, done := h.hub.Register(nodeUUID) defer done() // Replay unacked backlog (commands in queue with id > last_command_id). backlog, err := h.hub.Replay(ctx, nodeUUID, req.LastCommandID) if err != nil { return status.Errorf(codes.Internal, "replay: %v", err) } // Track the highest command_id sent from the backlog so the pump loop can // skip duplicates. A command pushed between Register and Replay ends up in // both the Redis queue (visible to Replay) and the local channel (delivered // by Push); without this guard the client would receive it twice. var maxReplayedID int64 for _, cmd := range backlog { if err := stream.Send(cmd); err != nil { return err } if cmd.CommandID > maxReplayedID { maxReplayedID = cmd.CommandID } } // Pump live commands until the stream breaks (client disconnect / ctx cancel). for { select { case <-ctx.Done(): return ctx.Err() case cmd, ok := <-ch: if !ok { return nil } // Skip commands that were already sent from the backlog replay above. if cmd.CommandID > 0 && cmd.CommandID <= maxReplayedID { continue } if err := stream.Send(cmd); err != nil { return err } } } } // Ack removes commandID from the node's pending queue. // A repeated Ack for the same commandID is a no-op. func (h *Handler) Ack(ctx context.Context, req *agentv1.AckRequest) (*agentv1.AckResponse, error) { nodeUUID, ok := mtls.NodeUUIDFromContext(ctx) if !ok { return nil, status.Error(codes.Unauthenticated, "missing node identity in context") } if err := h.hub.Ack(ctx, nodeUUID, req.CommandID); err != nil { return nil, status.Errorf(codes.Internal, "ack: %v", err) } return &agentv1.AckResponse{}, nil } // ReportUsage accumulates per-dp_uuid bandwidth and session metrics into usage_daily. // // The node has no knowledge of user accounts — it only sends opaque dp_uuids and // byte/minute counters. The control plane resolves dp_uuid → user_id internally. // Entries for unknown dp_uuids are silently skipped (user may have been deleted). func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*agentv1.UsageAck, error) { _, ok := mtls.NodeUUIDFromContext(ctx) if !ok { return nil, status.Error(codes.Unauthenticated, "missing node identity in context") } // Use the window end time to determine the calendar date for usage_daily. windowEnd := time.Unix(req.WindowEndUnix, 0).UTC() date := windowEnd.Truncate(24 * time.Hour) // Account-level minutes are deduped to WALL-CLOCK per user: the免费额度是「所有 // 设备共同的时间」,同一账户多台设备在同一窗口都活跃时只能算 1 分钟,否则 N 台并发 → // minutes_used = N×墙上时钟(超计,会把免费账号错误判耗尽)。字节是可加的 → 求和; // 分钟按窗口取 max(= 窗口墙上分钟)去重。每设备维度(usage_device_*)仍逐台累加。 type acctAgg struct{ bytesUp, bytesDown, minutes int64 } byUser := make(map[int64]*acctAgg) for _, entry := range req.Entries { if entry.DpUUID == "" { continue } userID, deviceID, found, err := h.store.UserDeviceByDpUUID(ctx, entry.DpUUID) if err != nil { slog.Warn("nodes.Handler.ReportUsage: dp_uuid lookup failed", "dp_uuid", entry.DpUUID, "err", err) continue // best-effort; don't fail the whole report } if !found { continue } // Fold into the per-user account aggregate (bytes sum, minutes max = 墙上时钟去重). a := byUser[userID] if a == nil { a = &acctAgg{} byUser[userID] = a } a.bytesUp += entry.BytesUp a.bytesDown += entry.BytesDown if entry.SessionMinutes > a.minutes { a.minutes = entry.SessionMinutes } // Per-device attribution (per entry — each device's own bytes/minutes; // deviceID==0 means a legacy account-level credential — no device dimension). if deviceID > 0 { if err := h.store.AccumulateDeviceUsage(ctx, userID, deviceID, date, entry.BytesUp, entry.BytesDown, entry.SessionMinutes, ); err != nil { slog.Warn("nodes.Handler.ReportUsage: device accumulate failed", "user_id", userID, "device_id", deviceID, "err", err) } // Per-device hourly (tz-aware per-device display curve, #10②). if err := h.store.AccumulateDeviceHourly(ctx, userID, deviceID, windowEnd, entry.BytesUp, entry.BytesDown, entry.SessionMinutes, ); err != nil { slog.Warn("nodes.Handler.ReportUsage: device hourly accumulate failed", "user_id", userID, "device_id", deviceID, "err", err) } // Heartbeat: keep the device's "online" status fresh while it reports. if err := h.store.TouchDeviceLastSeen(ctx, deviceID); err != nil { slog.Warn("nodes.Handler.ReportUsage: touch last_seen failed", "device_id", deviceID, "err", err) } } } // Flush the account-level rollup once per user (deduped wall-clock minutes): // daily (quota/today) + hourly (tz-aware display curve, keyed by window-end's UTC hour). for userID, a := range byUser { if err := h.store.AccumulateUsage(ctx, userID, date, a.bytesUp, a.bytesDown, a.minutes); err != nil { slog.Warn("nodes.Handler.ReportUsage: accumulate failed", "user_id", userID, "err", err) } if err := h.store.AccumulateHourly(ctx, userID, windowEnd, a.bytesUp, a.bytesDown, a.minutes); err != nil { slog.Warn("nodes.Handler.ReportUsage: hourly accumulate failed", "user_id", userID, "err", err) } } return &agentv1.UsageAck{}, nil }