Files
pangolin/server/internal/nodes/handler_grpc.go
T
wangjia f2499a64e1
ci-pangolin / Lint — shellcheck (push) Successful in 7s
ci-pangolin / OpenAPI Sync Check (push) Successful in 16s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 4s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m8s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
fix(usage): 统计按客户端本地日聚合 —— 修「29号却显示28号数据」时区 bug
根因:服务端用量全按 UTC 预切天,UTC+8 用户在中国 0-8 点时 UTC 还是前一天,
「今天」就显示前一天数据。

改法(用户拍板:小时桶+查询带时区偏移):
- migration 000017 `usage_hourly`(UTC 纪元小时整数桶,稀疏存储,空小时不落行)。
- ReportUsage 同时累加 usage_hourly(按 windowEnd 的 UTC 小时);usage_daily 保留作配额/今日。
- `UsageCurve(days, offsetMin)`:读 UTC 小时桶,按客户端时区偏移**重新聚合成本地日**曲线。
- `/v1/usage?tz_offset=分钟`;客户端 `usage()` 带 `DateTime.now().timeZoneOffset.inMinutes`。
- 客户端早已「取曲线最后一点当今日」,故只改服务端聚合 + 带偏移即可。

测试:store 时区分桶证明(UTC 18:00 在 UTC vs UTC+8 落不同本地日,锁住 bug 修复)+
migration v17 + usage_hourly 表;集成测试改喂 hourly;全量 go/flutter test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 07:02:04 +08:00

337 lines
11 KiB
Go

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,
}); 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)
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
}
// Account-level rollup (always): daily (quota/today) + hourly (tz-aware
// display curve, keyed by the window-end's UTC hour).
if err := h.store.AccumulateUsage(ctx, userID, date,
entry.BytesUp, entry.BytesDown, entry.SessionMinutes,
); err != nil {
slog.Warn("nodes.Handler.ReportUsage: accumulate failed",
"user_id", userID, "err", err)
}
if err := h.store.AccumulateHourly(ctx, userID, windowEnd,
entry.BytesUp, entry.BytesDown, entry.SessionMinutes,
); err != nil {
slog.Warn("nodes.Handler.ReportUsage: hourly accumulate failed",
"user_id", userID, "err", err)
}
// Per-device attribution (only when the dp_uuid maps to a registered device;
// 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)
}
// 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)
}
}
}
return &agentv1.UsageAck{}, nil
}