panel — open-source game server manager (public release)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:19:43 -07:00
commit 658bda1d24
2160 changed files with 300413 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
package db
import (
"context"
"fmt"
"time"
)
// AgentRow is the persisted form of an agent record.
type AgentRow struct {
AgentID string
Version string
HostOS string
HostArch string
Hostname string
Label string
Notes string
// PortRangeStart/End define the inclusive host-port window the panel
// allocates from when creating new instances on this agent. Both 0
// (unset) means use the global default 7000-9999. Set via the agent
// edit modal so operators can isolate per-agent port pools (e.g.
// agent A: 7000-7999, agent B: 8000-8999) for firewall sanity.
PortRangeStart int32
PortRangeEnd int32
// LANIP is the agent's RFC1918 LAN IP — used as the `target` field
// when the opnfwd integration creates a port forward for an instance
// on this agent. Empty disables the integration for this agent (loud
// log, no-op). Operator-edited via the agent edit modal.
LANIP string
FirstSeen time.Time
LastSeen time.Time
}
// UpsertAgent inserts the agent or updates its metadata on reconnect,
// bumping last_seen. first_seen is preserved on conflict.
func (db *DB) UpsertAgent(ctx context.Context, a AgentRow) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO agents (agent_id, version, host_os, host_arch, hostname, first_seen, last_seen)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (agent_id) DO UPDATE SET
version = EXCLUDED.version,
host_os = EXCLUDED.host_os,
host_arch = EXCLUDED.host_arch,
hostname = EXCLUDED.hostname,
last_seen = NOW()
`, a.AgentID, a.Version, a.HostOS, a.HostArch, a.Hostname)
if err != nil {
return fmt.Errorf("upsert agent: %w", err)
}
return nil
}
// TouchAgent bumps last_seen for an agent. Used on heartbeat arrival.
func (db *DB) TouchAgent(ctx context.Context, agentID string) error {
_, err := db.pool.Exec(ctx, `UPDATE agents SET last_seen = NOW() WHERE agent_id = $1`, agentID)
if err != nil {
return fmt.Errorf("touch agent: %w", err)
}
return nil
}
// UpdateAgentMeta sets operator-assigned display fields.
func (db *DB) UpdateAgentMeta(ctx context.Context, agentID, label, notes string) error {
_, err := db.pool.Exec(ctx, `UPDATE agents SET label = $2, notes = $3 WHERE agent_id = $1`, agentID, label, notes)
if err != nil {
return fmt.Errorf("update agent meta: %w", err)
}
return nil
}
// SetAgentPortRange persists the host-port window this agent allocates
// new instances from. Pass 0,0 to clear (allocator falls back to global
// default). Validates that start <= end and both fit in uint16 range.
func (db *DB) SetAgentPortRange(ctx context.Context, agentID string, start, end int32) error {
if start == 0 && end == 0 {
_, err := db.pool.Exec(ctx, `UPDATE agents SET port_range_start = NULL, port_range_end = NULL WHERE agent_id = $1`, agentID)
if err != nil {
return fmt.Errorf("clear port range: %w", err)
}
return nil
}
if start <= 0 || end <= 0 || start > 65535 || end > 65535 || start > end {
return fmt.Errorf("invalid port range %d-%d (must be 1-65535 and start<=end)", start, end)
}
_, err := db.pool.Exec(ctx, `UPDATE agents SET port_range_start = $2, port_range_end = $3 WHERE agent_id = $1`, agentID, start, end)
if err != nil {
return fmt.Errorf("set port range: %w", err)
}
return nil
}
// GetAgentPortRange returns the configured window or (0,0) if unset.
// (0,0) means "use the global default" — allocator decides what.
func (db *DB) GetAgentPortRange(ctx context.Context, agentID string) (start, end int32, err error) {
var s, e *int32
err = db.pool.QueryRow(ctx, `SELECT port_range_start, port_range_end FROM agents WHERE agent_id = $1`, agentID).Scan(&s, &e)
if err != nil {
return 0, 0, err
}
if s != nil {
start = *s
}
if e != nil {
end = *e
}
return
}
// GetAgentMeta returns just the label + notes (avoids pulling the full row
// when we already have everything else from the in-memory agent registry).
func (db *DB) GetAgentMeta(ctx context.Context, agentID string) (label, notes string, err error) {
err = db.pool.QueryRow(ctx, `SELECT COALESCE(label, ''), COALESCE(notes, '') FROM agents WHERE agent_id = $1`, agentID).Scan(&label, &notes)
return
}
// GetAgentLANIP returns the operator-set LAN IP for this agent. Empty
// string means the operator hasn't filled it in — opnfwd integration
// no-ops for instances on this agent until they do.
func (db *DB) GetAgentLANIP(ctx context.Context, agentID string) (string, error) {
var v string
err := db.pool.QueryRow(ctx, `SELECT COALESCE(lan_ip, '') FROM agents WHERE agent_id = $1`, agentID).Scan(&v)
return v, err
}
// SetAgentLANIP persists the LAN IP. Empty string is allowed (clears).
// Caller is responsible for sanity-checking the IP format.
func (db *DB) SetAgentLANIP(ctx context.Context, agentID, ip string) error {
_, err := db.pool.Exec(ctx, `UPDATE agents SET lan_ip = $2 WHERE agent_id = $1`, agentID, ip)
if err != nil {
return fmt.Errorf("set agent lan_ip: %w", err)
}
return nil
}
// DeleteAgent removes an agent record. The agent's cert remains valid
// until expiry — true revocation would require a CRL; for MVP this is a
// forget-from-DB. If the agent is live it'll just re-register next beat.
func (db *DB) DeleteAgent(ctx context.Context, agentID string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM agents WHERE agent_id = $1`, agentID)
if err != nil {
return fmt.Errorf("delete agent: %w", err)
}
return nil
}
// ListAgents returns every row in the agents table. The in-memory registry
// only knows about live-connected agents, but the dashboard needs to surface
// offline ones too so instances owned by an offline agent don't silently
// disappear from the UI. Caller merges live + DB and marks the DB-only ones
// as offline.
func (db *DB) ListAgents(ctx context.Context) ([]AgentRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT agent_id, version, host_os, host_arch, hostname,
COALESCE(label, ''), COALESCE(notes, ''),
COALESCE(port_range_start, 0), COALESCE(port_range_end, 0),
COALESCE(lan_ip, ''),
first_seen, last_seen
FROM agents
ORDER BY agent_id`)
if err != nil {
return nil, fmt.Errorf("list agents: %w", err)
}
defer rows.Close()
var out []AgentRow
for rows.Next() {
var a AgentRow
if err := rows.Scan(&a.AgentID, &a.Version, &a.HostOS, &a.HostArch, &a.Hostname,
&a.Label, &a.Notes, &a.PortRangeStart, &a.PortRangeEnd, &a.LANIP,
&a.FirstSeen, &a.LastSeen); err != nil {
return nil, fmt.Errorf("scan agent row: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
+76
View File
@@ -0,0 +1,76 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// BackupRow is the persisted form of a backup.
type BackupRow struct {
ID string
InstanceID string
AgentID string
Path string
SizeBytes int64
Description string
CreatedAt time.Time
}
func (db *DB) InsertBackup(ctx context.Context, r BackupRow) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO backups (id, instance_id, agent_id, path, size_bytes, description)
VALUES ($1, $2, $3, $4, $5, $6)
`, r.ID, r.InstanceID, r.AgentID, r.Path, r.SizeBytes, r.Description)
if err != nil {
return fmt.Errorf("insert backup: %w", err)
}
return nil
}
func (db *DB) DeleteBackup(ctx context.Context, id string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM backups WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete backup: %w", err)
}
return nil
}
func (db *DB) GetBackup(ctx context.Context, id string) (*BackupRow, error) {
var r BackupRow
err := db.pool.QueryRow(ctx, `
SELECT id, instance_id, agent_id, path, size_bytes, description, created_at
FROM backups WHERE id = $1
`, id).Scan(&r.ID, &r.InstanceID, &r.AgentID, &r.Path, &r.SizeBytes, &r.Description, &r.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("backup not found")
}
if err != nil {
return nil, fmt.Errorf("get backup: %w", err)
}
return &r, nil
}
func (db *DB) ListBackupsByInstance(ctx context.Context, instanceID string) ([]BackupRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, instance_id, agent_id, path, size_bytes, description, created_at
FROM backups WHERE instance_id = $1
ORDER BY created_at DESC
`, instanceID)
if err != nil {
return nil, fmt.Errorf("list backups: %w", err)
}
defer rows.Close()
var out []BackupRow
for rows.Next() {
var r BackupRow
if err := rows.Scan(&r.ID, &r.InstanceID, &r.AgentID, &r.Path, &r.SizeBytes, &r.Description, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
+58
View File
@@ -0,0 +1,58 @@
// Package db owns the Controller's durable state in Postgres. It exposes
// a small typed surface (UpsertAgent, InsertInstance, ListInstances, …)
// built on pgx/v5 with a connection pool. Migrations are embedded so the
// binary is self-contained.
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// DB wraps a pgx pool with our typed query methods.
type DB struct {
pool *pgxpool.Pool
}
// Open connects to Postgres using dsn, pings once to fail fast on bad
// credentials or unreachable server, and runs any pending migrations.
func Open(ctx context.Context, dsn string) (*DB, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse db dsn: %w", err)
}
cfg.MaxConns = 10
cfg.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("new pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
if err := Migrate(ctx, pool); err != nil {
pool.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return &DB{pool: pool}, nil
}
// Close releases pool resources.
func (db *DB) Close() {
if db != nil && db.pool != nil {
db.pool.Close()
}
}
// Pool exposes the underlying pool for tests / advanced callers.
func (db *DB) Pool() *pgxpool.Pool { return db.pool }
+104
View File
@@ -0,0 +1,104 @@
package db
import (
"context"
"fmt"
"time"
)
// InstanceForwardRow is one panel-owned port forward in opnfwd. Composite
// key (instance_id, port_name) — the panel allocates one forward per
// manifest port per instance.
type InstanceForwardRow struct {
InstanceID string
PortName string
NatUUID string
FilterUUID string
TargetIP string
HostPort int32
Proto string // "tcp" | "udp" | "tcp/udp"
Descr string // "panel-<instance>-<port_name>" — matches the OPNsense rule's <descr>
CreatedAt time.Time
}
// UpsertInstanceForward persists a single forward, replacing any prior row
// for the same (instance, port_name). The replace is intentional — if the
// panel re-creates a forward for an existing port (e.g. operator clicked
// Reconcile and we recreated), the new uuids supersede the old.
func (db *DB) UpsertInstanceForward(ctx context.Context, r InstanceForwardRow) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO instance_forwards (instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (instance_id, port_name) DO UPDATE SET
nat_uuid = EXCLUDED.nat_uuid,
filter_uuid = EXCLUDED.filter_uuid,
target_ip = EXCLUDED.target_ip,
host_port = EXCLUDED.host_port,
proto = EXCLUDED.proto,
descr = EXCLUDED.descr
`, r.InstanceID, r.PortName, r.NatUUID, r.FilterUUID, r.TargetIP, r.HostPort, r.Proto, r.Descr)
if err != nil {
return fmt.Errorf("upsert instance_forward: %w", err)
}
return nil
}
// ListInstanceForwards returns every forward for the given instance, used
// at delete time + by the instance-modal "Forwards" pill.
func (db *DB) ListInstanceForwards(ctx context.Context, instanceID string) ([]InstanceForwardRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr, created_at
FROM instance_forwards
WHERE instance_id = $1
ORDER BY port_name
`, instanceID)
if err != nil {
return nil, fmt.Errorf("list instance_forwards: %w", err)
}
defer rows.Close()
var out []InstanceForwardRow
for rows.Next() {
var r InstanceForwardRow
if err := rows.Scan(&r.InstanceID, &r.PortName, &r.NatUUID, &r.FilterUUID,
&r.TargetIP, &r.HostPort, &r.Proto, &r.Descr, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("scan instance_forward: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// ListAllInstanceForwards is for the reconcile path — compare every
// panel-tracked forward against opnfwd's view of the world.
func (db *DB) ListAllInstanceForwards(ctx context.Context) ([]InstanceForwardRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr, created_at
FROM instance_forwards
ORDER BY instance_id, port_name
`)
if err != nil {
return nil, fmt.Errorf("list all instance_forwards: %w", err)
}
defer rows.Close()
var out []InstanceForwardRow
for rows.Next() {
var r InstanceForwardRow
if err := rows.Scan(&r.InstanceID, &r.PortName, &r.NatUUID, &r.FilterUUID,
&r.TargetIP, &r.HostPort, &r.Proto, &r.Descr, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("scan instance_forward: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// DeleteInstanceForwards removes every panel-tracked forward for an
// instance. Called from the delete path AFTER opnfwd's bulk-delete
// returned successfully — this just drops the local mirror.
func (db *DB) DeleteInstanceForwards(ctx context.Context, instanceID string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM instance_forwards WHERE instance_id = $1`, instanceID)
if err != nil {
return fmt.Errorf("delete instance_forwards: %w", err)
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// InstanceRow is the persisted form of an instance record.
type InstanceRow struct {
InstanceID string
AgentID string
ModuleID string
ModuleVersion string
RunMode string
DataPath string
ConfigValues map[string]string
// AssignedPorts maps manifest port name → host port the panel
// allocated. Persisted so stopped instances still reserve their
// slots — net.Listen probing can't see ports for instances that
// aren't currently running, and would otherwise hand the same
// port to a new instance.
AssignedPorts map[string]uint32
Status string // 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'updating'
LastExitCode int32
Detail string
CreatedAt time.Time
UpdatedAt time.Time
}
// UpsertInstance inserts or updates an instance by instance_id. Used from
// CreateInstance — it's idempotent so retry is safe.
func (db *DB) UpsertInstance(ctx context.Context, r InstanceRow) error {
cfgJSON, err := json.Marshal(r.ConfigValues)
if err != nil {
return fmt.Errorf("marshal config_values: %w", err)
}
if r.AssignedPorts == nil {
r.AssignedPorts = map[string]uint32{}
}
portsJSON, err := json.Marshal(r.AssignedPorts)
if err != nil {
return fmt.Errorf("marshal assigned_ports: %w", err)
}
_, err = db.pool.Exec(ctx, `
INSERT INTO instances
(instance_id, agent_id, module_id, module_version, run_mode, data_path, config_values, assigned_ports, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (instance_id) DO UPDATE SET
agent_id = EXCLUDED.agent_id,
module_id = EXCLUDED.module_id,
module_version = EXCLUDED.module_version,
run_mode = EXCLUDED.run_mode,
data_path = EXCLUDED.data_path,
config_values = EXCLUDED.config_values,
assigned_ports = EXCLUDED.assigned_ports,
status = EXCLUDED.status,
updated_at = NOW()
`, r.InstanceID, r.AgentID, r.ModuleID, r.ModuleVersion, r.RunMode, r.DataPath, cfgJSON, portsJSON, r.Status)
if err != nil {
return fmt.Errorf("upsert instance: %w", err)
}
return nil
}
// UpdateInstanceConfigValues replaces ONLY the config_values column. Used by
// the durable config-render path, which must not touch status / data_path /
// assigned_ports the way the full UpsertInstance does (a config save racing
// an instance start could otherwise stomp a live status transition).
func (db *DB) UpdateInstanceConfigValues(ctx context.Context, instanceID string, values map[string]string) error {
cfgJSON, err := json.Marshal(values)
if err != nil {
return fmt.Errorf("marshal config_values: %w", err)
}
_, err = db.pool.Exec(ctx, `
UPDATE instances SET config_values = $2, updated_at = NOW()
WHERE instance_id = $1
`, instanceID, cfgJSON)
if err != nil {
return fmt.Errorf("update instance config_values: %w", err)
}
return nil
}
// UpdateInstanceStatus records a status transition from an InstanceStateUpdate.
// updated_at is only bumped when the status actually changed — re-announces
// from agent reconnects (e.g. controller restart triggering an announce loop)
// would otherwise reset the timestamp every time, which breaks the frontend's
// "instance has been running for >10 min" heuristic for clearing the
// "starting" overlay on log-only modules.
func (db *DB) UpdateInstanceStatus(ctx context.Context, instanceID, status string, exitCode int32, detail string) error {
_, err := db.pool.Exec(ctx, `
UPDATE instances
SET status = $2,
last_exit_code = $3,
detail = $4,
updated_at = CASE WHEN status IS DISTINCT FROM $2 THEN NOW() ELSE updated_at END
WHERE instance_id = $1
`, instanceID, status, exitCode, detail)
if err != nil {
return fmt.Errorf("update instance status: %w", err)
}
return nil
}
// DeleteInstance is not yet used; included so the schema doesn't grow a
// parallel soft-delete pattern before we need one. Call when an operator
// permanently removes an instance.
func (db *DB) DeleteInstance(ctx context.Context, instanceID string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM instances WHERE instance_id = $1`, instanceID)
if err != nil {
return fmt.Errorf("delete instance: %w", err)
}
return nil
}
// GetInstanceRow returns one instance row by ID, or an error if missing.
func (db *DB) GetInstanceRow(ctx context.Context, instanceID string) (InstanceRow, error) {
const q = `
SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''),
run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status,
COALESCE(last_exit_code, 0), COALESCE(detail, ''),
created_at, updated_at
FROM instances WHERE instance_id = $1
`
var r InstanceRow
var cfgJSON, portsJSON []byte
err := db.pool.QueryRow(ctx, q, instanceID).Scan(
&r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion,
&r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status,
&r.LastExitCode, &r.Detail,
&r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return r, fmt.Errorf("get instance %q: %w", instanceID, err)
}
if len(cfgJSON) > 0 {
_ = json.Unmarshal(cfgJSON, &r.ConfigValues)
}
if len(portsJSON) > 0 {
_ = json.Unmarshal(portsJSON, &r.AssignedPorts)
}
return r, nil
}
// ListInstances returns all instances, optionally filtered by agent_id.
func (db *DB) ListInstances(ctx context.Context, agentFilter string) ([]InstanceRow, error) {
var rows pgx.Rows
var err error
const selectAll = `
SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''),
run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status,
COALESCE(last_exit_code, 0), COALESCE(detail, ''),
created_at, updated_at
FROM instances
`
if agentFilter != "" {
rows, err = db.pool.Query(ctx, selectAll+` WHERE agent_id = $1 ORDER BY instance_id`, agentFilter)
} else {
rows, err = db.pool.Query(ctx, selectAll+` ORDER BY instance_id`)
}
if err != nil {
return nil, fmt.Errorf("list instances: %w", err)
}
defer rows.Close()
var out []InstanceRow
for rows.Next() {
var r InstanceRow
var cfgJSON, portsJSON []byte
if err := rows.Scan(
&r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion,
&r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status,
&r.LastExitCode, &r.Detail,
&r.CreatedAt, &r.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan instance: %w", err)
}
if len(cfgJSON) > 0 {
_ = json.Unmarshal(cfgJSON, &r.ConfigValues)
}
if len(portsJSON) > 0 {
_ = json.Unmarshal(portsJSON, &r.AssignedPorts)
}
out = append(out, r)
}
return out, rows.Err()
}
+95
View File
@@ -0,0 +1,95 @@
package db
import (
"context"
"embed"
"fmt"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// migrateLockKey is the pg advisory-lock key serializing Migrate across
// controllers sharing one database. Arbitrary but fixed: fnv-ish of
// "panel:schema_migrations". Session-scoped (not transaction-scoped) so it
// spans the whole multi-transaction run and releases on disconnect if the
// process dies mid-migrate.
const migrateLockKey int64 = 0x70616e656c6d6967 // "panelmig"
// Migrate applies any pending SQL files in migrations/ in filename-sorted
// order. Each file is wrapped in a transaction alongside its schema_migrations
// insert so failure leaves the DB in a consistent state. A session-level
// pg advisory lock serializes concurrent controllers: the second one blocks
// here, then finds every version already recorded and no-ops.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
// Dedicated connection: session advisory locks belong to a connection,
// and pool.Exec could lock on one conn and unlock on another.
lockConn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire migrate lock conn: %w", err)
}
defer lockConn.Release()
if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockKey); err != nil {
return fmt.Errorf("acquire migrate advisory lock: %w", err)
}
defer func() {
_, _ = lockConn.Exec(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrateLockKey)
}()
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, name := range files {
var exists bool
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name,
).Scan(&exists); err != nil {
return fmt.Errorf("check %s: %w", name, err)
}
if exists {
continue
}
body, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(ctx, string(body)); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, name); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
}
return nil
}
+49
View File
@@ -0,0 +1,49 @@
package db
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestMigrateConcurrent runs Migrate from two goroutines against a real
// Postgres and asserts neither errors (the advisory lock serializes them;
// the loser sees every version recorded and no-ops). Requires a live DB:
// set PANEL_TEST_DSN to run, e.g.
//
// PANEL_TEST_DSN=postgres://user:pw@localhost:5432/panel_test go test ./controller/internal/db/
//
// Skipped otherwise — no Postgres is assumed on dev machines or CI.
func TestMigrateConcurrent(t *testing.T) {
dsn := os.Getenv("PANEL_TEST_DSN")
if dsn == "" {
t.Skip("PANEL_TEST_DSN not set; migration lock test needs a live Postgres")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
var wg sync.WaitGroup
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
errs[i] = Migrate(ctx, pool)
}(i)
}
wg.Wait()
for i, e := range errs {
if e != nil {
t.Errorf("concurrent Migrate %d: %v", i, e)
}
}
}
@@ -0,0 +1,39 @@
-- 001_init.sql — initial schema
--
-- agents: one row per Target agent the controller has ever seen. Updated
-- with each connection + heartbeat. Serves as audit trail — we
-- never delete rows, so an agent disappearing from the network
-- doesn't wipe its history.
-- instances: one row per declared game server. The controller, not the
-- agent, is the source of truth for "instance X exists". Status
-- is updated from InstanceStateUpdate messages streamed up from
-- the agent that owns the instance.
CREATE TABLE agents (
agent_id TEXT PRIMARY KEY,
version TEXT NOT NULL,
host_os TEXT NOT NULL,
host_arch TEXT NOT NULL,
hostname TEXT NOT NULL,
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE instances (
instance_id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
module_id TEXT NOT NULL,
module_version TEXT,
run_mode TEXT NOT NULL DEFAULT 'docker',
data_path TEXT,
config_values JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'stopped',
last_exit_code INTEGER,
detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_instances_agent ON instances(agent_id);
CREATE INDEX idx_instances_module ON instances(module_id);
CREATE INDEX idx_instances_status ON instances(status);
@@ -0,0 +1,29 @@
-- 002_schedules.sql — timed / event-driven actions on instances.
--
-- For the MVP only cron triggers are supported; trigger_kind leaves room
-- for future 'event' / 'composite' without a schema change.
--
-- action is JSONB so new action types (webhook, exec, etc.) can be added
-- without migrations. The controller's scheduler engine validates shape
-- at dispatch time.
--
-- last_fired_at / last_status let the UI show whether a schedule is
-- actually doing what it says.
CREATE TABLE schedules (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
trigger_kind TEXT NOT NULL DEFAULT 'cron',
cron_spec TEXT NOT NULL,
action JSONB NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_fired_at TIMESTAMPTZ,
last_status TEXT,
last_detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_schedules_instance ON schedules(instance_id);
CREATE INDEX idx_schedules_enabled ON schedules(enabled) WHERE enabled = TRUE;
@@ -0,0 +1,29 @@
-- 003_users.sql — local accounts + session cookies.
--
-- Password hashes are argon2id in PHC string format: all params live in the
-- hash itself so we can bump time/memory costs without migrating.
--
-- Roles are coarse for now — admin (full control) / user (read-only). Fine-
-- grained per-resource ACL is a later migration layered on top.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE sessions (
token TEXT PRIMARY KEY, -- 32 bytes hex; random via crypto/rand
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_seen_ip TEXT,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
@@ -0,0 +1,20 @@
-- 004_event_triggers.sql — event-driven schedule triggers.
--
-- Until now schedules only supported cron. This migration adds:
-- * trigger_kind = 'event' schedules, powered by a CEL expression over
-- the event bus. Example: players_online == 0
-- * optional sustained-state: fire only after the condition has been
-- continuously true for N seconds. Enables "idle shutdown if 0 players
-- for 30m" without firing every 60s when the polled player count is 0.
--
-- cron_spec becomes nullable so event triggers can exist with NULL there.
-- Exactly one of cron_spec / event_spec is populated; the application
-- enforces this.
ALTER TABLE schedules ALTER COLUMN cron_spec DROP NOT NULL;
ALTER TABLE schedules
ADD COLUMN event_spec TEXT,
ADD COLUMN event_sustained_seconds INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_schedules_trigger_kind ON schedules(trigger_kind);
@@ -0,0 +1,18 @@
-- 005_backups.sql — instance backups (tar.gz of volumes).
--
-- The agent writes the archive to disk under its --backup-dir and
-- reports back path + size. Controller persists the row so operators
-- can list / restore / delete later.
CREATE TABLE backups (
id TEXT PRIMARY KEY, -- bkp_<hex16>
instance_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
path TEXT NOT NULL, -- absolute path on the agent
size_bytes BIGINT NOT NULL DEFAULT 0,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backups_instance ON backups(instance_id);
CREATE INDEX idx_backups_created ON backups(created_at DESC);
@@ -0,0 +1,21 @@
-- 006_pair_tokens.sql — pairing tokens for agent mTLS onboarding.
--
-- An operator generates a token, hands it to a new agent, the agent uses
-- it exactly once to fetch a signed client cert. Tokens expire fast
-- (default 15 min) to limit damage from leaked tokens. token_hash is
-- sha256 of the raw token — we never store the raw value.
CREATE TABLE pair_tokens (
id TEXT PRIMARY KEY, -- pt_<hex16>
token_hash TEXT NOT NULL UNIQUE, -- sha256 hex of the raw token
description TEXT NOT NULL DEFAULT '',
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
used_by_agent TEXT, -- agent_id that consumed it
issued_cert_fingerprint TEXT
);
CREATE INDEX idx_pair_tokens_hash ON pair_tokens(token_hash);
CREATE INDEX idx_pair_tokens_expires ON pair_tokens(expires_at);
@@ -0,0 +1,8 @@
-- 007_agent_labels.sql — operator-set display metadata for agents.
--
-- agent_id is the cryptographic identity (fixed, used in cert CN). Label
-- is a friendly name the operator assigns + can change. Notes is free-form.
ALTER TABLE agents
ADD COLUMN label TEXT NOT NULL DEFAULT '',
ADD COLUMN notes TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,25 @@
-- 008_steam_credentials.sql — operator's cached Steam login for SteamCMD.
--
-- Some games (DayZ app 223350, Arma, a handful of others) require a Steam
-- account that owns the game — anonymous SteamCMD login returns
-- "No subscription". The panel stores one Steam credential per operator
-- (for v1 we only support single-user mode, so a single row is enough).
--
-- password_ciphertext is AES-GCM encrypted using a key derived from the
-- controller's CA private key via HKDF-SHA256; see auth.go's
-- steamCredentialKey(). The plaintext password never touches disk or logs.
--
-- sentry_file is the SSFN-style device-auth blob SteamCMD writes after a
-- successful Steam Guard challenge. Storing it lets subsequent logins skip
-- the 2FA prompt. NULL until first successful login.
CREATE TABLE IF NOT EXISTS steam_credentials (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_ciphertext BYTEA NOT NULL,
password_nonce BYTEA NOT NULL,
sentry_file BYTEA, -- SSFN device-auth blob, filled after successful login
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,22 @@
-- 009_steam_login.sql — Steam OpenID sign-in support.
--
-- Adds a nullable `steam_id` column to `users` so an admin or operator
-- can log in with their Steam account instead of (or in addition to)
-- email + password.
--
-- steam_id stores the 17-digit SteamID64 as TEXT (not BIGINT) because
-- while the value fits in uint64, using TEXT avoids any sign-extension
-- surprises across the app / frontend / logs. Unique constraint so one
-- Steam account can only be linked to one panel user.
--
-- password_hash is made nullable: a user may exist who only signs in via
-- Steam with no local password. The email column stays NOT NULL — even
-- Steam-only users need a panel identity, we auto-fill with the
-- Steam-derived email "steam+<steamid>@panel.local" when there's nothing
-- better.
ALTER TABLE users ADD COLUMN steam_id TEXT;
ALTER TABLE users ADD CONSTRAINT users_steam_id_unique UNIQUE (steam_id);
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
CREATE INDEX idx_users_steam_id ON users(steam_id) WHERE steam_id IS NOT NULL;
@@ -0,0 +1,24 @@
-- 010_port_allocation.sql — per-agent port range + per-instance assigned ports
--
-- Why: probing net.Listen for "is this port free?" only catches CURRENTLY
-- bound ports. Two ARK servers, one running, one stopped — the running
-- one's 7777 binding is detected, but the stopped one's intent isn't.
-- Result: a fresh create assigns 7777 to a new server, then the stopped
-- one fails to start because something else now owns its port.
--
-- Fix: persist each instance's assigned host ports in the DB, and let
-- operators set a per-agent port range. Allocation now consults BOTH
-- the persisted assignments AND the live probe — whichever rules out
-- a port first wins.
ALTER TABLE agents
ADD COLUMN port_range_start INTEGER,
ADD COLUMN port_range_end INTEGER;
-- assigned_ports holds the actual host ports the panel allocated for
-- this instance, keyed by manifest port name. Example:
-- {"game": 7777, "raw": 7778, "rcon": 27020}
-- Stays attached even when the instance is stopped — that's the whole
-- point: stopped instances reserve their ports against the next create.
ALTER TABLE instances
ADD COLUMN assigned_ports JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,47 @@
-- 011_opnfwd.sql — wire panel into opnfwd's port-forward HTTP API.
--
-- Three changes:
-- 1. agents.lan_ip — operator-set LAN IP for this agent. opnfwd's `target`
-- field needs the LAN-side IP of the host running the game container.
-- Defaulted blank; the dashboard's agent edit modal lets the operator
-- fill it in. Without it, the opnfwd integration silently no-ops for
-- instances on that agent (loud log line, no destructive failure).
--
-- 2. panel_settings — singleton key/value table for instance-wide knobs.
-- Today: opnfwd_enabled / opnfwd_url / opnfwd_token. Future: anything
-- else that's "global, operator-edited, doesn't fit elsewhere."
--
-- 3. instance_forwards — what the panel believes it owns in opnfwd. We
-- persist the {nat_uuid, filter_uuid} returned by `POST /api/forwards`
-- so we can target opnfwd's bulk-delete on instance delete. Composite
-- PK on (instance_id, port_name) — every public port gets one row.
ALTER TABLE agents ADD COLUMN IF NOT EXISTS lan_ip TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS panel_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO panel_settings (key, value) VALUES
('opnfwd_enabled', 'false'),
('opnfwd_url', ''),
('opnfwd_token', '')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS instance_forwards (
instance_id TEXT NOT NULL,
port_name TEXT NOT NULL,
nat_uuid TEXT NOT NULL DEFAULT '',
filter_uuid TEXT NOT NULL DEFAULT '',
target_ip TEXT NOT NULL,
host_port INTEGER NOT NULL,
proto TEXT NOT NULL,
descr TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (instance_id, port_name)
);
CREATE INDEX IF NOT EXISTS instance_forwards_instance_id_idx
ON instance_forwards(instance_id);
@@ -0,0 +1,66 @@
-- Empyrion EAH-style features. Three tables:
--
-- 1. empyrion_events — append-only log of unsolicited Event_* packages
-- streamed from the bridge. Powers the player-history view, chat log,
-- and the "what happened while I was away" overview.
--
-- 2. empyrion_inv_templates — operator-defined inventory kits (name +
-- JSON list of {itemId, count}). Click "Apply" on a player row to
-- drop the whole kit via Request_Player_AddItem fan-out.
--
-- 3. empyrion_known_players — projected from empyrion_events. Cached
-- last-seen / first-seen / login count for the players-list view so
-- we don't aggregate the events table on every refresh.
--
-- All tables are scoped by instance_id so two running empyrion instances
-- on the same agent (rare but allowed) keep separate state.
CREATE TABLE IF NOT EXISTS empyrion_events (
id BIGSERIAL PRIMARY KEY,
instance_id TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cmd TEXT NOT NULL,
player_id INTEGER,
steam_id TEXT,
player_name TEXT,
playfield TEXT,
msg TEXT,
raw JSONB
);
CREATE INDEX IF NOT EXISTS empyrion_events_instance_ts
ON empyrion_events (instance_id, ts DESC);
CREATE INDEX IF NOT EXISTS empyrion_events_player_ts
ON empyrion_events (player_id, ts DESC) WHERE player_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS empyrion_events_steam_ts
ON empyrion_events (steam_id, ts DESC) WHERE steam_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS empyrion_events_cmd
ON empyrion_events (instance_id, cmd, ts DESC);
CREATE TABLE IF NOT EXISTS empyrion_inv_templates (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
items JSONB NOT NULL DEFAULT '[]'::jsonb,
-- items shape: [{ "itemId": 4307, "count": 1, "ammo": 0, "decay": 0 }, ...]
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS empyrion_inv_templates_name
ON empyrion_inv_templates (name);
CREATE TABLE IF NOT EXISTS empyrion_known_players (
instance_id TEXT NOT NULL,
player_id INTEGER NOT NULL,
steam_id TEXT NOT NULL DEFAULT '',
player_name TEXT NOT NULL DEFAULT '',
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
login_count INTEGER NOT NULL DEFAULT 0,
last_playfield TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
PRIMARY KEY (instance_id, player_id)
);
CREATE INDEX IF NOT EXISTS empyrion_known_players_steam
ON empyrion_known_players (steam_id) WHERE steam_id != '';
CREATE INDEX IF NOT EXISTS empyrion_known_players_lastseen
ON empyrion_known_players (instance_id, last_seen DESC);
@@ -0,0 +1,9 @@
CREATE TABLE empyrion_blueprints (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
uploader TEXT,
notes TEXT
);
CREATE INDEX idx_empyrion_blueprints_name ON empyrion_blueprints(name);
@@ -0,0 +1,43 @@
-- Saved-coordinate book (wdcoordinates_edit equivalent).
CREATE TABLE empyrion_coords (
id BIGSERIAL PRIMARY KEY,
instance_id TEXT NOT NULL,
name TEXT NOT NULL,
playfield TEXT NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
z DOUBLE PRECISION NOT NULL,
notes TEXT,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (instance_id, name)
);
CREATE INDEX idx_empyrion_coords_instance ON empyrion_coords(instance_id);
-- Player-warning log (wdwarnings + ctrlplayerwarnings equivalent).
-- Append-only history of admin- or auto-issued warnings.
CREATE TABLE empyrion_warnings (
id BIGSERIAL PRIMARY KEY,
instance_id TEXT NOT NULL,
player_id INT NOT NULL,
player_name TEXT,
reason TEXT NOT NULL,
given_by TEXT,
auto_rule TEXT,
given_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_empyrion_warnings_player ON empyrion_warnings(instance_id, player_id, given_at DESC);
-- Configurable warning rules (CEL-driven, matches scheduler_event.go's CEL env).
CREATE TABLE empyrion_warning_rules (
id TEXT PRIMARY KEY,
instance_id TEXT, -- NULL = applies to all empyrion instances
description TEXT NOT NULL DEFAULT '',
cel TEXT NOT NULL,
warn_message TEXT NOT NULL,
threshold INT NOT NULL DEFAULT 3,
threshold_action TEXT NOT NULL DEFAULT 'kick', -- kick | ban | none
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,19 @@
-- Chat-bot configuration: per-instance trigger registry
CREATE TABLE empyrion_chatbot_rules (
id TEXT PRIMARY KEY,
instance_id TEXT, -- NULL = applies to all empyrion instances
command TEXT NOT NULL, -- player types "!<command>" in chat
description TEXT NOT NULL DEFAULT '',
response TEXT NOT NULL, -- supports {playerName} {playerId} {factionId} {playfield} placeholders
channel TEXT NOT NULL DEFAULT 'whisper', -- whisper | global | faction
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (instance_id, command)
);
-- Seed three default rules (global, applies to all instances)
INSERT INTO empyrion_chatbot_rules (id, instance_id, command, description, response, channel, enabled) VALUES
('cb_help', NULL, 'help', 'List available commands', 'Bot commands: !help · !coords · !time', 'whisper', true),
('cb_coords', NULL, 'coords', 'Tell player their coords', 'Your position: {playfield} ({x},{y},{z})', 'whisper', true),
('cb_time', NULL, 'time', 'Server clock', 'Server time: {now}', 'whisper', true);
@@ -0,0 +1,17 @@
-- 016_update_checks.sql — WI-14 "update available" detection.
--
-- One row per instance: the result of the last manual update check
-- (installed buildid from the Steam ACF vs the latest buildid for the
-- instance's branch from steamcmd app_info). CHECK-ONLY bookkeeping —
-- nothing in the panel acts on this table automatically.
CREATE TABLE IF NOT EXISTS update_checks (
instance_id TEXT PRIMARY KEY,
app_id TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
installed_buildid TEXT NOT NULL DEFAULT '',
latest_buildid TEXT NOT NULL DEFAULT '',
update_available BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT NOT NULL DEFAULT '',
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type PairTokenRow struct {
ID string
TokenHash string
Description string
CreatedBy int64
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
UsedByAgent string
}
var ErrPairTokenNotFound = errors.New("pairing token not found or already used")
func (db *DB) CreatePairToken(ctx context.Context, r PairTokenRow) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO pair_tokens (id, token_hash, description, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5)
`, r.ID, r.TokenHash, r.Description, r.CreatedBy, r.ExpiresAt)
if err != nil {
return fmt.Errorf("insert pair_token: %w", err)
}
return nil
}
// ConsumePairToken looks up a token by its hash, validates that it's
// unused + unexpired, and atomically marks it consumed. Returns the row
// on success. Returns ErrPairTokenNotFound if no such valid token.
func (db *DB) ConsumePairToken(ctx context.Context, hash, agentID, fingerprint string) (*PairTokenRow, error) {
var r PairTokenRow
err := db.pool.QueryRow(ctx, `
UPDATE pair_tokens
SET used_at = NOW(), used_by_agent = $2, issued_cert_fingerprint = $3
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > NOW()
RETURNING id, token_hash, description, created_by, created_at, expires_at
`, hash, agentID, fingerprint).Scan(
&r.ID, &r.TokenHash, &r.Description, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPairTokenNotFound
}
if err != nil {
return nil, fmt.Errorf("consume pair_token: %w", err)
}
return &r, nil
}
func (db *DB) ListPairTokens(ctx context.Context) ([]PairTokenRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, token_hash, description, created_by, created_at, expires_at, used_at, COALESCE(used_by_agent, '')
FROM pair_tokens
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PairTokenRow
for rows.Next() {
var r PairTokenRow
if err := rows.Scan(&r.ID, &r.TokenHash, &r.Description, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, &r.UsedAt, &r.UsedByAgent); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
+53
View File
@@ -0,0 +1,53 @@
package db
import (
"context"
"fmt"
)
// GetSetting returns the value of a panel_settings key, or empty string if
// the key isn't present (caller treats empty as "unset / disabled / default").
func (db *DB) GetSetting(ctx context.Context, key string) (string, error) {
var v string
err := db.pool.QueryRow(ctx, `SELECT value FROM panel_settings WHERE key = $1`, key).Scan(&v)
if err != nil {
// pgx returns ErrNoRows when key missing — surface as empty, not error.
// Settings are always optional from the caller's POV.
if err.Error() == "no rows in result set" {
return "", nil
}
return "", fmt.Errorf("get setting %q: %w", key, err)
}
return v, nil
}
// SetSetting upserts a panel_settings row.
func (db *DB) SetSetting(ctx context.Context, key, value string) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO panel_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`, key, value)
if err != nil {
return fmt.Errorf("set setting %q: %w", key, err)
}
return nil
}
// ListSettings dumps every key→value, used by the admin settings page to
// populate fields. Keep this small — it's not paginated.
func (db *DB) ListSettings(ctx context.Context) (map[string]string, error) {
rows, err := db.pool.Query(ctx, `SELECT key, value FROM panel_settings`)
if err != nil {
return nil, fmt.Errorf("list settings: %w", err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("scan setting: %w", err)
}
out[k] = v
}
return out, rows.Err()
}
+25
View File
@@ -0,0 +1,25 @@
package db
import (
"context"
"fmt"
"time"
)
// PruneEmpyrionEvents deletes empyrion_events rows older than keep.
// empyrion_events is append-only (one row per bridge Event_* package) and
// grows without bound on a busy server; the history/activity views only
// ever page over recent windows. Returns the number of rows deleted.
//
// empyrion_known_players is NOT pruned here: it is already compact — one
// upserted row per (instance_id, player_id) carrying login_count and
// first/last_seen aggregates the players view depends on.
func (db *DB) PruneEmpyrionEvents(ctx context.Context, keep time.Duration) (int64, error) {
tag, err := db.pool.Exec(ctx,
`DELETE FROM empyrion_events WHERE ts < NOW() - $1::interval`,
fmt.Sprintf("%d seconds", int64(keep.Seconds())))
if err != nil {
return 0, fmt.Errorf("prune empyrion_events: %w", err)
}
return tag.RowsAffected(), nil
}
+139
View File
@@ -0,0 +1,139 @@
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ScheduleRow is the persisted form of a schedule.
type ScheduleRow struct {
ID string
InstanceID string
Description string
TriggerKind string // "cron" | "event"
CronSpec string // populated for TriggerKind == "cron"
EventSpec string // populated for TriggerKind == "event" — CEL expression returning bool
EventSustainedSeconds int32 // for event: fire only after condition true this many seconds
Action json.RawMessage
Enabled bool
LastFiredAt *time.Time
LastStatus string
LastDetail string
CreatedAt time.Time
UpdatedAt time.Time
}
func (db *DB) InsertSchedule(ctx context.Context, r ScheduleRow) error {
var cronSpec, eventSpec *string
if r.CronSpec != "" {
cronSpec = &r.CronSpec
}
if r.EventSpec != "" {
eventSpec = &r.EventSpec
}
_, err := db.pool.Exec(ctx, `
INSERT INTO schedules (id, instance_id, description, trigger_kind, cron_spec, event_spec, event_sustained_seconds, action, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, r.ID, r.InstanceID, r.Description, r.TriggerKind, cronSpec, eventSpec, r.EventSustainedSeconds, []byte(r.Action), r.Enabled)
if err != nil {
return fmt.Errorf("insert schedule: %w", err)
}
return nil
}
func (db *DB) DeleteSchedule(ctx context.Context, id string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM schedules WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete schedule: %w", err)
}
return nil
}
func (db *DB) SetScheduleEnabled(ctx context.Context, id string, enabled bool) error {
_, err := db.pool.Exec(ctx, `UPDATE schedules SET enabled = $2, updated_at = NOW() WHERE id = $1`, id, enabled)
if err != nil {
return fmt.Errorf("set schedule enabled: %w", err)
}
return nil
}
// UpdateSchedule persists changes to an existing schedule row.
func (db *DB) UpdateSchedule(ctx context.Context, r ScheduleRow) error {
_, err := db.pool.Exec(ctx, `
UPDATE schedules
SET description = $2,
trigger_kind = $3,
cron_spec = $4,
event_spec = $5,
event_sustained_seconds = $6,
action = $7,
enabled = $8,
updated_at = NOW()
WHERE id = $1`,
r.ID, r.Description, r.TriggerKind, r.CronSpec, r.EventSpec, r.EventSustainedSeconds, []byte(r.Action), r.Enabled)
if err != nil {
return fmt.Errorf("update schedule: %w", err)
}
return nil
}
// UpdateScheduleFireResult records the outcome of a trigger fire — used by
// the engine to surface success/failure in the UI.
func (db *DB) UpdateScheduleFireResult(ctx context.Context, id, status, detail string) error {
_, err := db.pool.Exec(ctx, `
UPDATE schedules
SET last_fired_at = NOW(), last_status = $2, last_detail = $3, updated_at = NOW()
WHERE id = $1
`, id, status, detail)
if err != nil {
return fmt.Errorf("update schedule result: %w", err)
}
return nil
}
// ListSchedules returns every schedule, optionally filtered by instance_id.
func (db *DB) ListSchedules(ctx context.Context, instanceFilter string) ([]ScheduleRow, error) {
const selectAll = `
SELECT id, instance_id, description, trigger_kind,
COALESCE(cron_spec, ''), COALESCE(event_spec, ''), event_sustained_seconds,
action,
enabled, last_fired_at, COALESCE(last_status, ''), COALESCE(last_detail, ''),
created_at, updated_at
FROM schedules
`
var rows pgx.Rows
var err error
if instanceFilter != "" {
rows, err = db.pool.Query(ctx, selectAll+` WHERE instance_id = $1 ORDER BY id`, instanceFilter)
} else {
rows, err = db.pool.Query(ctx, selectAll+` ORDER BY instance_id, id`)
}
if err != nil {
return nil, fmt.Errorf("list schedules: %w", err)
}
defer rows.Close()
var out []ScheduleRow
for rows.Next() {
var r ScheduleRow
var action []byte
var lastFired *time.Time
if err := rows.Scan(
&r.ID, &r.InstanceID, &r.Description, &r.TriggerKind,
&r.CronSpec, &r.EventSpec, &r.EventSustainedSeconds,
&action,
&r.Enabled, &lastFired, &r.LastStatus, &r.LastDetail,
&r.CreatedAt, &r.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan schedule: %w", err)
}
r.Action = action
r.LastFiredAt = lastFired
out = append(out, r)
}
return out, rows.Err()
}
+124
View File
@@ -0,0 +1,124 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// SteamCredentialRow is a single stored Steam account for SteamCMD
// non-anonymous login. v1 is single-operator, so most panels will have
// at most one row.
type SteamCredentialRow struct {
ID int64
Username string
PasswordCiphertext []byte // AES-GCM ciphertext of the password
PasswordNonce []byte // AES-GCM nonce (12 bytes)
SentryFile []byte // SSFN device-auth blob; NULL until first success
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// GetSteamCredentialByUsername returns the stored credential for the given
// username or pgx.ErrNoRows if none exists.
func (db *DB) GetSteamCredentialByUsername(ctx context.Context, username string) (*SteamCredentialRow, error) {
row := db.pool.QueryRow(ctx, `
SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at
FROM steam_credentials
WHERE username = $1`, username)
var r SteamCredentialRow
if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
return &r, nil
}
// GetAnySteamCredential returns the most-recently-used Steam credential, or
// pgx.ErrNoRows if none exist. Used when the caller doesn't care which
// account (single-operator panels typically have one).
func (db *DB) GetAnySteamCredential(ctx context.Context) (*SteamCredentialRow, error) {
row := db.pool.QueryRow(ctx, `
SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at
FROM steam_credentials
ORDER BY COALESCE(last_used_at, created_at) DESC
LIMIT 1`)
var r SteamCredentialRow
if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
return &r, nil
}
// UpsertSteamCredential stores (or replaces) a Steam credential. Leaves
// sentry_file untouched on update — that's populated separately by
// UpdateSteamSentryFile once a SteamCMD login succeeds.
func (db *DB) UpsertSteamCredential(ctx context.Context, username string, cipher, nonce []byte) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO steam_credentials (username, password_ciphertext, password_nonce, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (username)
DO UPDATE SET password_ciphertext = EXCLUDED.password_ciphertext,
password_nonce = EXCLUDED.password_nonce,
updated_at = NOW()`,
username, cipher, nonce)
if err != nil {
return fmt.Errorf("upsert steam credential: %w", err)
}
return nil
}
// UpdateSteamSentryFile stores the SSFN blob after a successful login so
// subsequent SteamCMD invocations can skip the Steam Guard 2FA prompt.
func (db *DB) UpdateSteamSentryFile(ctx context.Context, username string, sentry []byte) error {
res, err := db.pool.Exec(ctx, `
UPDATE steam_credentials
SET sentry_file = $2, last_used_at = NOW(), updated_at = NOW()
WHERE username = $1`,
username, sentry)
if err != nil {
return fmt.Errorf("update sentry: %w", err)
}
if res.RowsAffected() == 0 {
return errors.New("steam credential not found")
}
return nil
}
// TouchSteamCredential updates last_used_at without writing a new sentry.
func (db *DB) TouchSteamCredential(ctx context.Context, username string) error {
_, err := db.pool.Exec(ctx, `UPDATE steam_credentials SET last_used_at = NOW() WHERE username = $1`, username)
return err
}
// DeleteSteamCredential removes a cached credential (operator action).
func (db *DB) DeleteSteamCredential(ctx context.Context, username string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM steam_credentials WHERE username = $1`, username)
return err
}
// ListSteamCredentialUsernames returns just the usernames (no password
// material) so the UI can show "Steam accounts configured: X" without
// touching the secrets.
func (db *DB) ListSteamCredentialUsernames(ctx context.Context) ([]string, error) {
rows, err := db.pool.Query(ctx, `SELECT username FROM steam_credentials ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var u string
if err := rows.Scan(&u); err != nil {
return nil, err
}
names = append(names, u)
}
return names, rows.Err()
}
// avoid unused-import removal
var _ = pgx.ErrNoRows
+92
View File
@@ -0,0 +1,92 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// UpdateCheckRow is the persisted result of the last update-available
// check for an instance (WI-14). CHECK-ONLY bookkeeping.
type UpdateCheckRow struct {
InstanceID string
AppID string
Branch string
InstalledBuildID string
LatestBuildID string
UpdateAvailable bool
Error string
CheckedAt time.Time
}
// UpsertUpdateCheck records the latest check result for an instance.
func (db *DB) UpsertUpdateCheck(ctx context.Context, r UpdateCheckRow) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO update_checks (instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (instance_id) DO UPDATE SET
app_id = EXCLUDED.app_id,
branch = EXCLUDED.branch,
installed_buildid = EXCLUDED.installed_buildid,
latest_buildid = EXCLUDED.latest_buildid,
update_available = EXCLUDED.update_available,
error = EXCLUDED.error,
checked_at = NOW()
`, r.InstanceID, r.AppID, r.Branch, r.InstalledBuildID, r.LatestBuildID, r.UpdateAvailable, r.Error)
if err != nil {
return fmt.Errorf("upsert update check: %w", err)
}
return nil
}
// GetUpdateCheck returns the last stored check for an instance, or
// (nil, nil) when the instance has never been checked.
func (db *DB) GetUpdateCheck(ctx context.Context, instanceID string) (*UpdateCheckRow, error) {
row := db.pool.QueryRow(ctx, `
SELECT instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at
FROM update_checks WHERE instance_id = $1
`, instanceID)
var r UpdateCheckRow
err := row.Scan(&r.InstanceID, &r.AppID, &r.Branch, &r.InstalledBuildID, &r.LatestBuildID, &r.UpdateAvailable, &r.Error, &r.CheckedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get update check: %w", err)
}
return &r, nil
}
// ListUpdateChecks returns every stored check, keyed for the dashboard's
// "Update available" badges.
func (db *DB) ListUpdateChecks(ctx context.Context) ([]UpdateCheckRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at
FROM update_checks ORDER BY instance_id
`)
if err != nil {
return nil, fmt.Errorf("list update checks: %w", err)
}
defer rows.Close()
var out []UpdateCheckRow
for rows.Next() {
var r UpdateCheckRow
if err := rows.Scan(&r.InstanceID, &r.AppID, &r.Branch, &r.InstalledBuildID, &r.LatestBuildID, &r.UpdateAvailable, &r.Error, &r.CheckedAt); err != nil {
return nil, fmt.Errorf("scan update check: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// DeleteUpdateCheck removes an instance's row (instance deleted).
func (db *DB) DeleteUpdateCheck(ctx context.Context, instanceID string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM update_checks WHERE instance_id = $1`, instanceID)
if err != nil {
return fmt.Errorf("delete update check: %w", err)
}
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// UserRow is a user record.
type UserRow struct {
ID int64
Email string
PasswordHash string // empty for Steam-only users
SteamID string // SteamID64 (17-digit); empty if not linked
Role string // "admin" | "user"
Disabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
// SessionRow is a valid (or expired) browser session.
type SessionRow struct {
Token string
UserID int64
CreatedAt time.Time
ExpiresAt time.Time
LastSeenIP string
LastSeenAt time.Time
}
func (db *DB) CountUsers(ctx context.Context) (int, error) {
var n int
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
return 0, fmt.Errorf("count users: %w", err)
}
return n, nil
}
func (db *DB) CreateUser(ctx context.Context, email, passwordHash, role string) (int64, error) {
var id int64
err := db.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id
`, email, passwordHash, role).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
}
return id, nil
}
// CreateSteamUser provisions a new panel user with only a SteamID
// linked — no password, sign-in via Steam OpenID only. Used by the
// admin-side "Add user by SteamID" flow so a teammate can be granted
// access without giving them a panel email/password.
func (db *DB) CreateSteamUser(ctx context.Context, email, steamID, role string) (int64, error) {
var id int64
err := db.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, steam_id, role)
VALUES ($1, NULL, $2, $3)
RETURNING id
`, email, steamID, role).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create steam user: %w", err)
}
return id, nil
}
// ErrUserNotFound / ErrSessionNotFound are sentinel errors for the middleware
// to distinguish "not logged in" from real DB errors.
var (
ErrUserNotFound = errors.New("user not found")
ErrSessionNotFound = errors.New("session not found")
)
// scanSteamAware is the generic row scan that handles the nullable
// password_hash + steam_id columns the 009 migration introduced. Used by
// every GetUser* helper so their SQL SELECT clauses stay aligned.
func scanSteamAware(row pgx.Row, u *UserRow) error {
var pwHash, steamID *string
err := row.Scan(&u.ID, &u.Email, &pwHash, &steamID, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return err
}
if pwHash != nil {
u.PasswordHash = *pwHash
}
if steamID != nil {
u.SteamID = *steamID
}
return nil
}
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
var u UserRow
err := scanSteamAware(db.pool.QueryRow(ctx, `
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
FROM users WHERE email = $1
`, email), &u)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &u, nil
}
func (db *DB) GetUserByID(ctx context.Context, id int64) (*UserRow, error) {
var u UserRow
err := scanSteamAware(db.pool.QueryRow(ctx, `
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
FROM users WHERE id = $1
`, id), &u)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &u, nil
}
// GetUserBySteamID looks up a user by their linked SteamID64. Returns
// ErrUserNotFound if nobody on this panel has that Steam account linked.
func (db *DB) GetUserBySteamID(ctx context.Context, steamID string) (*UserRow, error) {
var u UserRow
err := scanSteamAware(db.pool.QueryRow(ctx, `
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
FROM users WHERE steam_id = $1
`, steamID), &u)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("get user by steam: %w", err)
}
return &u, nil
}
// GetFirstAdmin returns the earliest-created admin user, or ErrUserNotFound
// if no admin exists. Used by --initial-admin-steam-id to locate the
// account to link a Steam ID to when no explicit target is specified.
func (db *DB) GetFirstAdmin(ctx context.Context) (*UserRow, error) {
var u UserRow
err := scanSteamAware(db.pool.QueryRow(ctx, `
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
FROM users WHERE role = 'admin' AND disabled = FALSE
ORDER BY created_at ASC
LIMIT 1
`), &u)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("get first admin: %w", err)
}
return &u, nil
}
// LinkSteamIDToUser sets/replaces the steam_id on an existing user.
// If another user already owns that Steam ID, returns an error (the
// unique constraint enforces one-user-per-Steam-account).
func (db *DB) LinkSteamIDToUser(ctx context.Context, userID int64, steamID string) error {
_, err := db.pool.Exec(ctx, `
UPDATE users SET steam_id = $2, updated_at = NOW() WHERE id = $1
`, userID, steamID)
if err != nil {
return fmt.Errorf("link steam id: %w", err)
}
return nil
}
// UnlinkSteamID nulls the steam_id on a user. Operator pulled the
// Steam link from the panel UI.
func (db *DB) UnlinkSteamID(ctx context.Context, userID int64) error {
_, err := db.pool.Exec(ctx, `UPDATE users SET steam_id = NULL, updated_at = NOW() WHERE id = $1`, userID)
if err != nil {
return fmt.Errorf("unlink steam id: %w", err)
}
return nil
}
// ListUsers returns every user. Admin-only at the API surface; this
// helper has no auth check itself.
func (db *DB) ListUsers(ctx context.Context) ([]UserRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, email, COALESCE(password_hash, ''), steam_id, role, disabled, created_at, updated_at
FROM users ORDER BY created_at ASC
`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var out []UserRow
for rows.Next() {
var u UserRow
var sid *string
if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &sid, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
if sid != nil {
u.SteamID = *sid
}
out = append(out, u)
}
return out, rows.Err()
}
func (db *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error {
_, err := db.pool.Exec(ctx, `
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1
`, id, passwordHash)
if err != nil {
return fmt.Errorf("update user password: %w", err)
}
return nil
}
func (db *DB) CreateSession(ctx context.Context, token string, userID int64, expiresAt time.Time, ip string) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO sessions (token, user_id, expires_at, last_seen_ip)
VALUES ($1, $2, $3, $4)
`, token, userID, expiresAt, ip)
if err != nil {
return fmt.Errorf("create session: %w", err)
}
return nil
}
// GetSessionUser returns the user associated with a valid, non-expired
// session token. It also bumps last_seen_at so we can show active sessions
// in a future admin UI.
func (db *DB) GetSessionUser(ctx context.Context, token, ip string) (*UserRow, error) {
var u UserRow
err := db.pool.QueryRow(ctx, `
UPDATE sessions SET last_seen_at = NOW(), last_seen_ip = $2
WHERE token = $1 AND expires_at > NOW()
RETURNING user_id
`, token, ip).Scan(&u.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSessionNotFound
}
if err != nil {
return nil, fmt.Errorf("get session: %w", err)
}
return db.GetUserByID(ctx, u.ID)
}
func (db *DB) DeleteSession(ctx context.Context, token string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}