feat(backend): 挂载 /v1 API + 实现 nodes/connect 端到端
- 新增 internal/dpcred 包,统一 DeriveHy2Password + DefaultFlow agentd 与 HTTP connect handler 共享同一实现 - 新增迁移 000011:nodes 表拆分 reality_prk 私钥 / reality_pbk 公钥 reality_short_id;修正 handler_grpc.go 使用私钥字段 - 新增迁移 000012:connect_credentials 持久化凭证 实现 CredentialsForNode 修复 agent 重连 resync 原先返回空的桩 - 扩展 NodeStore 接口:ListUp / EntitlementForUser / PersistCredential / DeleteCredential;同步 grpc_test.go mock - 新增 httpapi/nodes.go:GET /nodes、POST /nodes/id/connect Hub.Push + PersistCredential + 渲染完整 sing-box client 配置 JSON POST /nodes/id/disconnect - 新增 httpapi/account.go:GET /me、GET /plans、GET /notices - 新增 httpapi/clientconfig.go:BuildClientConfig 服务端渲染 - 重写 cmd/server/main.go:手写 chi public/protected 分组 nodes.Service/Hub 在 main 构造并共享;SMTPMailer/LogMailer go build ./... && go vet ./... && go test ./... 全部通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,22 @@ func (m *mockNodeStore) UserIDByDpUUID(_ context.Context, dpUUID string) (int64,
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) ListUp(_ context.Context) ([]*nodes.NodeRow, error) {
|
||||
return []*nodes.NodeRow{{ID: 1, UUID: m.nodeUUID, Status: "up"}}, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) EntitlementForUser(_ context.Context, _ int64) (*nodes.Entitlement, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) PersistCredential(_ context.Context, _ int64, _ *agentv1.Credential, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) AccumulateUsage(_ context.Context, userID int64, date time.Time,
|
||||
bytesUp, bytesDown, minutes int64,
|
||||
) error {
|
||||
|
||||
@@ -106,9 +106,19 @@ func (h *Handler) Register(ctx context.Context, req *agentv1.RegisterRequest) (*
|
||||
}
|
||||
|
||||
// Populate inbound configs from the nodes row.
|
||||
if node.RealityPBK != "" {
|
||||
// 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.
|
||||
if node.RealityPRK != "" {
|
||||
snap.Reality = &agentv1.RealityInbound{
|
||||
PrivateKey: node.RealityPRK,
|
||||
ShortID: node.RealityShortID,
|
||||
ServerName: node.RealitySNI,
|
||||
}
|
||||
} else if node.RealityPBK != "" {
|
||||
// Fallback for nodes seeded before migration 000011: use pbk field.
|
||||
snap.Reality = &agentv1.RealityInbound{
|
||||
PrivateKey: node.RealityPBK,
|
||||
ShortID: node.RealityShortID,
|
||||
ServerName: node.RealitySNI,
|
||||
}
|
||||
}
|
||||
|
||||
+164
-15
@@ -11,13 +11,28 @@ import (
|
||||
|
||||
// NodeRow holds a node's essential fields from the nodes table.
|
||||
type NodeRow struct {
|
||||
ID int64
|
||||
UUID string
|
||||
Status string
|
||||
RealityPBK string
|
||||
RealitySNI string
|
||||
Endpoint string
|
||||
Hy2Port sql.NullInt32
|
||||
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.
|
||||
@@ -27,6 +42,13 @@ type NodeStore interface {
|
||||
// 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)
|
||||
@@ -36,9 +58,14 @@ type NodeStore interface {
|
||||
ActiveNodeUUIDs(ctx context.Context) ([]string, error)
|
||||
|
||||
// CredentialsForNode returns the active data-plane credentials for nodeUUID.
|
||||
// Returns an empty slice until task 5d creates the connect_credentials table.
|
||||
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)
|
||||
@@ -62,14 +89,17 @@ func NewSQLNodeStore(db *sql.DB) *SQLNodeStore {
|
||||
// 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, status, reality_pbk, reality_sni, endpoint, hy2_port
|
||||
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.Status,
|
||||
&n.RealityPBK, &n.RealitySNI, &n.Endpoint, &n.Hy2Port,
|
||||
&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
|
||||
@@ -80,6 +110,77 @@ func (s *SQLNodeStore) NodeByUUID(ctx context.Context, uuid string) (*NodeRow, e
|
||||
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) {
|
||||
@@ -115,10 +216,58 @@ func (s *SQLNodeStore) ActiveNodeUUIDs(ctx context.Context) ([]string, error) {
|
||||
return uuids, rows.Err()
|
||||
}
|
||||
|
||||
// CredentialsForNode returns active credentials for nodeUUID.
|
||||
// Stub: returns empty slice until task 5d creates the connect_credentials table.
|
||||
func (s *SQLNodeStore) CredentialsForNode(_ context.Context, _ string) ([]*agentv1.Credential, error) {
|
||||
return nil, nil
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user