fix(server): 付费会话「活跃即续期」——根治 macOS 常驻隧道一天多掉线
根因:付费连接凭证硬编码 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
This commit is contained in:
@@ -21,7 +21,9 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// paidCredentialTTL is the default connect credential lifetime for paid users.
|
// paidCredentialTTL is the default connect credential lifetime for paid users.
|
||||||
paidCredentialTTL = 24 * time.Hour
|
// Single source in the nodes package: ReportUsage 的「活跃即续期」用同一常量续期,
|
||||||
|
// 两处 24h 不会漂移。
|
||||||
|
paidCredentialTTL = nodes.PaidCredentialTTL
|
||||||
// freeCredentialTTL is the per-minute TTL for free users (per remaining minutes).
|
// freeCredentialTTL is the per-minute TTL for free users (per remaining minutes).
|
||||||
freeMinuteTTL = time.Minute
|
freeMinuteTTL = time.Minute
|
||||||
// deviceStaleWindow: connect 设备上限 backstop 只数近此窗口活跃的设备(与 devices 侧一致)。
|
// deviceStaleWindow: connect 设备上限 backstop 只数近此窗口活跃的设备(与 devices 侧一致)。
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,15 @@ type mockNodeStore struct {
|
|||||||
devicesByDpUUID map[string][2]int64
|
devicesByDpUUID map[string][2]int64
|
||||||
deviceUsageAccum []mockDeviceUsageEntry
|
deviceUsageAccum []mockDeviceUsageEntry
|
||||||
lastSeenTouched []int64
|
lastSeenTouched []int64
|
||||||
|
// ent is returned by EntitlementForUser (nil = no subscription = free plan).
|
||||||
|
ent *nodes.Entitlement
|
||||||
|
// renewed logs RenewCredential calls (renew-on-activity assertions).
|
||||||
|
renewed []mockRenewEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockRenewEntry struct {
|
||||||
|
DpUUID string
|
||||||
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockUsageEntry struct {
|
type mockUsageEntry struct {
|
||||||
@@ -99,13 +108,28 @@ func (m *mockNodeStore) ListUp(_ context.Context) ([]*nodes.NodeRow, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockNodeStore) EntitlementForUser(_ context.Context, _ int64) (*nodes.Entitlement, error) {
|
func (m *mockNodeStore) EntitlementForUser(_ context.Context, _ int64) (*nodes.Entitlement, error) {
|
||||||
return nil, nil
|
return m.ent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockNodeStore) PersistCredential(_ context.Context, _ int64, _ *agentv1.Credential, _ time.Time) error {
|
func (m *mockNodeStore) PersistCredential(_ context.Context, _ int64, _ *agentv1.Credential, _ time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockNodeStore) RenewCredential(_ context.Context, dpUUID string, newExpiresAt time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.renewed = append(m.renewed, mockRenewEntry{dpUUID, newExpiresAt})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNodeStore) renewLog() []mockRenewEntry {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]mockRenewEntry, len(m.renewed))
|
||||||
|
copy(out, m.renewed)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) error {
|
func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1050,3 +1074,81 @@ func TestReportUsage_PerDevice(t *testing.T) {
|
|||||||
t.Errorf("device usage wrong: %+v", dev[0])
|
t.Errorf("device usage wrong: %+v", dev[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReportUsage_RenewsPaidCredential verifies renew-on-activity: a PAID user's
|
||||||
|
// usage report bumps its data-plane credential expiry ~PaidCredentialTTL into the
|
||||||
|
// future — so a persistent session never hits the TTL wall mid-connection (the
|
||||||
|
// root cause of "connected ~1 day then silently drops" on always-on macOS).
|
||||||
|
func TestReportUsage_RenewsPaidCredential(t *testing.T) {
|
||||||
|
const nodeUUID = "test-node-renew-paid"
|
||||||
|
b := newTestServer(t, 1, nodeUUID)
|
||||||
|
ctx := context.Background()
|
||||||
|
b.store.devicesByDpUUID = map[string][2]int64{"dp-paid": {101, 55}}
|
||||||
|
b.store.ent = &nodes.Entitlement{AdGate: false} // paid plan
|
||||||
|
|
||||||
|
_, _, conn := enrollNode(t, b, nodeUUID)
|
||||||
|
client := agentv1.NewAgentServiceClient(conn)
|
||||||
|
if _, err := client.Register(ctx, &agentv1.RegisterRequest{NodeUUID: nodeUUID}); err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now().UTC()
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := client.ReportUsage(ctx, &agentv1.UsageReport{
|
||||||
|
NodeUUID: nodeUUID,
|
||||||
|
WindowStartUnix: now.Add(-time.Minute).Unix(),
|
||||||
|
WindowEndUnix: now.Unix(),
|
||||||
|
Entries: []*agentv1.UsageEntry{
|
||||||
|
{DpUUID: "dp-paid", BytesUp: 100, BytesDown: 200, SessionMinutes: 1},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ReportUsage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renewed := b.store.renewLog()
|
||||||
|
if len(renewed) != 1 {
|
||||||
|
t.Fatalf("renew calls = %d, want 1 (paid session should renew)", len(renewed))
|
||||||
|
}
|
||||||
|
if renewed[0].DpUUID != "dp-paid" {
|
||||||
|
t.Errorf("renewed dp_uuid = %q, want dp-paid", renewed[0].DpUUID)
|
||||||
|
}
|
||||||
|
// Expiry should land ~PaidCredentialTTL from now (allow the test's own runtime slack).
|
||||||
|
wantMin := before.Add(nodes.PaidCredentialTTL)
|
||||||
|
wantMax := time.Now().UTC().Add(nodes.PaidCredentialTTL + time.Minute)
|
||||||
|
if renewed[0].ExpiresAt.Before(wantMin) || renewed[0].ExpiresAt.After(wantMax) {
|
||||||
|
t.Errorf("renewed expiry %v out of [%v, %v]", renewed[0].ExpiresAt, wantMin, wantMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReportUsage_DoesNotRenewFreeCredential verifies free credentials are NEVER
|
||||||
|
// renewed: their TTL encodes the daily-minute quota, and renewing would bypass the
|
||||||
|
// data-plane hard cut-off that enforces it.
|
||||||
|
func TestReportUsage_DoesNotRenewFreeCredential(t *testing.T) {
|
||||||
|
const nodeUUID = "test-node-renew-free"
|
||||||
|
b := newTestServer(t, 1, nodeUUID)
|
||||||
|
ctx := context.Background()
|
||||||
|
b.store.devicesByDpUUID = map[string][2]int64{"dp-free": {202, 66}}
|
||||||
|
b.store.ent = &nodes.Entitlement{AdGate: true} // free plan (minute-quota-gated)
|
||||||
|
|
||||||
|
_, _, conn := enrollNode(t, b, nodeUUID)
|
||||||
|
client := agentv1.NewAgentServiceClient(conn)
|
||||||
|
if _, err := client.Register(ctx, &agentv1.RegisterRequest{NodeUUID: nodeUUID}); err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := client.ReportUsage(ctx, &agentv1.UsageReport{
|
||||||
|
NodeUUID: nodeUUID,
|
||||||
|
WindowStartUnix: now.Add(-time.Minute).Unix(),
|
||||||
|
WindowEndUnix: now.Unix(),
|
||||||
|
Entries: []*agentv1.UsageEntry{
|
||||||
|
{DpUUID: "dp-free", BytesUp: 100, BytesDown: 200, SessionMinutes: 1},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ReportUsage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if renewed := b.store.renewLog(); len(renewed) != 0 {
|
||||||
|
t.Fatalf("renew calls = %d, want 0 (free credential must NOT be renewed)", len(renewed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -297,6 +297,25 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
|
|||||||
type acctAgg struct{ bytesUp, bytesDown, minutes int64 }
|
type acctAgg struct{ bytesUp, bytesDown, minutes int64 }
|
||||||
byUser := make(map[int64]*acctAgg)
|
byUser := make(map[int64]*acctAgg)
|
||||||
|
|
||||||
|
// paidUser caches each user's paid/free status for this report so the
|
||||||
|
// renew-on-activity path below does at most one entitlement lookup per user.
|
||||||
|
paidUser := make(map[int64]bool)
|
||||||
|
isPaid := func(userID int64) bool {
|
||||||
|
if v, ok := paidUser[userID]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
ent, err := h.store.EntitlementForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("nodes.Handler.ReportUsage: entitlement lookup failed",
|
||||||
|
"user_id", userID, "err", err)
|
||||||
|
}
|
||||||
|
paid := ent != nil && !ent.AdGate // AdGate = free plan (minute-quota-gated)
|
||||||
|
paidUser[userID] = paid
|
||||||
|
return paid
|
||||||
|
}
|
||||||
|
|
||||||
|
renewedAt := time.Now().UTC().Add(PaidCredentialTTL)
|
||||||
|
|
||||||
for _, entry := range req.Entries {
|
for _, entry := range req.Entries {
|
||||||
if entry.DpUUID == "" {
|
if entry.DpUUID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -310,6 +329,21 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
|
|||||||
if !found {
|
if !found {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Renew-on-activity: a live PAID session keeps sending usage reports, so
|
||||||
|
// bump its data-plane credential expiry forward each window — it never hits
|
||||||
|
// the PaidCredentialTTL wall mid-session. This is the fix for "connected ~1
|
||||||
|
// day then silently drops": the client only re-issues a credential on an
|
||||||
|
// in-foreground reconnect, which a persistent macOS sysext tunnel (GUI app
|
||||||
|
// closed → no watchdog) never triggers. Server-side renewal is client-
|
||||||
|
// lifecycle-independent, so it fixes all platforms at once. Free credentials
|
||||||
|
// encode the daily-minute quota in their TTL — never renew them (would
|
||||||
|
// bypass the hard cut-off), so this is gated to paid plans.
|
||||||
|
if isPaid(userID) {
|
||||||
|
if err := h.store.RenewCredential(ctx, entry.DpUUID, renewedAt); err != nil {
|
||||||
|
slog.Warn("nodes.Handler.ReportUsage: renew credential failed",
|
||||||
|
"dp_uuid", entry.DpUUID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Fold into the per-user account aggregate (bytes sum, minutes max = 墙上时钟去重).
|
// Fold into the per-user account aggregate (bytes sum, minutes max = 墙上时钟去重).
|
||||||
a := byUser[userID]
|
a := byUser[userID]
|
||||||
if a == nil {
|
if a == nil {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ import (
|
|||||||
// connect 据此拒连并提示重新登录(重新注册设备),不再回退账户级 dp_uuid。
|
// connect 据此拒连并提示重新登录(重新注册设备),不再回退账户级 dp_uuid。
|
||||||
var ErrDeviceNotFound = errors.New("device not registered")
|
var ErrDeviceNotFound = errors.New("device not registered")
|
||||||
|
|
||||||
|
// PaidCredentialTTL is the付费连接凭证有效期(单一真相源)。httpapi 签发凭证与
|
||||||
|
// ReportUsage 的「活跃即续期」都引用它,避免两处 24h 漂移。到期后 CredentialsForNode
|
||||||
|
// 的 WHERE expires_at > now 会把它挡在 agent 快照外 → 下次 agent 重注册重渲染即踢下线。
|
||||||
|
// 续期机制(见 RenewCredential)让活跃付费会话永不撞这道墙;免费凭证 TTL 编码日额度、
|
||||||
|
// 不走此常量、也不续期。
|
||||||
|
const PaidCredentialTTL = 24 * time.Hour
|
||||||
|
|
||||||
// NodeRow holds a node's essential fields from the nodes table.
|
// NodeRow holds a node's essential fields from the nodes table.
|
||||||
type NodeRow struct {
|
type NodeRow struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -72,6 +79,13 @@ type NodeStore interface {
|
|||||||
// PersistCredential upserts a credential row in connect_credentials.
|
// PersistCredential upserts a credential row in connect_credentials.
|
||||||
PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error
|
PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error
|
||||||
|
|
||||||
|
// RenewCredential 把某 dp_uuid 仍活跃(未过期)凭证的 expires_at 顶到 newExpiresAt
|
||||||
|
// ——「活跃即续期」用:付费会话持续上报用量时把有效期不断往后推,让常驻隧道(尤其
|
||||||
|
// macOS sysext,GUI app 关闭后客户端看门狗不运行、不会重连重签)不再在 24h 撞过期墙。
|
||||||
|
// 只更新 expires_at > now 的行:绝不复活已过期凭证(那属于已断开会话,须重新连接)。
|
||||||
|
// 调用方须只对付费凭证调用;免费凭证 TTL 编码日额度,续期会击穿日限。
|
||||||
|
RenewCredential(ctx context.Context, dpUUID string, newExpiresAt time.Time) error
|
||||||
|
|
||||||
// DeleteCredential removes the credential for (nodeID, dpUUID).
|
// DeleteCredential removes the credential for (nodeID, dpUUID).
|
||||||
DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error
|
DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error
|
||||||
|
|
||||||
@@ -340,6 +354,18 @@ func (s *SQLNodeStore) PersistCredential(ctx context.Context, nodeID int64, cred
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenewCredential extends the expiry of dpUUID's still-active credential(s) to
|
||||||
|
// newExpiresAt. The `expires_at > ?` guard means an already-expired row is left
|
||||||
|
// untouched (never resurrect a disconnected session). Portable SQL: bind
|
||||||
|
// newExpiresAt then now, both UTC.
|
||||||
|
func (s *SQLNodeStore) RenewCredential(ctx context.Context, dpUUID string, newExpiresAt time.Time) error {
|
||||||
|
const q = `UPDATE connect_credentials SET expires_at = ? WHERE dp_uuid = ? AND expires_at > ?`
|
||||||
|
if _, err := s.db.ExecContext(ctx, q, newExpiresAt.UTC(), dpUUID, time.Now().UTC()); err != nil {
|
||||||
|
return fmt.Errorf("nodes.SQLNodeStore.RenewCredential: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CredentialLocation identifies a node holding a given dp_uuid credential.
|
// CredentialLocation identifies a node holding a given dp_uuid credential.
|
||||||
type CredentialLocation struct {
|
type CredentialLocation struct {
|
||||||
NodeID int64
|
NodeID int64
|
||||||
|
|||||||
Reference in New Issue
Block a user