721371d806
根因:付费连接凭证硬编码 24h TTL(paidCredentialTTL),而客户端只在 _connect() (用户/看门狗前台重连)才重签,服务端从不因流量续期。macOS sysext(root)隧道独立于 GUI app 常驻,用户关窗后 Dart 看门狗根本不运行 → 凭证 24h 到期、下次 agent 重注册 用「未过期」快照整表覆盖并重渲染 sing-box → REALITY 会话被剔除、永久黑洞。该逻辑 四端共用同一份 Dart,故为全端共性(macOS 最易现形)。 修法(方案 A,服务端、与客户端生命周期无关,一改修四端): - ReportUsage 收到某 dp_uuid 有流量,若属付费套餐(!AdGate)即把其凭证 expires_at 顶到 now+PaidCredentialTTL。活跃会话永不过期;免费凭证 TTL 编码日额度、绝不续期 (否则击穿日限)。每报按 user 缓存一次 entitlement 查询。 - 新增 NodeStore.RenewCredential(纯 UPDATE,WHERE expires_at>now 不复活已过期会话)。 - 24h 提为 nodes.PaidCredentialTTL 单一真相源,httpapi 引用它消除漂移。 - 纯 DB 续期,无需再 push agent(现有 REALITY 用户仍在,只要 DB 行不过期,下次 重注册快照仍含它)。可移植 SQL(? 占位 + Go 端算时间,无 MySQL 专属构造)。 测试:handler 层付费续期/免费不续期(mock);store 层真 SQLite 续期/不复活已过期。 go test ./... 全绿、go vet 干净。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD
128 lines
4.3 KiB
Go
128 lines
4.3 KiB
Go
package nodes_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/config"
|
|
"github.com/wangjia/pangolin/server/internal/nodes"
|
|
"github.com/wangjia/pangolin/server/internal/store"
|
|
)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// RenewCredential is the DB half of "renew-on-activity" (the fix for a paid
|
|
// session silently dropping ~1 day in — the connect credential's 24h TTL with
|
|
// nothing refreshing it while a persistent macOS sysext tunnel outlives the GUI
|
|
// app). Pure-Go modernc sqlite → runs in the default no-docker path alongside
|
|
// entitlement_override_sqlite_test.go. Exercises the actual UPDATE SQL (column
|
|
// names, the expires_at > now guard) the handler mock can't cover.
|
|
// --------------------------------------------------------------------------
|
|
|
|
func openRenewTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
|
t.Fatalf("MigrateUp: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// seedRenewNode inserts a provider + node (provider_id is a FK) and returns the
|
|
// node id, on which credentials can be hung.
|
|
func seedRenewNode(t *testing.T, db *sql.DB) int64 {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
res, err := db.ExecContext(ctx,
|
|
`INSERT INTO providers (name, api_kind, regions, pool)
|
|
VALUES ('renew-test', 'vultr', '["HK"]', 'consumable')`)
|
|
if err != nil {
|
|
t.Fatalf("seed provider: %v", err)
|
|
}
|
|
providerID, err := res.LastInsertId()
|
|
if err != nil {
|
|
t.Fatalf("provider id: %v", err)
|
|
}
|
|
res, err = db.ExecContext(ctx, `
|
|
INSERT INTO nodes
|
|
(uuid, region, name_zh, name_en, tier, endpoint, reality_pbk, reality_sni, provider_id, status)
|
|
VALUES ('renew-node', 'HK', '香港', 'HK', 'pro', '1.2.3.4:443', 'pbk', 'sni.example.com', ?, 'up')
|
|
`, providerID)
|
|
if err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
nodeID, err := res.LastInsertId()
|
|
if err != nil {
|
|
t.Fatalf("node id: %v", err)
|
|
}
|
|
return nodeID
|
|
}
|
|
|
|
func insertRenewCred(t *testing.T, db *sql.DB, nodeID int64, dpUUID string, expiresAt time.Time) {
|
|
t.Helper()
|
|
if _, err := db.ExecContext(context.Background(),
|
|
`INSERT INTO connect_credentials (node_id, dp_uuid, protocol, flow, expires_at)
|
|
VALUES (?, ?, 3, 'xtls-rprx-vision', ?)`,
|
|
nodeID, dpUUID, expiresAt.UTC()); err != nil {
|
|
t.Fatalf("insert credential %s: %v", dpUUID, err)
|
|
}
|
|
}
|
|
|
|
func readRenewExpiry(t *testing.T, db *sql.DB, dpUUID string) time.Time {
|
|
t.Helper()
|
|
var exp time.Time
|
|
if err := db.QueryRowContext(context.Background(),
|
|
`SELECT expires_at FROM connect_credentials WHERE dp_uuid = ?`, dpUUID).Scan(&exp); err != nil {
|
|
t.Fatalf("read expiry %s: %v", dpUUID, err)
|
|
}
|
|
return exp.UTC()
|
|
}
|
|
|
|
// TestRenewCredential_BumpsActive: a still-active credential's expiry is pushed
|
|
// forward to the requested time.
|
|
func TestRenewCredential_BumpsActive(t *testing.T) {
|
|
db := openRenewTestDB(t)
|
|
st := nodes.NewSQLNodeStore(db)
|
|
nodeID := seedRenewNode(t, db)
|
|
|
|
now := time.Now().UTC()
|
|
insertRenewCred(t, db, nodeID, "dp-active", now.Add(1*time.Hour)) // still active
|
|
|
|
newExp := now.Add(nodes.PaidCredentialTTL)
|
|
if err := st.RenewCredential(context.Background(), "dp-active", newExp); err != nil {
|
|
t.Fatalf("RenewCredential: %v", err)
|
|
}
|
|
|
|
got := readRenewExpiry(t, db, "dp-active")
|
|
if got.Sub(newExp).Abs() > time.Second {
|
|
t.Errorf("expiry = %v, want ~%v (renewed)", got, newExp)
|
|
}
|
|
}
|
|
|
|
// TestRenewCredential_LeavesExpired: an already-expired credential is NOT
|
|
// resurrected (the expires_at > now guard) — a disconnected session must go
|
|
// through a fresh /connect, not get silently revived by a late usage report.
|
|
func TestRenewCredential_LeavesExpired(t *testing.T) {
|
|
db := openRenewTestDB(t)
|
|
st := nodes.NewSQLNodeStore(db)
|
|
nodeID := seedRenewNode(t, db)
|
|
|
|
now := time.Now().UTC()
|
|
expiredAt := now.Add(-1 * time.Hour)
|
|
insertRenewCred(t, db, nodeID, "dp-expired", expiredAt) // already expired
|
|
|
|
if err := st.RenewCredential(context.Background(), "dp-expired", now.Add(nodes.PaidCredentialTTL)); err != nil {
|
|
t.Fatalf("RenewCredential: %v", err)
|
|
}
|
|
|
|
got := readRenewExpiry(t, db, "dp-expired")
|
|
if got.After(now) {
|
|
t.Errorf("expiry = %v, want unchanged (still expired, before %v)", got, now)
|
|
}
|
|
}
|