daaed39bac
agent 按每 dp_uuid(每设备)每窗口计 SessionMinutes=1,控制面 ReportUsage 逐条累加进 usage_daily.minutes_used → N 台并发 = N×墙上时钟(线上 chenxin 2903 分钟/天,不可能)。 免费额度是「所有设备共同的时间」,超计会把免费账号错误判「时长耗尽」而断连。 改 ReportUsage:账户维度按 user 分组去重 —— 字节仍求和(可加),分钟按窗口取 max (=窗口墙上分钟,恒为 1)去重,循环后每 user 各 flush 一次 AccumulateUsage/Hourly。 每设备维度(usage_device_*)保持逐台累加不变。usage_daily.minutes_used ≤ Σ 每设备分钟, /me 的 quota_today_min 与 ConnectNode 免费门随之正确。历史数据次日归零、不回填。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
360 lines
13 KiB
Go
360 lines
13 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,
|
|
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
|
|
}
|