package nodes import ( "context" "database/sql" "fmt" "time" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) // NodeRow holds a node's essential fields from the nodes table. type NodeRow struct { ID int64 UUID string Region string NameZH string NameEN string Tier string Status string RealityPBK string // REALITY x25519 PUBLIC key (for client connect config) RealityPRK string // REALITY x25519 PRIVATE key (for agent inbound TLS) RealitySNI string RealityShortID string Endpoint string Hy2Port sql.NullInt32 } // Entitlement summarises a user's active plan for the connect gate. type Entitlement struct { DpUUID string PlanCode string AdGate bool // true = free plan, require ad unlock + minute quota DailyMinutes sql.NullInt64 ExpiresAt sql.NullTime // latest subscription expiry (nil = trial/active) } // NodeStore is the persistence interface used by the nodes domain handlers. // All methods are context-aware and safe for concurrent use. type NodeStore interface { // NodeByUUID looks up a node record by its UUID. // Returns (nil, nil) when no matching row exists. NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error) // ListUp returns all nodes with status='up', ordered by weight DESC. ListUp(ctx context.Context) ([]*NodeRow, error) // EntitlementForUser returns the user's dp_uuid and active plan entitlement. // Returns (nil, nil) when the user has no active subscription (treats as free). EntitlementForUser(ctx context.Context, userID int64) (*Entitlement, error) // ConfigVersion returns the current global directory version. // This is the version from the directory_version singleton table. ConfigVersion(ctx context.Context) (int64, error) // ActiveNodeUUIDs returns the UUIDs of all nodes with status 'up' or 'draining'. // Used by Broadcast to enumerate delivery targets. ActiveNodeUUIDs(ctx context.Context) ([]string, error) // CredentialsForNode returns the active data-plane credentials for nodeUUID. CredentialsForNode(ctx context.Context, nodeUUID string) ([]*agentv1.Credential, error) // PersistCredential upserts a credential row in connect_credentials. PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error // DeleteCredential removes the credential for (nodeID, dpUUID). DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error // UserIDByDpUUID maps a data-plane UUID to the owning user's internal ID. // Returns (0, false, nil) if the dp_uuid is unknown or the user is inactive. UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) // AccumulateUsage adds bytes and minutes to usage_daily for userID on date. // Uses INSERT … ON DUPLICATE KEY UPDATE (idempotent within a day). AccumulateUsage(ctx context.Context, userID int64, date time.Time, bytesUp, bytesDown int64, minutes int64) error } // SQLNodeStore implements NodeStore against a MySQL 8.x database. type SQLNodeStore struct { db *sql.DB } // NewSQLNodeStore creates a SQLNodeStore backed by db. func NewSQLNodeStore(db *sql.DB) *SQLNodeStore { return &SQLNodeStore{db: db} } // NodeByUUID looks up a node by UUID. Returns (nil, nil) when not found. func (s *SQLNodeStore) NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error) { const q = ` SELECT id, uuid, region, name_zh, name_en, tier, status, reality_pbk, reality_prk, reality_sni, reality_short_id, endpoint, hy2_port FROM nodes WHERE uuid = ? ` var n NodeRow err := s.db.QueryRowContext(ctx, q, uuid).Scan( &n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Tier, &n.Status, &n.RealityPBK, &n.RealityPRK, &n.RealitySNI, &n.RealityShortID, &n.Endpoint, &n.Hy2Port, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.NodeByUUID: %w", err) } return &n, nil } // ListUp returns nodes with status='up', ordered by weight DESC. func (s *SQLNodeStore) ListUp(ctx context.Context) ([]*NodeRow, error) { const q = ` SELECT id, uuid, region, name_zh, name_en, tier, status, reality_pbk, reality_prk, reality_sni, reality_short_id, endpoint, hy2_port FROM nodes WHERE status = 'up' ORDER BY weight DESC ` rows, err := s.db.QueryContext(ctx, q) if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ListUp: %w", err) } defer rows.Close() var out []*NodeRow for rows.Next() { var n NodeRow if err := rows.Scan( &n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Tier, &n.Status, &n.RealityPBK, &n.RealityPRK, &n.RealitySNI, &n.RealityShortID, &n.Endpoint, &n.Hy2Port, ); err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ListUp scan: %w", err) } out = append(out, &n) } return out, rows.Err() } // EntitlementForUser returns the user's dp_uuid and best active plan entitlement. // Picks the subscription with the latest expires_at; falls back to the 'free' plan // if the user has no active subscription. func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*Entitlement, error) { // First get dp_uuid from the users table. var dpUUID string if err := s.db.QueryRowContext(ctx, `SELECT dp_uuid FROM users WHERE id = ? AND status = 'active'`, userID, ).Scan(&dpUUID); err == sql.ErrNoRows { return nil, nil } else if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: dp_uuid: %w", err) } // Look up the best active subscription. const q = ` SELECT p.code, p.ad_gate, p.daily_minutes, s.expires_at FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP() ORDER BY s.expires_at DESC LIMIT 1 ` e := &Entitlement{DpUUID: dpUUID} err := s.db.QueryRowContext(ctx, q, userID).Scan( &e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.ExpiresAt, ) if err == sql.ErrNoRows { // No active subscription → free plan defaults. e.PlanCode = "free" e.AdGate = true e.DailyMinutes = sql.NullInt64{Valid: true, Int64: 10} return e, nil } if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: plan: %w", err) } return e, nil } // ConfigVersion returns the current global directory version. // Returns 0 with nil error if the directory_version row does not yet exist. func (s *SQLNodeStore) ConfigVersion(ctx context.Context) (int64, error) { var v int64 err := s.db.QueryRowContext(ctx, `SELECT version FROM directory_version WHERE id = 1`).Scan(&v) if err == sql.ErrNoRows { return 0, nil } if err != nil { return 0, fmt.Errorf("nodes.SQLNodeStore.ConfigVersion: %w", err) } return v, nil } // ActiveNodeUUIDs returns UUIDs of all nodes with status 'up' or 'draining'. func (s *SQLNodeStore) ActiveNodeUUIDs(ctx context.Context) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT uuid FROM nodes WHERE status IN ('up', 'draining')`) if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ActiveNodeUUIDs: %w", err) } defer rows.Close() var uuids []string for rows.Next() { var u string if err := rows.Scan(&u); err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ActiveNodeUUIDs scan: %w", err) } uuids = append(uuids, u) } return uuids, rows.Err() } // CredentialsForNode returns active (non-expired) credentials for nodeUUID. func (s *SQLNodeStore) CredentialsForNode(ctx context.Context, nodeUUID string) ([]*agentv1.Credential, error) { const q = ` SELECT cc.dp_uuid, cc.protocol, cc.flow, UNIX_TIMESTAMP(cc.expires_at) FROM connect_credentials cc JOIN nodes n ON n.id = cc.node_id WHERE n.uuid = ? AND cc.expires_at > UTC_TIMESTAMP() ` rows, err := s.db.QueryContext(ctx, q, nodeUUID) if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.CredentialsForNode: %w", err) } defer rows.Close() var out []*agentv1.Credential for rows.Next() { var c agentv1.Credential if err := rows.Scan(&c.DpUUID, &c.Protocol, &c.Flow, &c.ExpiresAtUnix); err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.CredentialsForNode scan: %w", err) } out = append(out, &c) } return out, rows.Err() } // PersistCredential upserts the credential into connect_credentials. func (s *SQLNodeStore) PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error { const q = ` INSERT INTO connect_credentials (node_id, dp_uuid, protocol, flow, expires_at) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE protocol = VALUES(protocol), flow = VALUES(flow), expires_at = VALUES(expires_at) ` if _, err := s.db.ExecContext(ctx, q, nodeID, cred.DpUUID, int32(cred.Protocol), cred.Flow, expiresAt.UTC(), ); err != nil { return fmt.Errorf("nodes.SQLNodeStore.PersistCredential: %w", err) } return nil } // DeleteCredential removes the credential for (nodeID, dpUUID). func (s *SQLNodeStore) DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error { if _, err := s.db.ExecContext(ctx, `DELETE FROM connect_credentials WHERE node_id = ? AND dp_uuid = ?`, nodeID, dpUUID, ); err != nil { return fmt.Errorf("nodes.SQLNodeStore.DeleteCredential: %w", err) } return nil } // UserIDByDpUUID resolves a data-plane UUID to an active user's internal ID. func (s *SQLNodeStore) UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) { var userID int64 err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE dp_uuid = ? AND status = 'active' LIMIT 1`, dpUUID, ).Scan(&userID) if err == sql.ErrNoRows { return 0, false, nil } if err != nil { return 0, false, fmt.Errorf("nodes.SQLNodeStore.UserIDByDpUUID: %w", err) } return userID, true, nil } // AccumulateUsage adds bytes/minutes to usage_daily for the given user and date. func (s *SQLNodeStore) AccumulateUsage( ctx context.Context, userID int64, date time.Time, bytesUp, bytesDown int64, minutes int64, ) error { const q = ` INSERT INTO usage_daily (user_id, date, bytes_up, bytes_down, minutes_used) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE bytes_up = bytes_up + VALUES(bytes_up), bytes_down = bytes_down + VALUES(bytes_down), minutes_used = minutes_used + VALUES(minutes_used) ` if _, err := s.db.ExecContext(ctx, q, userID, date.Format("2006-01-02"), bytesUp, bytesDown, minutes, ); err != nil { return fmt.Errorf("nodes.SQLNodeStore.AccumulateUsage: %w", err) } return nil }