Files
UNITRONIX 1408f904f3 fix(security): Address Book ACL filter and legacy role CHECK migration (#342)
Restrict GET /api/ab peers/tags to device-group scope and migrate upgraded
SQLite users tables off the pre-Phase-52 role CHECK so console sync works.
Refs #342.
2026-08-01 16:53:18 +02:00

2532 lines
81 KiB
Go

package db
import (
"database/sql"
"fmt"
"log"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// SQLiteDB implements the Database interface using modernc.org/sqlite (pure Go, no CGO).
type SQLiteDB struct {
db *sql.DB
mu sync.RWMutex // serialize writes (SQLite limitation)
}
// OpenSQLite opens or creates a SQLite database at the given path.
func OpenSQLite(path string) (*SQLiteDB, error) {
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=ON", path)
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("db: failed to open SQLite %q: %w", path, err)
}
sqlDB.SetMaxOpenConns(1) // SQLite single-writer
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0) // keep alive
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("db: SQLite ping failed: %w", err)
}
return &SQLiteDB{db: sqlDB}, nil
}
// Close closes the database connection.
func (s *SQLiteDB) Close() error {
return s.db.Close()
}
// Migrate creates all required tables if they don't exist.
func (s *SQLiteDB) Migrate() error {
s.mu.Lock()
defer s.mu.Unlock()
statements := []string{
`CREATE TABLE IF NOT EXISTS peers (
id TEXT PRIMARY KEY,
uuid TEXT DEFAULT '',
pk BLOB DEFAULT NULL,
ip TEXT DEFAULT '',
user TEXT DEFAULT '',
hostname TEXT DEFAULT '',
os TEXT DEFAULT '',
version TEXT DEFAULT '',
status TEXT DEFAULT 'OFFLINE',
nat_type INTEGER DEFAULT 0,
last_online TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
disabled INTEGER DEFAULT 0,
banned INTEGER DEFAULT 0,
ban_reason TEXT DEFAULT '',
banned_at TEXT DEFAULT NULL,
soft_deleted INTEGER DEFAULT 0,
deleted_at TEXT DEFAULT NULL,
note TEXT DEFAULT '',
tags TEXT DEFAULT '',
heartbeat_seq INTEGER DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_peers_uuid ON peers(uuid)`,
`CREATE INDEX IF NOT EXISTS idx_peers_status ON peers(status)`,
`CREATE TABLE IF NOT EXISTS server_config (
key TEXT PRIMARY KEY,
value TEXT DEFAULT ''
)`,
`CREATE TABLE IF NOT EXISTS id_change_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
old_id TEXT NOT NULL,
new_id TEXT NOT NULL,
changed_at TEXT DEFAULT (datetime('now')),
reason TEXT DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_id_history_old ON id_change_history(old_id)`,
`CREATE INDEX IF NOT EXISTS idx_id_history_new ON id_change_history(new_id)`,
// Users table (authentication)
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
auth_provider TEXT NOT NULL DEFAULT 'local',
totp_secret TEXT DEFAULT '',
totp_enabled INTEGER DEFAULT 0,
totp_recovery_codes TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
last_login TEXT DEFAULT ''
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
// API keys table (scoped programmatic access)
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_hash TEXT UNIQUE NOT NULL,
key_prefix TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT DEFAULT '',
last_used TEXT DEFAULT ''
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`,
// Device tokens table (Dual Key System for enhanced security)
`CREATE TABLE IF NOT EXISTS device_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT UNIQUE NOT NULL,
token_prefix TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
peer_id TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
max_uses INTEGER DEFAULT 1,
use_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT DEFAULT NULL,
revoked_at TEXT DEFAULT NULL,
last_used_at TEXT DEFAULT NULL,
created_by TEXT DEFAULT '',
note TEXT DEFAULT ''
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_hash ON device_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS idx_device_tokens_peer ON device_tokens(peer_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_tokens_status ON device_tokens(status)`,
// Address books table (RustDesk client AB sync)
`CREATE TABLE IF NOT EXISTS address_books (
username TEXT NOT NULL,
ab_type TEXT NOT NULL DEFAULT 'legacy',
data TEXT DEFAULT '{}',
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (username, ab_type)
)`,
// Peer metrics table (heartbeat CPU/memory/disk data)
`CREATE TABLE IF NOT EXISTS peer_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_id TEXT NOT NULL,
cpu_usage REAL DEFAULT 0,
memory_usage REAL DEFAULT 0,
disk_usage REAL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_peer_metrics_peer ON peer_metrics(peer_id)`,
`CREATE INDEX IF NOT EXISTS idx_peer_metrics_created ON peer_metrics(created_at)`,
// Chat messages table
`CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
from_id TEXT NOT NULL,
from_name TEXT DEFAULT '',
to_id TEXT DEFAULT '',
text TEXT NOT NULL,
read INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_chat_messages_conv ON chat_messages(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_chat_messages_from ON chat_messages(from_id)`,
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created ON chat_messages(created_at)`,
// Chat groups table
`CREATE TABLE IF NOT EXISTS chat_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
members TEXT DEFAULT '',
created_by TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
)`,
// Help requests table (support requests raised by agent devices)
`CREATE TABLE IF NOT EXISTS help_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
hostname TEXT DEFAULT '',
org_id TEXT DEFAULT '',
message TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
handled_by TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_device ON help_requests(device_id)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_status ON help_requests(status)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_created ON help_requests(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_help_requests_org ON help_requests(org_id)`,
// RustDesk client login sessions (Issue #242 / #284)
clientSessionsSQLiteDDL,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_client_sessions_hash ON client_sessions(token_hash)`,
`CREATE INDEX IF NOT EXISTS idx_client_sessions_user ON client_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_client_sessions_expires ON client_sessions(expires_at)`,
// Organizations (v3.0.0)
`CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
logo_url TEXT DEFAULT '',
settings TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_organizations_slug ON organizations(slug)`,
// Organization users (v3.0.0)
`CREATE TABLE IF NOT EXISTS org_users (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organizations(id),
username TEXT NOT NULL,
display_name TEXT DEFAULT '',
email TEXT DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user',
totp_secret TEXT DEFAULT '',
avatar_url TEXT DEFAULT '',
last_login TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(org_id, username)
)`,
`CREATE INDEX IF NOT EXISTS idx_org_users_org ON org_users(org_id)`,
// Organization devices (v3.0.0)
`CREATE TABLE IF NOT EXISTS org_devices (
org_id TEXT NOT NULL REFERENCES organizations(id),
device_id TEXT NOT NULL,
assigned_user_id TEXT DEFAULT '',
department TEXT DEFAULT '',
location TEXT DEFAULT '',
building TEXT DEFAULT '',
tags TEXT DEFAULT '',
PRIMARY KEY(org_id, device_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_org_devices_org ON org_devices(org_id)`,
`CREATE INDEX IF NOT EXISTS idx_org_devices_device ON org_devices(device_id)`,
// Organization invitations (v3.0.0)
`CREATE TABLE IF NOT EXISTS org_invitations (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organizations(id),
token TEXT UNIQUE NOT NULL,
email TEXT DEFAULT '',
role TEXT DEFAULT 'user',
expires_at TEXT NOT NULL,
used_at TEXT DEFAULT NULL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_org_invitations_token ON org_invitations(token)`,
// Organization settings (v3.0.0)
`CREATE TABLE IF NOT EXISTS org_settings (
org_id TEXT NOT NULL REFERENCES organizations(id),
key TEXT NOT NULL,
value TEXT DEFAULT '',
PRIMARY KEY(org_id, key)
)`,
// Organization shared address books (Issue #187 / #190)
`CREATE TABLE IF NOT EXISTS org_address_books (
org_id TEXT NOT NULL REFERENCES organizations(id),
ab_type TEXT NOT NULL DEFAULT 'legacy',
data TEXT NOT NULL DEFAULT '{}',
updated_at TEXT DEFAULT (datetime('now')),
updated_by TEXT NOT NULL DEFAULT '',
PRIMARY KEY(org_id, ab_type)
)`,
`CREATE TABLE IF NOT EXISTS access_policies (
peer_id TEXT PRIMARY KEY,
unattended_enabled INTEGER DEFAULT 0,
password_hash TEXT DEFAULT '',
schedule_enabled INTEGER DEFAULT 0,
schedule_days TEXT DEFAULT '',
schedule_start_time TEXT DEFAULT '',
schedule_end_time TEXT DEFAULT '',
schedule_timezone TEXT DEFAULT '',
allowed_operators TEXT DEFAULT '',
updated_at TEXT DEFAULT '',
updated_by TEXT DEFAULT ''
)`,
// Role permissions (RBAC Phase 52) — custom permission overrides per role
`CREATE TABLE IF NOT EXISTS role_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
permission TEXT NOT NULL,
granted INTEGER NOT NULL DEFAULT 1,
UNIQUE(role, permission)
)`,
`CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role)`,
// Audit logs (RustDesk client reporting — API-port consolidation Phase A)
`CREATE TABLE IF NOT EXISTS audit_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
host_uuid TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
peer_name TEXT DEFAULT '',
action TEXT NOT NULL DEFAULT '',
conn_type INTEGER DEFAULT 0,
session_id TEXT DEFAULT '',
ip TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_conn_host ON audit_connections(host_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_audit_conn_peer ON audit_connections(peer_id, created_at)`,
`CREATE TABLE IF NOT EXISTS audit_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
host_uuid TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
direction INTEGER DEFAULT 0,
path TEXT DEFAULT '',
is_file INTEGER DEFAULT 1,
num_files INTEGER DEFAULT 0,
files_json TEXT DEFAULT '[]',
ip TEXT DEFAULT '',
peer_name TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_files_host ON audit_files(host_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_audit_files_peer ON audit_files(peer_id, created_at)`,
`CREATE TABLE IF NOT EXISTS audit_alarms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alarm_type INTEGER NOT NULL DEFAULT 0,
alarm_name TEXT DEFAULT '',
host_id TEXT DEFAULT '',
peer_id TEXT DEFAULT '',
ip TEXT DEFAULT '',
details TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_alarms_type ON audit_alarms(alarm_type, created_at)`,
// Billing / commercialization module
`CREATE TABLE IF NOT EXISTS billing_packages (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
included_minutes INTEGER NOT NULL DEFAULT 0,
overage_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'PLN',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS billing_contracts (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL DEFAULT 'org',
target_key TEXT NOT NULL,
package_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
remaining_minutes INTEGER NOT NULL DEFAULT 0,
overage_rate REAL,
hourly_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'PLN',
valid_from TEXT,
valid_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(target_type, target_key),
FOREIGN KEY (package_id) REFERENCES billing_packages(id)
)`,
`CREATE INDEX IF NOT EXISTS idx_billing_contracts_target ON billing_contracts(target_type, target_key)`,
`CREATE INDEX IF NOT EXISTS idx_billing_contracts_status ON billing_contracts(status)`,
`CREATE TABLE IF NOT EXISTS billing_sessions (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
contract_id TEXT NOT NULL DEFAULT '',
operator_id TEXT NOT NULL DEFAULT '',
operator_name TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL,
device_name TEXT NOT NULL DEFAULT '',
relay_uuid TEXT NOT NULL DEFAULT '',
transport TEXT NOT NULL DEFAULT 'rustdesk',
status TEXT NOT NULL DEFAULT 'active',
billing_phase TEXT NOT NULL DEFAULT 'included',
started_at TEXT NOT NULL,
ended_at TEXT,
raw_seconds INTEGER NOT NULL DEFAULT 0,
billed_minutes INTEGER NOT NULL DEFAULT 0,
included_minutes_used INTEGER NOT NULL DEFAULT 0,
overage_minutes INTEGER NOT NULL DEFAULT 0,
amount_included REAL NOT NULL DEFAULT 0,
amount_overage REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'PLN',
clock_offset_ms_at_start INTEGER NOT NULL DEFAULT 0,
clock_synced_at_start INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_billing_sessions_org ON billing_sessions(org_id, started_at)`,
`CREATE INDEX IF NOT EXISTS idx_billing_sessions_relay ON billing_sessions(relay_uuid)`,
`CREATE TABLE IF NOT EXISTS billing_session_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_billing_ledger_session ON billing_session_ledger(session_id)`,
`CREATE TABLE IF NOT EXISTS billing_work_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
operator_id TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL,
category TEXT DEFAULT '',
ticket_ref TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS billing_currencies (
code TEXT PRIMARY KEY,
symbol TEXT NOT NULL DEFAULT '',
exchange_rate_to_base REAL NOT NULL DEFAULT 1
)`,
// User/device groups + strategies (API-port consolidation Phase A)
`CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
note TEXT DEFAULT '',
team_id TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS device_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
note TEXT DEFAULT '',
team_id TEXT DEFAULT '',
source_type TEXT DEFAULT 'manual',
tag_filter TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS strategies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
user_group_guid TEXT DEFAULT '',
device_group_guid TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
permissions TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS strategy_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL,
target_key TEXT NOT NULL,
strategy_guid TEXT NOT NULL DEFAULT '',
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(target_type, target_key)
)`,
`CREATE INDEX IF NOT EXISTS idx_strategy_assignments_strategy ON strategy_assignments(strategy_guid)`,
}
for _, stmt := range statements {
if _, err := s.db.Exec(stmt); err != nil {
return fmt.Errorf("db: migration failed: %w\nStatement: %s", err, stmt)
}
}
// Incremental column migrations for existing databases.
// SQLite does not support ADD COLUMN IF NOT EXISTS, so we check PRAGMA table_info first.
columnMigrations := []struct {
table string
column string
ddl string
}{
// users: TOTP 2FA columns (added in v2.3.0)
{"users", "totp_secret", `ALTER TABLE users ADD COLUMN totp_secret TEXT DEFAULT ''`},
{"users", "totp_enabled", `ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`},
{"users", "totp_recovery_codes", `ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT DEFAULT NULL`},
// peers: ban columns (added in v2.1.0)
{"peers", "banned", `ALTER TABLE peers ADD COLUMN banned INTEGER DEFAULT 0`},
{"peers", "ban_reason", `ALTER TABLE peers ADD COLUMN ban_reason TEXT DEFAULT ''`},
{"peers", "banned_at", `ALTER TABLE peers ADD COLUMN banned_at TEXT DEFAULT NULL`},
// peers: tags (added in v2.2.0)
{"peers", "tags", `ALTER TABLE peers ADD COLUMN tags TEXT DEFAULT ''`},
// peers: heartbeat_seq (added in v2.3.0)
{"peers", "heartbeat_seq", `ALTER TABLE peers ADD COLUMN heartbeat_seq INTEGER DEFAULT 0`},
// peers: CDAP device type and linked peer (added in v2.5.0)
{"peers", "device_type", `ALTER TABLE peers ADD COLUMN device_type TEXT DEFAULT ''`},
{"peers", "linked_peer_id", `ALTER TABLE peers ADD COLUMN linked_peer_id TEXT DEFAULT ''`},
// peers: display_name alias (added in v2.6.0)
{"peers", "display_name", `ALTER TABLE peers ADD COLUMN display_name TEXT DEFAULT ''`},
// users: is_server_admin flag (RBAC Phase 52)
{"users", "is_server_admin", `ALTER TABLE users ADD COLUMN is_server_admin INTEGER DEFAULT 0`},
{"users", "auth_provider", `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'`},
// org_users: server_user_id for linking existing users (Issue #106)
{"org_users", "server_user_id", `ALTER TABLE org_users ADD COLUMN server_user_id INTEGER DEFAULT 0`},
// peers/users: Pro strategy assignment GUIDs
{"peers", "guid", `ALTER TABLE peers ADD COLUMN guid TEXT DEFAULT ''`},
{"users", "guid", `ALTER TABLE users ADD COLUMN guid TEXT DEFAULT ''`},
}
for _, m := range columnMigrations {
if !s.hasColumn(m.table, m.column) {
if _, err := s.db.Exec(m.ddl); err != nil {
// Ignore "duplicate column" errors — race-safe
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("db: column migration failed (%s.%s): %w", m.table, m.column, err)
}
}
}
}
// Deferred indexes — created after column migrations to avoid
// "no such column" errors on legacy databases.
deferredIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_peers_banned ON peers(banned)`,
`CREATE INDEX IF NOT EXISTS idx_peers_soft_deleted ON peers(soft_deleted)`,
`CREATE INDEX IF NOT EXISTS idx_peers_linked_peer ON peers(linked_peer_id)`,
`CREATE INDEX IF NOT EXISTS idx_peer_metrics_peer_created ON peer_metrics(peer_id, created_at DESC)`,
}
for _, idx := range deferredIndexes {
if _, err := s.db.Exec(idx); err != nil {
return fmt.Errorf("db: deferred index failed: %w\nStatement: %s", err, idx)
}
}
// Billing: backfill NULL optional text columns (SQLite cannot ALTER to add NOT NULL).
billingNullBackfills := []string{
`UPDATE billing_sessions SET contract_id = '' WHERE contract_id IS NULL`,
`UPDATE billing_sessions SET operator_name = '' WHERE operator_name IS NULL`,
`UPDATE billing_sessions SET device_name = '' WHERE device_name IS NULL`,
`UPDATE billing_sessions SET relay_uuid = '' WHERE relay_uuid IS NULL`,
}
for _, stmt := range billingNullBackfills {
if _, err := s.db.Exec(stmt); err != nil {
return fmt.Errorf("db: billing null backfill failed: %w\nStatement: %s", err, stmt)
}
}
// Users: never-logged-in / legacy rows may store NULL in text columns that
// Scan into string (Issue #292). Normalize so ListUsers/GetUser do not 500.
userNullBackfills := []string{
`UPDATE users SET last_login = '' WHERE last_login IS NULL`,
`UPDATE users SET totp_secret = '' WHERE totp_secret IS NULL`,
`UPDATE users SET created_at = datetime('now') WHERE created_at IS NULL`,
}
for _, stmt := range userNullBackfills {
if _, err := s.db.Exec(stmt); err != nil {
// Table may not exist yet on very early migrate failures — ignore.
if !strings.Contains(err.Error(), "no such table") {
return fmt.Errorf("db: user null backfill failed: %w\nStatement: %s", err, stmt)
}
}
}
if err := s.migrateUsersDropLegacyRoleCheck(); err != nil {
return err
}
if err := s.migrateBillingOrgContracts(); err != nil {
return err
}
return nil
}
// usersHasLegacyRoleCheck detects upgraded SQLite installs that still enforce
// CHECK (role IN ('admin', 'operator', 'viewer')) from the pre-Phase-52 schema.
func (s *SQLiteDB) usersHasLegacyRoleCheck() bool {
var sqlText string
err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&sqlText)
if err != nil || strings.TrimSpace(sqlText) == "" {
return false
}
low := strings.ToLower(sqlText)
return strings.Contains(low, "check") &&
strings.Contains(low, "role") &&
strings.Contains(low, "'admin'") &&
strings.Contains(low, "'viewer'")
}
// migrateUsersDropLegacyRoleCheck rebuilds the users table without the legacy
// three-role CHECK so Phase 52 roles (super_admin, global_admin, …) can sync.
func (s *SQLiteDB) migrateUsersDropLegacyRoleCheck() error {
if !s.usersHasLegacyRoleCheck() {
return nil
}
log.Printf("[db] migrating users table: dropping legacy role CHECK constraint")
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("db: begin users role-check migration: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
return fmt.Errorf("db: disable foreign_keys for users rebuild: %w", err)
}
const createUsersNew = `CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
auth_provider TEXT NOT NULL DEFAULT 'local',
totp_secret TEXT DEFAULT '',
totp_enabled INTEGER DEFAULT 0,
totp_recovery_codes TEXT DEFAULT NULL,
is_server_admin INTEGER DEFAULT 0,
guid TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
last_login TEXT DEFAULT ''
)`
if _, err := tx.Exec(createUsersNew); err != nil {
return fmt.Errorf("db: create users_new: %w", err)
}
copySQL := `INSERT INTO users_new (
id, username, password_hash, role, auth_provider, totp_secret, totp_enabled,
totp_recovery_codes, is_server_admin, guid, created_at, last_login
)
SELECT
id, username, password_hash, role,
COALESCE(auth_provider, 'local'),
COALESCE(totp_secret, ''),
COALESCE(totp_enabled, 0),
totp_recovery_codes,
COALESCE(is_server_admin, 0),
COALESCE(guid, ''),
COALESCE(created_at, datetime('now')),
COALESCE(last_login, '')
FROM users`
if _, err := tx.Exec(copySQL); err != nil {
return fmt.Errorf("db: copy users → users_new: %w", err)
}
if _, err := tx.Exec(`DROP TABLE users`); err != nil {
return fmt.Errorf("db: drop legacy users: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE users_new RENAME TO users`); err != nil {
return fmt.Errorf("db: rename users_new: %w", err)
}
if _, err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)`); err != nil {
return fmt.Errorf("db: recreate users username index: %w", err)
}
if _, err := tx.Exec(`PRAGMA foreign_keys=ON`); err != nil {
return fmt.Errorf("db: re-enable foreign_keys: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: commit users role-check migration: %w", err)
}
return nil
}
// migrateBillingOrgContracts copies legacy billing_org_contracts rows into billing_contracts.
func (s *SQLiteDB) migrateBillingOrgContracts() error {
var name string
err := s.db.QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name='billing_org_contracts'`,
).Scan(&name)
if err != nil {
return nil // table does not exist
}
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM billing_contracts`).Scan(&n); err != nil {
return fmt.Errorf("db: billing_contracts count: %w", err)
}
if n > 0 {
return nil
}
_, err = s.db.Exec(`
INSERT OR IGNORE INTO billing_contracts
(id, target_type, target_key, package_id, status, remaining_minutes, overage_rate, hourly_rate, currency, valid_from, valid_until, created_at, updated_at)
SELECT id, 'org', org_id, package_id, status, remaining_minutes, overage_rate, hourly_rate, currency, valid_from, valid_until, created_at, updated_at
FROM billing_org_contracts`)
if err != nil {
return fmt.Errorf("db: migrate billing_org_contracts: %w", err)
}
return nil
}
// safeIdentifier validates that a string is a safe SQL identifier (letters, digits, underscores).
func safeIdentifier(name string) bool {
if len(name) == 0 || len(name) > 64 {
return false
}
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
return false
}
}
return true
}
// hasColumn checks if a column exists in a table using PRAGMA table_info.
func (s *SQLiteDB) hasColumn(table, column string) bool {
if !safeIdentifier(table) {
return false
}
rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull int
var dfltValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err != nil {
continue
}
if strings.EqualFold(name, column) {
return true
}
}
return false
}
// GetPeer returns a peer by ID, or nil if not found.
func (s *SQLiteDB) GetPeer(id string) (*Peer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
err := s.db.QueryRow(`
SELECT id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name
FROM peers WHERE id = ? AND (soft_deleted IS NULL OR soft_deleted = 0)`, id).Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID, &p.DisplayName,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("db: GetPeer(%q): %w", id, err)
}
p.LastOnline = parseTime(lastOnline)
p.CreatedAt = parseTime(createdAt)
p.BannedAt = parseTimePtr(bannedAt)
p.DeletedAt = parseTimePtr(deletedAt)
return p, nil
}
// GetPeerIDState returns whether an ID is missing, active, or soft-deleted.
func (s *SQLiteDB) GetPeerIDState(id string) (PeerIDState, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var deleted bool
err := s.db.QueryRow(`SELECT soft_deleted FROM peers WHERE id = ?`, id).Scan(&deleted)
if err == sql.ErrNoRows {
return PeerIDMissing, nil
}
if err != nil {
return "", fmt.Errorf("db: GetPeerIDState(%q): %w", id, err)
}
if deleted {
return PeerIDSoftDeleted, nil
}
return PeerIDActive, nil
}
// GetPeersByIDs returns active peers for the given IDs in one query.
func (s *SQLiteDB) GetPeersByIDs(ids []string) (map[string]*Peer, error) {
if len(ids) == 0 {
return map[string]*Peer{}, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`SELECT id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name
FROM peers
WHERE (soft_deleted IS NULL OR soft_deleted = 0) AND id IN (%s)`,
strings.Join(placeholders, ","))
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("db: GetPeersByIDs: %w", err)
}
defer rows.Close()
out := make(map[string]*Peer, len(ids))
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID, &p.DisplayName,
); err != nil {
return nil, fmt.Errorf("db: GetPeersByIDs scan: %w", err)
}
p.LastOnline = parseTime(lastOnline)
p.CreatedAt = parseTime(createdAt)
p.BannedAt = parseTimePtr(bannedAt)
p.DeletedAt = parseTimePtr(deletedAt)
out[p.ID] = p
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: GetPeersByIDs: %w", err)
}
return out, nil
}
// GetPeerByUUID returns a peer by UUID, or nil if not found.
func (s *SQLiteDB) GetPeerByUUID(uuid string) (*Peer, error) {
s.mu.RLock()
var id string
err := s.db.QueryRow(`SELECT id FROM peers WHERE uuid = ?`, uuid).Scan(&id)
s.mu.RUnlock()
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetPeer(id)
}
// UpsertPeer inserts or updates a peer record.
func (s *SQLiteDB) UpsertPeer(p *Peer) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`
INSERT INTO peers (id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, disabled, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
uuid = COALESCE(NULLIF(excluded.uuid, ''), peers.uuid),
pk = COALESCE(excluded.pk, peers.pk),
ip = excluded.ip,
user = COALESCE(NULLIF(excluded.user, ''), peers.user),
hostname = COALESCE(NULLIF(excluded.hostname, ''), peers.hostname),
os = COALESCE(NULLIF(excluded.os, ''), peers.os),
version = COALESCE(NULLIF(excluded.version, ''), peers.version),
status = excluded.status,
nat_type = excluded.nat_type,
last_online = excluded.last_online,
disabled = excluded.disabled,
note = COALESCE(NULLIF(excluded.note, ''), peers.note),
tags = COALESCE(NULLIF(excluded.tags, ''), peers.tags),
heartbeat_seq = excluded.heartbeat_seq,
device_type = COALESCE(NULLIF(excluded.device_type, ''), peers.device_type),
linked_peer_id = COALESCE(NULLIF(excluded.linked_peer_id, ''), peers.linked_peer_id),
display_name = COALESCE(NULLIF(excluded.display_name, ''), peers.display_name)`,
/* SECURITY (GHSA-3v82-3gf8-fxx8): UpsertPeer MUST NOT silently clear
soft_deleted/deleted_at on conflict. Restoration is now an explicit
operation — see RestorePeer. */
p.ID, p.UUID, p.PK, p.IP, p.User, p.Hostname, p.OS, p.Version,
p.Status, p.NATType, formatTime(p.LastOnline),
p.Disabled, p.Note, p.Tags, p.HeartbeatSeq,
p.DeviceType, p.LinkedPeerID, p.DisplayName,
)
if err != nil {
return fmt.Errorf("db: UpsertPeer(%q): %w", p.ID, err)
}
return nil
}
// DeletePeer marks a peer as soft-deleted.
func (s *SQLiteDB) DeletePeer(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE peers SET soft_deleted = 1, deleted_at = datetime('now') WHERE id = ?`, id)
return err
}
// HardDeletePeer permanently removes a peer from the database and releases
// any id_change_history reservations for that ID (#213).
func (s *SQLiteDB) HardDeletePeer(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM peers WHERE id = ?`, id); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM id_change_history WHERE old_id = ? OR new_id = ?`, id, id); err != nil {
return err
}
return tx.Commit()
}
// ListPeers returns all peers, optionally including soft-deleted ones.
func (s *SQLiteDB) ListPeers(includeDeleted bool) ([]*Peer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
query := `SELECT id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name
FROM peers`
if !includeDeleted {
query += ` WHERE soft_deleted = 0`
}
query += ` ORDER BY id`
rows, err := s.db.Query(query)
if err != nil {
return nil, fmt.Errorf("db: ListPeers: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID, &p.DisplayName,
); err != nil {
return nil, fmt.Errorf("db: ListPeers scan: %w", err)
}
p.LastOnline = parseTime(lastOnline)
p.CreatedAt = parseTime(createdAt)
p.BannedAt = parseTimePtr(bannedAt)
p.DeletedAt = parseTimePtr(deletedAt)
peers = append(peers, p)
}
return peers, rows.Err()
}
// ListPeersPaginated returns a page of peers and the total matching row count.
func (s *SQLiteDB) ListPeersPaginated(includeDeleted bool, limit, offset int) ([]*Peer, int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
where := ""
if !includeDeleted {
where = ` WHERE soft_deleted = 0`
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM peers` + where).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("db: ListPeersPaginated count: %w", err)
}
query := `SELECT id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name
FROM peers` + where + ` ORDER BY id`
if limit > 0 {
query += fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
}
rows, err := s.db.Query(query)
if err != nil {
return nil, 0, fmt.Errorf("db: ListPeersPaginated: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID, &p.DisplayName,
); err != nil {
return nil, 0, fmt.Errorf("db: ListPeersPaginated scan: %w", err)
}
p.LastOnline = parseTime(lastOnline)
p.CreatedAt = parseTime(createdAt)
p.BannedAt = parseTimePtr(bannedAt)
p.DeletedAt = parseTimePtr(deletedAt)
peers = append(peers, p)
}
return peers, total, rows.Err()
}
// GetPeerCount returns total and online peer counts.
func (s *SQLiteDB) GetPeerCount() (total int, online int, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
err = s.db.QueryRow(
`SELECT COUNT(*) FROM peers WHERE soft_deleted = 0`).Scan(&total)
if err != nil {
return 0, 0, err
}
err = s.db.QueryRow(
`SELECT COUNT(*) FROM peers WHERE soft_deleted = 0 AND status = 'ONLINE'`).Scan(&online)
return total, online, err
}
// GetBannedPeerCount returns the number of banned peers in the database.
func (s *SQLiteDB) GetBannedPeerCount() (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM peers WHERE banned = 1 AND soft_deleted = 0`).Scan(&count)
return count, err
}
// UpdatePeerStatus updates a peer's status and IP, plus last_online timestamp.
func (s *SQLiteDB) UpdatePeerStatus(id string, status string, ip string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE peers SET status = ?, ip = ?, last_online = datetime('now') WHERE id = ? AND soft_deleted = 0`,
status, ip, id)
return err
}
// BatchUpdatePeerStatus sets status for many peers in one query (empty ip preserved).
func (s *SQLiteDB) BatchUpdatePeerStatus(ids []string, status string) error {
if len(ids) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, status)
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
query := fmt.Sprintf(
`UPDATE peers SET status = ?, last_online = datetime('now') WHERE soft_deleted = 0 AND id IN (%s)`,
strings.Join(placeholders, ","),
)
_, err := s.db.Exec(query, args...)
return err
}
// UpdatePeerSysinfo updates hostname, os, and version for a peer.
// Only non-empty values overwrite existing data.
func (s *SQLiteDB) UpdatePeerSysinfo(id, hostname, os, version string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`
UPDATE peers SET
hostname = CASE WHEN ? != '' THEN ? ELSE hostname END,
os = CASE WHEN ? != '' THEN ? ELSE os END,
version = CASE WHEN ? != '' THEN ? ELSE version END
WHERE id = ?`,
hostname, hostname, os, os, version, version, id)
return err
}
// SetAllOffline marks all peers as OFFLINE. Called at server startup.
func (s *SQLiteDB) SetAllOffline() error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`UPDATE peers SET status = 'OFFLINE'`)
return err
}
// BanPeer bans a specific peer by ID.
func (s *SQLiteDB) BanPeer(id string, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE peers SET banned = 1, ban_reason = ?, banned_at = datetime('now') WHERE id = ?`,
reason, id)
return err
}
// UnbanPeer removes the ban from a peer.
func (s *SQLiteDB) UnbanPeer(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE peers SET banned = 0, ban_reason = '', banned_at = NULL WHERE id = ?`, id)
return err
}
// IsPeerBanned checks if a peer is banned.
func (s *SQLiteDB) IsPeerBanned(id string) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var banned bool
err := s.db.QueryRow(`SELECT banned FROM peers WHERE id = ?`, id).Scan(&banned)
if err == sql.ErrNoRows {
return false, nil
}
return banned, err
}
// IsPeerSoftDeleted checks if a peer is soft-deleted.
func (s *SQLiteDB) IsPeerSoftDeleted(id string) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var deleted bool
err := s.db.QueryRow(`SELECT soft_deleted FROM peers WHERE id = ?`, id).Scan(&deleted)
if err == sql.ErrNoRows {
return false, nil
}
return deleted, err
}
// RestorePeer clears soft_deleted and deleted_at for a previously deleted
// peer. Required because UpsertPeer no longer does this implicitly
// (GHSA-3v82-3gf8-fxx8).
func (s *SQLiteDB) RestorePeer(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE peers SET soft_deleted = 0, deleted_at = NULL WHERE id = ?`,
id)
return err
}
// UpdatePeerFields updates specific peer fields (note, user, tags, device_type, linked_peer_id, display_name).
// Only provided keys are updated; others are left unchanged.
// Allowed keys: "note", "user", "tags", "device_type", "linked_peer_id".
func (s *SQLiteDB) UpdatePeerFields(id string, fields map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
allowed := map[string]bool{"note": true, "user": true, "tags": true, "device_type": true, "linked_peer_id": true, "display_name": true}
setClauses := []string{}
args := []interface{}{}
for k, v := range fields {
if !allowed[k] {
continue
}
setClauses = append(setClauses, k+" = ?")
args = append(args, v)
}
if len(setClauses) == 0 {
return nil
}
args = append(args, id)
query := "UPDATE peers SET " + strings.Join(setClauses, ", ") + " WHERE id = ? AND soft_deleted = 0"
_, err := s.db.Exec(query, args...)
return err
}
func (s *SQLiteDB) cascadePeerIDInTx(tx *sql.Tx, oldID, newID string) error {
stmts := []struct {
query string
args []any
}{
{`UPDATE device_tokens SET peer_id = ? WHERE peer_id = ?`, []any{newID, oldID}},
{`UPDATE org_devices SET device_id = ? WHERE device_id = ?`, []any{newID, oldID}},
{`UPDATE peers SET linked_peer_id = ? WHERE linked_peer_id = ?`, []any{newID, oldID}},
}
for _, st := range stmts {
if _, err := tx.Exec(st.query, st.args...); err != nil {
return fmt.Errorf("db: cascadePeerID %s→%s: %w", oldID, newID, err)
}
}
return nil
}
// ChangePeerID changes a peer's ID and records it in history.
func (s *SQLiteDB) ChangePeerID(oldID, newID, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var targetDeleted bool
if err := tx.QueryRow(`SELECT soft_deleted FROM peers WHERE id = ?`, newID).Scan(&targetDeleted); err != nil && err != sql.ErrNoRows {
return err
} else if err == nil {
if targetDeleted {
return ErrPeerIDSoftDeleted
}
return ErrPeerIDExists
}
// Create new row with new ID, copy data from old
result, err := tx.Exec(`
INSERT INTO peers (id, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name)
SELECT ?, uuid, pk, ip, user, hostname, os, version,
status, nat_type, last_online, created_at,
disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name
FROM peers WHERE id = ? AND (soft_deleted IS NULL OR soft_deleted = 0)`, newID, oldID)
if err != nil {
return fmt.Errorf("db: ChangePeerID insert: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: ChangePeerID rows affected: %w", err)
}
if rows == 0 {
return ErrPeerNotFound
}
// Delete old row
if _, err := tx.Exec(`DELETE FROM peers WHERE id = ?`, oldID); err != nil {
return fmt.Errorf("db: ChangePeerID delete: %w", err)
}
if err := s.cascadePeerIDInTx(tx, oldID, newID); err != nil {
return err
}
// Record in history
if _, err := tx.Exec(
`INSERT INTO id_change_history (old_id, new_id, reason) VALUES (?, ?, ?)`,
oldID, newID, reason); err != nil {
return fmt.Errorf("db: ChangePeerID history: %w", err)
}
return tx.Commit()
}
// GetIDChangeHistory returns the ID change history for a peer.
func (s *SQLiteDB) GetIDChangeHistory(id string) ([]*IDChangeHistory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(
`SELECT old_id, new_id, changed_at, reason FROM id_change_history
WHERE old_id = ? OR new_id = ? ORDER BY changed_at DESC`, id, id)
if err != nil {
return nil, err
}
defer rows.Close()
var history []*IDChangeHistory
for rows.Next() {
h := &IDChangeHistory{}
var changedAt string
if err := rows.Scan(&h.OldID, &h.NewID, &changedAt, &h.Reason); err != nil {
return nil, err
}
h.ChangedAt, _ = time.Parse("2006-01-02 15:04:05", changedAt)
history = append(history, h)
}
return history, rows.Err()
}
// IsRenamedPeerID returns true if the given ID was previously used and then
// changed to a different one (appears as old_id in id_change_history).
// This prevents a device from re-registering under its old ID after an
// admin-initiated ID change (#97).
func (s *SQLiteDB) IsRenamedPeerID(id string) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM id_change_history WHERE old_id = ?`, id).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetLatestRenameTarget returns the most recent new_id for old_id.
func (s *SQLiteDB) GetLatestRenameTarget(oldID string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var newID string
err := s.db.QueryRow(
`SELECT new_id FROM id_change_history WHERE old_id = ? ORDER BY rowid DESC LIMIT 1`,
oldID).Scan(&newID)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
return newID, nil
}
// ReleasePeerID clears id_change_history rows involving id.
func (s *SQLiteDB) ReleasePeerID(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`DELETE FROM id_change_history WHERE old_id = ? OR new_id = ?`, id, id)
return err
}
// GetLinkedPeers returns all non-deleted peers that have linked_peer_id matching the given ID.
func (s *SQLiteDB) GetLinkedPeers(id string) ([]*Peer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type,
last_online, created_at, disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name
FROM peers
WHERE soft_deleted = 0 AND linked_peer_id = ?
ORDER BY id`, id)
if err != nil {
return nil, fmt.Errorf("db: GetLinkedPeers: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID, &p.DisplayName,
); err != nil {
return nil, fmt.Errorf("db: GetLinkedPeers scan: %w", err)
}
p.LastOnline = parseTime(lastOnline)
p.CreatedAt = parseTime(createdAt)
p.BannedAt = parseTimePtr(bannedAt)
p.DeletedAt = parseTimePtr(deletedAt)
peers = append(peers, p)
}
return peers, rows.Err()
}
// GetConfig retrieves a configuration value by key.
func (s *SQLiteDB) GetConfig(key string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var value string
err := s.db.QueryRow(`SELECT value FROM server_config WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
// SetConfig sets a configuration key-value pair.
func (s *SQLiteDB) SetConfig(key, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`INSERT INTO server_config (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
// DeleteConfig removes a configuration key.
func (s *SQLiteDB) DeleteConfig(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM server_config WHERE key = ?`, key)
return err
}
// ListConfigByPrefix returns all configuration entries whose key starts with the given prefix.
func (s *SQLiteDB) ListConfigByPrefix(prefix string) ([]ServerConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT key, value FROM server_config WHERE key LIKE ?`,
prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var configs []ServerConfig
for rows.Next() {
var c ServerConfig
if err := rows.Scan(&c.Key, &c.Value); err != nil {
return nil, err
}
configs = append(configs, c)
}
return configs, rows.Err()
}
// UpdatePeerTags updates the tags field for a peer.
func (s *SQLiteDB) UpdatePeerTags(id, tags string) error {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`UPDATE peers SET tags = ? WHERE id = ?`, tags, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("peer %s not found", id)
}
return nil
}
// ListPeersByTag returns all non-deleted peers that have the given tag.
// Tags are stored as comma-separated strings; the query uses LIKE matching.
func (s *SQLiteDB) ListPeersByTag(tag string) ([]*Peer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// M1: Escape SQL LIKE wildcards to prevent pattern injection.
// '%' and '_' in user-supplied tag could match unintended rows.
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(tag)
pattern := "%" + escaped + "%"
rows, err := s.db.Query(`
SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type,
last_online, created_at, disabled, banned, ban_reason, banned_at,
soft_deleted, deleted_at, note, tags, heartbeat_seq,
device_type, linked_peer_id, display_name
FROM peers
WHERE soft_deleted = 0 AND tags LIKE ? ESCAPE '\'
ORDER BY id`, pattern)
if err != nil {
return nil, fmt.Errorf("db: ListPeersByTag: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname,
&p.OS, &p.Version, &p.Status, &p.NATType,
&lastOnline, &createdAt, &p.Disabled, &p.Banned,
&p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq,
&p.DeviceType, &p.LinkedPeerID, &p.DisplayName,
); err != nil {
return nil, fmt.Errorf("db: ListPeersByTag scan: %w", err)
}
p.LastOnline = parseTime(lastOnline)
p.CreatedAt = parseTime(createdAt)
p.BannedAt = parseTimePtr(bannedAt)
p.DeletedAt = parseTimePtr(deletedAt)
peers = append(peers, p)
}
return peers, rows.Err()
}
// Helper: parse SQLite datetime to time.Time
func parseTime(ns sql.NullString) time.Time {
if !ns.Valid || ns.String == "" {
return time.Time{}
}
t, _ := time.Parse("2006-01-02 15:04:05", ns.String)
return t
}
func parseTimePtr(ns sql.NullString) *time.Time {
if !ns.Valid || ns.String == "" {
return nil
}
t, _ := time.Parse("2006-01-02 15:04:05", ns.String)
return &t
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
// --- User Operations ---
// userSelectCols is the shared SELECT list for GetUser/GetUserByID/ListUsers.
// COALESCE guards against NULL text columns that Scan into string (Issue #292).
const userSelectCols = `id, username, password_hash, role, COALESCE(is_server_admin, 0),
COALESCE(auth_provider, 'local'), COALESCE(totp_secret, ''), totp_enabled, COALESCE(totp_recovery_codes, ''),
COALESCE(created_at, datetime('now')), COALESCE(last_login, '')`
// CreateUser inserts a new user.
func (s *SQLiteDB) CreateUser(u *User) error {
s.mu.Lock()
defer s.mu.Unlock()
if u.AuthProvider == "" {
u.AuthProvider = AuthProviderLocal
}
res, err := s.db.Exec(`INSERT INTO users (username, password_hash, role, auth_provider, totp_secret, totp_enabled, last_login)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
u.Username, u.PasswordHash, u.Role, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled, "")
if err != nil {
return fmt.Errorf("db: CreateUser: %w", err)
}
u.ID, _ = res.LastInsertId()
return nil
}
// GetUser returns a user by username, or nil if not found.
func (s *SQLiteDB) GetUser(username string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u := &User{}
err := s.db.QueryRow(`SELECT `+userSelectCols+` FROM users WHERE username = ?`, username).Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin,
&u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
// GetUserByID returns a user by numeric ID, or nil if not found.
func (s *SQLiteDB) GetUserByID(id int64) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u := &User{}
err := s.db.QueryRow(`SELECT `+userSelectCols+` FROM users WHERE id = ?`, id).Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin,
&u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
// ListUsers returns all users.
func (s *SQLiteDB) ListUsers() ([]*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT ` + userSelectCols + ` FROM users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("db: ListUsers: %w", err)
}
defer rows.Close()
var users []*User
for rows.Next() {
u := &User{}
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin,
&u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// UpdateUser updates a user's mutable fields.
func (s *SQLiteDB) UpdateUser(u *User) error {
s.mu.Lock()
defer s.mu.Unlock()
if u.AuthProvider == "" {
u.AuthProvider = AuthProviderLocal
}
_, err := s.db.Exec(`UPDATE users SET password_hash=?, role=?, is_server_admin=?, auth_provider=?,
totp_secret=?, totp_enabled=?, totp_recovery_codes=? WHERE id=?`,
u.PasswordHash, u.Role, u.IsServerAdmin, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled, u.TOTPRecoveryCodes, u.ID)
return err
}
// DeleteUser removes a user by ID and clears org membership links (Issue #292).
func (s *SQLiteDB) DeleteUser(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.db.Exec(`DELETE FROM org_users WHERE server_user_id = ? AND server_user_id > 0`, id); err != nil {
// org_users may be absent on very old DBs before org migrations.
if !strings.Contains(err.Error(), "no such table") {
return fmt.Errorf("db: DeleteUser org cleanup: %w", err)
}
}
_, err := s.db.Exec(`DELETE FROM users WHERE id=?`, id)
return err
}
// UpdateUserLogin updates the last_login timestamp for a user.
func (s *SQLiteDB) UpdateUserLogin(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`UPDATE users SET last_login=datetime('now') WHERE id=?`, id)
return err
}
// NullifyUserLoginFieldsForTest sets last_login/totp_secret to SQL NULL for
// Issue #292 regression tests (never-logged-in / legacy rows).
func (s *SQLiteDB) NullifyUserLoginFieldsForTest(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`UPDATE users SET last_login = NULL, totp_secret = NULL WHERE id = ?`, id)
return err
}
// UserCount returns the total number of users.
func (s *SQLiteDB) UserCount() (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
// --- API Key Operations ---
// CreateAPIKey inserts a new API key.
func (s *SQLiteDB) CreateAPIKey(k *APIKey) error {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`INSERT INTO api_keys (key_hash, key_prefix, name, role, expires_at)
VALUES (?, ?, ?, ?, ?)`,
k.KeyHash, k.KeyPrefix, k.Name, k.Role, k.ExpiresAt)
if err != nil {
return fmt.Errorf("db: CreateAPIKey: %w", err)
}
k.ID, _ = res.LastInsertId()
return nil
}
// GetAPIKeyByHash returns an API key by its SHA-256 hash, or nil if not found.
func (s *SQLiteDB) GetAPIKeyByHash(keyHash string) (*APIKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
k := &APIKey{}
err := s.db.QueryRow(`SELECT id, key_hash, key_prefix, name, role, created_at, expires_at, last_used
FROM api_keys WHERE key_hash = ?`, keyHash).Scan(
&k.ID, &k.KeyHash, &k.KeyPrefix, &k.Name, &k.Role,
&k.CreatedAt, &k.ExpiresAt, &k.LastUsed)
if err == sql.ErrNoRows {
return nil, nil
}
return k, err
}
// ListAPIKeys returns all API keys.
func (s *SQLiteDB) ListAPIKeys() ([]*APIKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, key_hash, key_prefix, name, role, created_at, expires_at, last_used
FROM api_keys ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("db: ListAPIKeys: %w", err)
}
defer rows.Close()
var keys []*APIKey
for rows.Next() {
k := &APIKey{}
if err := rows.Scan(&k.ID, &k.KeyHash, &k.KeyPrefix, &k.Name, &k.Role,
&k.CreatedAt, &k.ExpiresAt, &k.LastUsed); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
// DeleteAPIKey removes an API key by ID.
func (s *SQLiteDB) DeleteAPIKey(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id=?`, id)
return err
}
// TouchAPIKey updates the last_used timestamp for an API key.
func (s *SQLiteDB) TouchAPIKey(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`UPDATE api_keys SET last_used=datetime('now') WHERE id=?`, id)
return err
}
// ============================================================================
// Device Token Methods (Dual Key System)
// ============================================================================
// CreateDeviceToken creates a new device enrollment token.
func (s *SQLiteDB) CreateDeviceToken(t *DeviceToken) error {
s.mu.Lock()
defer s.mu.Unlock()
var expiresAt sql.NullString
if t.ExpiresAt != nil {
expiresAt.Valid = true
expiresAt.String = t.ExpiresAt.Format(time.RFC3339)
}
res, err := s.db.Exec(`
INSERT INTO device_tokens (token_hash, token_prefix, name, peer_id, status, max_uses, use_count, expires_at, created_by, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.TokenHash, t.Token[:8], t.Name, t.PeerID, t.Status, t.MaxUses, t.UseCount, expiresAt, t.CreatedBy, t.Note)
if err != nil {
return fmt.Errorf("db: CreateDeviceToken: %w", err)
}
id, _ := res.LastInsertId()
t.ID = id
return nil
}
// GetDeviceToken returns a device token by ID.
func (s *SQLiteDB) GetDeviceToken(id int64) (*DeviceToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.getDeviceTokenByQuery(`SELECT id, token_hash, token_prefix, name, peer_id, status, max_uses, use_count,
created_at, expires_at, revoked_at, last_used_at, created_by, note
FROM device_tokens WHERE id = ?`, id)
}
// GetDeviceTokenByHash returns a device token by its hash.
func (s *SQLiteDB) GetDeviceTokenByHash(tokenHash string) (*DeviceToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.getDeviceTokenByQuery(`SELECT id, token_hash, token_prefix, name, peer_id, status, max_uses, use_count,
created_at, expires_at, revoked_at, last_used_at, created_by, note
FROM device_tokens WHERE token_hash = ?`, tokenHash)
}
// GetDeviceTokenByPeerID returns the token bound to a peer.
func (s *SQLiteDB) GetDeviceTokenByPeerID(peerID string) (*DeviceToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.getDeviceTokenByQuery(`SELECT id, token_hash, token_prefix, name, peer_id, status, max_uses, use_count,
created_at, expires_at, revoked_at, last_used_at, created_by, note
FROM device_tokens WHERE peer_id = ? AND status IN ('active', 'pending')`, peerID)
}
// getDeviceTokenByQuery is a helper function to scan a device token row.
func (s *SQLiteDB) getDeviceTokenByQuery(query string, args ...interface{}) (*DeviceToken, error) {
t := &DeviceToken{}
var createdAt, expiresAt, revokedAt, lastUsedAt sql.NullString
err := s.db.QueryRow(query, args...).Scan(
&t.ID, &t.TokenHash, &t.Token, &t.Name, &t.PeerID, &t.Status, &t.MaxUses, &t.UseCount,
&createdAt, &expiresAt, &revokedAt, &lastUsedAt, &t.CreatedBy, &t.Note)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if createdAt.Valid {
if tm, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
t.CreatedAt = tm
} else if tm, err := time.Parse("2006-01-02 15:04:05", createdAt.String); err == nil {
t.CreatedAt = tm
}
}
if expiresAt.Valid {
if tm, err := time.Parse(time.RFC3339, expiresAt.String); err == nil {
t.ExpiresAt = &tm
} else if tm, err := time.Parse("2006-01-02 15:04:05", expiresAt.String); err == nil {
t.ExpiresAt = &tm
}
}
if revokedAt.Valid {
if tm, err := time.Parse(time.RFC3339, revokedAt.String); err == nil {
t.RevokedAt = &tm
} else if tm, err := time.Parse("2006-01-02 15:04:05", revokedAt.String); err == nil {
t.RevokedAt = &tm
}
}
if lastUsedAt.Valid {
if tm, err := time.Parse(time.RFC3339, lastUsedAt.String); err == nil {
t.LastUsedAt = &tm
} else if tm, err := time.Parse("2006-01-02 15:04:05", lastUsedAt.String); err == nil {
t.LastUsedAt = &tm
}
}
return t, nil
}
// ListDeviceTokens returns all device tokens.
func (s *SQLiteDB) ListDeviceTokens(includeRevoked bool) ([]*DeviceToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
query := `SELECT id, token_hash, token_prefix, name, peer_id, status, max_uses, use_count,
created_at, expires_at, revoked_at, last_used_at, created_by, note
FROM device_tokens`
if !includeRevoked {
query += ` WHERE status != 'revoked'`
}
query += ` ORDER BY id DESC`
rows, err := s.db.Query(query)
if err != nil {
return nil, fmt.Errorf("db: ListDeviceTokens: %w", err)
}
defer rows.Close()
var tokens []*DeviceToken
for rows.Next() {
t := &DeviceToken{}
var createdAt, expiresAt, revokedAt, lastUsedAt sql.NullString
if err := rows.Scan(&t.ID, &t.TokenHash, &t.Token, &t.Name, &t.PeerID, &t.Status,
&t.MaxUses, &t.UseCount, &createdAt, &expiresAt, &revokedAt, &lastUsedAt,
&t.CreatedBy, &t.Note); err != nil {
return nil, err
}
if createdAt.Valid {
if tm, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
t.CreatedAt = tm
} else if tm, err := time.Parse("2006-01-02 15:04:05", createdAt.String); err == nil {
t.CreatedAt = tm
}
}
if expiresAt.Valid {
if tm, err := time.Parse(time.RFC3339, expiresAt.String); err == nil {
t.ExpiresAt = &tm
}
}
if revokedAt.Valid {
if tm, err := time.Parse(time.RFC3339, revokedAt.String); err == nil {
t.RevokedAt = &tm
}
}
if lastUsedAt.Valid {
if tm, err := time.Parse(time.RFC3339, lastUsedAt.String); err == nil {
t.LastUsedAt = &tm
}
}
tokens = append(tokens, t)
}
return tokens, rows.Err()
}
// UpdateDeviceToken updates a device token.
func (s *SQLiteDB) UpdateDeviceToken(t *DeviceToken) error {
s.mu.Lock()
defer s.mu.Unlock()
var expiresAt sql.NullString
if t.ExpiresAt != nil {
expiresAt.Valid = true
expiresAt.String = t.ExpiresAt.Format(time.RFC3339)
}
_, err := s.db.Exec(`
UPDATE device_tokens SET name=?, peer_id=?, status=?, max_uses=?, expires_at=?, note=?
WHERE id=?`, t.Name, t.PeerID, t.Status, t.MaxUses, expiresAt, t.Note, t.ID)
return err
}
// RevokeDeviceToken revokes a device token by ID.
func (s *SQLiteDB) RevokeDeviceToken(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`UPDATE device_tokens SET status='revoked', revoked_at=datetime('now') WHERE id=?`, id)
return err
}
// BindTokenToPeer binds a token to a peer ID after successful enrollment.
func (s *SQLiteDB) BindTokenToPeer(tokenHash, peerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`
UPDATE device_tokens SET peer_id=?, status='active', last_used_at=datetime('now')
WHERE token_hash=? AND status='pending'`, peerID, tokenHash)
return err
}
// IncrementTokenUse increments the use count for a token.
func (s *SQLiteDB) IncrementTokenUse(tokenHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`
UPDATE device_tokens SET use_count = use_count + 1, last_used_at=datetime('now')
WHERE token_hash=?`, tokenHash)
return err
}
// ValidateToken checks if a token is valid for enrollment.
// Returns the token if valid, nil if invalid/expired/revoked.
func (s *SQLiteDB) ValidateToken(tokenHash string) (*DeviceToken, error) {
t, err := s.GetDeviceTokenByHash(tokenHash)
if err != nil {
return nil, err
}
if t == nil {
return nil, nil // Token not found
}
// Check status
if t.Status == TokenStatusRevoked {
return nil, nil
}
// Check expiration
if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
// Mark as expired if not already
s.mu.Lock()
s.db.Exec(`UPDATE device_tokens SET status='expired' WHERE id=? AND status NOT IN ('revoked', 'expired')`, t.ID)
s.mu.Unlock()
return nil, nil
}
// Check max uses
if t.MaxUses > 0 && t.UseCount >= t.MaxUses {
return nil, nil // Exceeded max uses
}
return t, nil
}
// CleanupExpiredTokens marks expired tokens as expired.
func (s *SQLiteDB) CleanupExpiredTokens() (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`
UPDATE device_tokens SET status='expired'
WHERE status='pending' AND expires_at IS NOT NULL AND expires_at < datetime('now')`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ── Address Book ──────────────────────────────────────────────────────
// GetAddressBook retrieves the address book data for a user.
func (s *SQLiteDB) GetAddressBook(username, abType string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var data string
err := s.db.QueryRow(
`SELECT data FROM address_books WHERE username = ? AND ab_type = ?`,
username, abType).Scan(&data)
if err == sql.ErrNoRows {
return "{}", nil
}
return data, err
}
// SaveAddressBook stores the address book data for a user (upsert).
func (s *SQLiteDB) SaveAddressBook(username, abType, data string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`INSERT INTO address_books (username, ab_type, data, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(username, ab_type) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
username, abType, data)
return err
}
// ── Peer Metrics ──────────────────────────────────────────────────────
// SavePeerMetric inserts a new heartbeat metric data point for a peer.
func (s *SQLiteDB) SavePeerMetric(peerID string, cpu, memory, disk float64) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`INSERT INTO peer_metrics (peer_id, cpu_usage, memory_usage, disk_usage, created_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
peerID, cpu, memory, disk)
return err
}
// GetPeerMetrics returns the most recent metric data points for a peer.
func (s *SQLiteDB) GetPeerMetrics(peerID string, limit int) ([]*PeerMetric, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(
`SELECT id, peer_id, cpu_usage, memory_usage, disk_usage, created_at
FROM peer_metrics WHERE peer_id = ? ORDER BY created_at DESC LIMIT ?`,
peerID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var metrics []*PeerMetric
for rows.Next() {
var m PeerMetric
var createdAt string
if err := rows.Scan(&m.ID, &m.PeerID, &m.CPU, &m.Memory, &m.Disk, &createdAt); err != nil {
return nil, err
}
m.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
metrics = append(metrics, &m)
}
return metrics, nil
}
// GetLatestPeerMetric returns the most recent metric for a peer.
func (s *SQLiteDB) GetLatestPeerMetric(peerID string) (*PeerMetric, error) {
metrics, err := s.GetPeerMetrics(peerID, 1)
if err != nil {
return nil, err
}
if len(metrics) == 0 {
return nil, nil
}
return metrics[0], nil
}
// CleanupOldMetrics deletes metric records older than maxAge.
// Returns the number of deleted rows.
func (s *SQLiteDB) CleanupOldMetrics(maxAge time.Duration) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-maxAge).UTC().Format("2006-01-02 15:04:05")
result, err := s.db.Exec(
`DELETE FROM peer_metrics WHERE created_at < ?`, cutoff)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// ---------------------------------------------------------------------------
// Chat Messages
// ---------------------------------------------------------------------------
// SaveChatMessage inserts a new chat message and returns its ID.
func (s *SQLiteDB) SaveChatMessage(msg *ChatMessage) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
result, err := s.db.Exec(
`INSERT INTO chat_messages (conversation_id, from_id, from_name, to_id, text, read)
VALUES (?, ?, ?, ?, ?, ?)`,
msg.ConversationID, msg.FromID, msg.FromName, msg.ToID, msg.Text, msg.Read,
)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
// GetChatHistory returns the most recent messages for a conversation.
func (s *SQLiteDB) GetChatHistory(conversationID string, limit int) ([]*ChatMessage, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(
`SELECT id, conversation_id, from_id, from_name, to_id, text, read, created_at
FROM chat_messages WHERE conversation_id = ?
ORDER BY id DESC LIMIT ?`,
conversationID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []*ChatMessage
for rows.Next() {
var m ChatMessage
var createdAt string
if err := rows.Scan(&m.ID, &m.ConversationID, &m.FromID, &m.FromName, &m.ToID, &m.Text, &m.Read, &createdAt); err != nil {
return nil, err
}
m.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
msgs = append(msgs, &m)
}
// Reverse so oldest is first
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
// GetChatHistoryBefore returns messages before a given ID (for pagination).
func (s *SQLiteDB) GetChatHistoryBefore(conversationID string, beforeID int64, limit int) ([]*ChatMessage, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(
`SELECT id, conversation_id, from_id, from_name, to_id, text, read, created_at
FROM chat_messages WHERE conversation_id = ? AND id < ?
ORDER BY id DESC LIMIT ?`,
conversationID, beforeID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []*ChatMessage
for rows.Next() {
var m ChatMessage
var createdAt string
if err := rows.Scan(&m.ID, &m.ConversationID, &m.FromID, &m.FromName, &m.ToID, &m.Text, &m.Read, &createdAt); err != nil {
return nil, err
}
m.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
msgs = append(msgs, &m)
}
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
// MarkChatRead marks all messages in a conversation as read for a given reader.
func (s *SQLiteDB) MarkChatRead(conversationID, readerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE chat_messages SET read = 1
WHERE conversation_id = ? AND from_id != ? AND read = 0`,
conversationID, readerID,
)
return err
}
// GetUnreadCount returns total unread messages for a device across all conversations.
func (s *SQLiteDB) GetUnreadCount(deviceID string) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM chat_messages
WHERE (conversation_id = ? OR to_id = ?) AND from_id != ? AND read = 0`,
deviceID, deviceID, deviceID,
).Scan(&count)
return count, err
}
// DeleteChatHistory removes all messages for a conversation.
func (s *SQLiteDB) DeleteChatHistory(conversationID string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM chat_messages WHERE conversation_id = ?`, conversationID)
return err
}
// ---------------------------------------------------------------------------
// Chat Groups
// ---------------------------------------------------------------------------
// CreateChatGroup inserts a new chat group.
func (s *SQLiteDB) CreateChatGroup(g *ChatGroup) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`INSERT INTO chat_groups (id, name, members, created_by) VALUES (?, ?, ?, ?)`,
g.ID, g.Name, g.Members, g.CreatedBy,
)
return err
}
// GetChatGroup returns a chat group by ID.
func (s *SQLiteDB) GetChatGroup(id string) (*ChatGroup, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var g ChatGroup
var createdAt string
err := s.db.QueryRow(
`SELECT id, name, members, created_by, created_at FROM chat_groups WHERE id = ?`, id,
).Scan(&g.ID, &g.Name, &g.Members, &g.CreatedBy, &createdAt)
if err != nil {
return nil, err
}
g.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
return &g, nil
}
// ListChatGroups returns all groups where memberID is in the members list.
func (s *SQLiteDB) ListChatGroups(memberID string) ([]*ChatGroup, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Use LIKE with escaped wildcards for comma-separated member search
pattern := "%" + memberID + "%"
rows, err := s.db.Query(
`SELECT id, name, members, created_by, created_at FROM chat_groups
WHERE members LIKE ?`, pattern,
)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []*ChatGroup
for rows.Next() {
var g ChatGroup
var createdAt string
if err := rows.Scan(&g.ID, &g.Name, &g.Members, &g.CreatedBy, &createdAt); err != nil {
return nil, err
}
g.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
groups = append(groups, &g)
}
return groups, nil
}
// UpdateChatGroup updates a chat group's name or members.
func (s *SQLiteDB) UpdateChatGroup(g *ChatGroup) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`UPDATE chat_groups SET name = ?, members = ? WHERE id = ?`,
g.Name, g.Members, g.ID,
)
return err
}
// DeleteChatGroup removes a chat group.
func (s *SQLiteDB) DeleteChatGroup(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM chat_groups WHERE id = ?`, id)
return err
}
// ============================================================
// Access Policies (unattended access management)
// ============================================================
// GetAccessPolicy retrieves the access policy for a peer device.
func (s *SQLiteDB) GetAccessPolicy(peerID string) (*AccessPolicy, error) {
s.mu.RLock()
defer s.mu.RUnlock()
row := s.db.QueryRow(
`SELECT peer_id, unattended_enabled, password_hash, schedule_enabled,
schedule_days, schedule_start_time, schedule_end_time, schedule_timezone,
allowed_operators, updated_at, updated_by
FROM access_policies WHERE peer_id = ?`, peerID)
var p AccessPolicy
err := row.Scan(&p.PeerID, &p.UnattendedEnabled, &p.PasswordHash, &p.ScheduleEnabled,
&p.ScheduleDays, &p.ScheduleStartTime, &p.ScheduleEndTime, &p.ScheduleTimezone,
&p.AllowedOperators, &p.UpdatedAt, &p.UpdatedBy)
if err != nil {
return nil, err
}
p.PasswordSet = p.PasswordHash != ""
return &p, nil
}
// SaveAccessPolicy creates or updates the access policy for a peer device.
func (s *SQLiteDB) SaveAccessPolicy(p *AccessPolicy) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(
`INSERT INTO access_policies (peer_id, unattended_enabled, password_hash,
schedule_enabled, schedule_days, schedule_start_time, schedule_end_time,
schedule_timezone, allowed_operators, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(peer_id) DO UPDATE SET
unattended_enabled = excluded.unattended_enabled,
password_hash = CASE WHEN excluded.password_hash = '' THEN access_policies.password_hash WHEN excluded.password_hash = 'CLEAR' THEN '' ELSE excluded.password_hash END,
schedule_enabled = excluded.schedule_enabled,
schedule_days = excluded.schedule_days,
schedule_start_time = excluded.schedule_start_time,
schedule_end_time = excluded.schedule_end_time,
schedule_timezone = excluded.schedule_timezone,
allowed_operators = excluded.allowed_operators,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by`,
p.PeerID, p.UnattendedEnabled, p.PasswordHash,
p.ScheduleEnabled, p.ScheduleDays, p.ScheduleStartTime, p.ScheduleEndTime,
p.ScheduleTimezone, p.AllowedOperators, p.UpdatedAt, p.UpdatedBy)
return err
}
// DeleteAccessPolicy removes the access policy for a peer device.
func (s *SQLiteDB) DeleteAccessPolicy(peerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM access_policies WHERE peer_id = ?`, peerID)
return err
}
// --- Role Permissions (RBAC Phase 52) ---
// ListRolePermissions returns all custom permission overrides for a role.
func (s *SQLiteDB) ListRolePermissions(role string) ([]*RolePermission, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, role, permission, granted FROM role_permissions WHERE role = ?`, role)
if err != nil {
return nil, fmt.Errorf("db: ListRolePermissions: %w", err)
}
defer rows.Close()
var perms []*RolePermission
for rows.Next() {
p := &RolePermission{}
if err := rows.Scan(&p.ID, &p.Role, &p.Permission, &p.Granted); err != nil {
return nil, err
}
perms = append(perms, p)
}
return perms, rows.Err()
}
// SetRolePermission creates or updates a custom permission override for a role.
func (s *SQLiteDB) SetRolePermission(role, permission string, granted bool) error {
s.mu.Lock()
defer s.mu.Unlock()
grantedInt := 0
if granted {
grantedInt = 1
}
_, err := s.db.Exec(`INSERT INTO role_permissions (role, permission, granted)
VALUES (?, ?, ?) ON CONFLICT(role, permission) DO UPDATE SET granted = excluded.granted`,
role, permission, grantedInt)
return err
}
// DeleteRolePermission removes a custom permission override, reverting to defaults.
func (s *SQLiteDB) DeleteRolePermission(role, permission string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM role_permissions WHERE role = ? AND permission = ?`, role, permission)
return err
}
// HasRolePermission checks if a custom permission override exists for a role.
// Returns (granted_value, nil) if found, or (false, error) if no override exists.
func (s *SQLiteDB) HasRolePermission(role, permission string) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var granted bool
err := s.db.QueryRow(`SELECT granted FROM role_permissions WHERE role = ? AND permission = ?`,
role, permission).Scan(&granted)
if err == sql.ErrNoRows {
return false, fmt.Errorf("no override")
}
return granted, err
}
// --- Org-scoped device queries (RBAC Phase 52) ---
// ListPeersForOrg returns only peers assigned to the given organization.
func (s *SQLiteDB) ListPeersForOrg(orgID string, includeDeleted bool) ([]*Peer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
query := `SELECT p.id, p.uuid, p.pk, p.ip, p.user, p.hostname, p.os, p.version,
p.status, p.nat_type, p.last_online, p.created_at, p.disabled,
p.banned, p.ban_reason, p.banned_at, p.soft_deleted, p.deleted_at,
p.note, p.tags, p.heartbeat_seq, COALESCE(p.display_name, ''),
COALESCE(p.device_type, ''), COALESCE(p.linked_peer_id, '')
FROM peers p INNER JOIN org_devices od ON p.id = od.device_id
WHERE od.org_id = ?`
if !includeDeleted {
query += ` AND p.soft_deleted = 0`
}
query += ` ORDER BY p.last_online DESC`
rows, err := s.db.Query(query, orgID)
if err != nil {
return nil, fmt.Errorf("db: ListPeersForOrg: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname, &p.OS, &p.Version,
&p.Status, &p.NATType, &lastOnline, &createdAt, &p.Disabled,
&p.Banned, &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq, &p.DisplayName,
&p.DeviceType, &p.LinkedPeerID,
); err != nil {
return nil, err
}
if lastOnline.Valid {
p.LastOnline, _ = time.Parse("2006-01-02 15:04:05", lastOnline.String)
}
if createdAt.Valid {
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt.String)
}
if bannedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", bannedAt.String)
p.BannedAt = &t
}
if deletedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", deletedAt.String)
p.DeletedAt = &t
}
peers = append(peers, p)
}
return peers, rows.Err()
}
// ListPeersForOrgPaginated returns an org-scoped peer page and total count.
func (s *SQLiteDB) ListPeersForOrgPaginated(orgID string, includeDeleted bool, limit, offset int) ([]*Peer, int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
where := ` FROM peers p INNER JOIN org_devices od ON p.id = od.device_id WHERE od.org_id = ?`
args := []any{orgID}
if !includeDeleted {
where += ` AND p.soft_deleted = 0`
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*)`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("db: ListPeersForOrgPaginated count: %w", err)
}
query := `SELECT p.id, p.uuid, p.pk, p.ip, p.user, p.hostname, p.os, p.version,
p.status, p.nat_type, p.last_online, p.created_at, p.disabled,
p.banned, p.ban_reason, p.banned_at, p.soft_deleted, p.deleted_at,
p.note, p.tags, p.heartbeat_seq, COALESCE(p.display_name, ''),
COALESCE(p.device_type, ''), COALESCE(p.linked_peer_id, '')` + where +
` ORDER BY p.last_online DESC`
if limit > 0 {
query += fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, 0, fmt.Errorf("db: ListPeersForOrgPaginated: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname, &p.OS, &p.Version,
&p.Status, &p.NATType, &lastOnline, &createdAt, &p.Disabled,
&p.Banned, &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq, &p.DisplayName,
&p.DeviceType, &p.LinkedPeerID,
); err != nil {
return nil, 0, err
}
if lastOnline.Valid {
p.LastOnline, _ = time.Parse("2006-01-02 15:04:05", lastOnline.String)
}
if createdAt.Valid {
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt.String)
}
if bannedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", bannedAt.String)
p.BannedAt = &t
}
if deletedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", deletedAt.String)
p.DeletedAt = &t
}
peers = append(peers, p)
}
return peers, total, rows.Err()
}