Files
lpra/internal/server/db.go
T

583 lines
17 KiB
Go

package server
import (
"database/sql"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
// DB wraps the SQLite connection and all queries used by the server.
type DB struct {
sql *sql.DB
}
// OpenDB opens (creating if needed) the SQLite database at path and runs
// migrations. The pure-Go driver ("sqlite") needs no CGO.
func OpenDB(path string) (*DB, error) {
// _pragma busy_timeout avoids "database is locked" under the concurrent
// long-poll load; foreign_keys keeps the schema honest.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path)
sdb, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// SQLite writes serialize; a single connection avoids lock churn while the
// busy_timeout still covers the long-poll readers.
sdb.SetMaxOpenConns(1)
if err := sdb.Ping(); err != nil {
return nil, err
}
db := &DB{sql: sdb}
if err := db.migrate(); err != nil {
return nil, err
}
return db, nil
}
func (db *DB) Close() error { return db.sql.Close() }
func (db *DB) migrate() error {
const schema = `
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
os TEXT NOT NULL,
arch TEXT NOT NULL,
token_hash TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '',
run_user TEXT NOT NULL DEFAULT '',
login_user TEXT NOT NULL DEFAULT '',
enrolled_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER,
agent_id TEXT,
action TEXT NOT NULL,
at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ufw_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
port INTEGER NOT NULL,
proto TEXT NOT NULL,
rule_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
UNIQUE(port, proto)
);
CREATE TABLE IF NOT EXISTS admin_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
cred_id BLOB UNIQUE NOT NULL,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS forward_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
client_port INTEGER NOT NULL,
host_port INTEGER NOT NULL,
listen_addr TEXT NOT NULL DEFAULT '0.0.0.0',
proto TEXT NOT NULL DEFAULT 'http',
created_at INTEGER NOT NULL
);`
if _, err := db.sql.Exec(schema); err != nil {
return err
}
// Add columns to databases created before they existed. SQLite has no
// "IF NOT EXISTS" for columns, so ignore the "duplicate column" error.
for _, alter := range []string{
`ALTER TABLE forward_rules ADD COLUMN proto TEXT NOT NULL DEFAULT 'http'`,
`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ufw_rules ADD COLUMN rule_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
} {
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
}
}
return nil
}
// --- config kv ---
func (db *DB) GetConfig(key string) (string, error) {
var v string
err := db.sql.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
return v, err
}
func (db *DB) SetConfig(key, value string) error {
_, err := db.sql.Exec(
`INSERT INTO config(key, value) VALUES(?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
// --- admins ---
func (db *DB) CountAdmins() (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&n)
return n, err
}
func (db *DB) CreateAdmin(username, passHash string) error {
_, err := db.sql.Exec(
`INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`,
username, passHash, time.Now().Unix())
return err
}
// Admin is one row of the admins table.
type Admin struct {
ID int64
Username string
PassHash string
}
func (db *DB) AdminByUsername(username string) (*Admin, error) {
a := &Admin{}
err := db.sql.QueryRow(
`SELECT id, username, pass_hash FROM admins WHERE username = ?`, username).
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func (db *DB) ListAdmins() ([]Admin, error) {
rows, err := db.sql.Query(`SELECT id, username FROM admins ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Admin
for rows.Next() {
var a Admin
if err := rows.Scan(&a.ID, &a.Username); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (db *DB) DeleteAdmin(id int64) error {
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
return err
}
// AdminKey is a passkey an admin signs in with.
type AdminKey struct {
ID int64
AdminID int64
CredID []byte
PublicKey []byte // PKIX DER
SignCount uint32
Name string
CreatedAt int64
LastUsed int64
}
func (db *DB) AdminByID(id int64) (*Admin, error) {
a := &Admin{}
err := db.sql.QueryRow(`SELECT id, username, pass_hash FROM admins WHERE id = ?`, id).
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func (db *DB) AddAdminKey(k AdminKey) error {
_, err := db.sql.Exec(
`INSERT INTO admin_keys(admin_id, cred_id, public_key, sign_count, name, created_at) VALUES(?, ?, ?, ?, ?, ?)`,
k.AdminID, k.CredID, k.PublicKey, k.SignCount, k.Name, time.Now().Unix())
return err
}
func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) {
rows, err := db.sql.Query(
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
FROM admin_keys WHERE admin_id = ? ORDER BY id`, adminID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AdminKey
for rows.Next() {
var k AdminKey
if err := rows.Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// CountAdminKeys is how many passkeys an admin has: with any, the password
// no longer signs them in.
func (db *DB) CountAdminKeys(adminID int64) int {
var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n)
return n
}
func (db *DB) AdminKeyByCredID(credID []byte) (*AdminKey, error) {
k := &AdminKey{}
err := db.sql.QueryRow(
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
FROM admin_keys WHERE cred_id = ?`, credID).
Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed)
if err == sql.ErrNoRows {
return nil, nil
}
return k, err
}
func (db *DB) UseAdminKey(id int64, signCount uint32) error {
_, err := db.sql.Exec(`UPDATE admin_keys SET sign_count = ?, last_used = ? WHERE id = ?`, signCount, time.Now().Unix(), id)
return err
}
func (db *DB) DeleteAdminKey(adminID, id int64) error {
_, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id)
return err
}
func (db *DB) DeleteAdminKeys(adminID int64) error {
_, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID)
return err
}
// --- admin sessions ---
func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
now := time.Now()
_, err := db.sql.Exec(
`INSERT INTO admin_sessions(id, admin_id, created_at, expires_at) VALUES(?, ?, ?, ?)`,
id, adminID, now.Unix(), now.Add(ttl).Unix())
return err
}
// SessionAdmin returns the admin for a live (non-expired) session, or nil.
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
a := &Admin{}
err := db.sql.QueryRow(
`SELECT a.id, a.username, a.pass_hash
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()).
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func (db *DB) DeleteSession(id string) error {
_, err := db.sql.Exec(`DELETE FROM admin_sessions WHERE id = ?`, id)
return err
}
// --- agents ---
// Agent is one row of the agents table.
type Agent struct {
ID string
Name string
Hostname string
OS string
Arch string
TokenHash string
Version string
RunUser string // OS user the agent process runs as (reported by the agent)
LoginUser string // OS user terminals are opened as; "" = RunUser
LastSeen time.Time
}
// TerminalUser is the OS user a terminal on this agent runs as. Agents are
// installed as a root systemd service, so that is the fallback for agents too
// old to report their user.
func (a Agent) TerminalUser() string {
return firstNonEmpty(a.LoginUser, a.RunUser, "root")
}
// recentlySeen is the fallback for "online" right after a server start, while
// the hub has not seen the agents' polls yet (see Server.online).
func (a Agent) recentlySeen() bool {
return time.Since(a.LastSeen) < 45*time.Second
}
func (db *DB) CreateAgent(a Agent) error {
now := time.Now().Unix()
_, err := db.sql.Exec(
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, run_user, enrolled_at, last_seen)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, a.RunUser, now, now)
return err
}
func (db *DB) GetAgent(id string) (*Agent, error) {
a := &Agent{}
var last int64
err := db.sql.QueryRow(
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id).
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
a.LastSeen = time.Unix(last, 0)
return a, nil
}
func (db *DB) ListAgents() ([]Agent, error) {
rows, err := db.sql.Query(
`SELECT id, name, hostname, os, arch, version, run_user, login_user, last_seen FROM agents ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Agent
for rows.Next() {
var a Agent
var last int64
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &a.RunUser, &a.LoginUser, &last); err != nil {
return nil, err
}
a.LastSeen = time.Unix(last, 0)
out = append(out, a)
}
return out, rows.Err()
}
func (db *DB) TouchAgent(id string) error {
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
return err
}
// TouchAgentInfo updates last_seen together with the reported version and OS user.
func (db *DB) TouchAgentInfo(id, version, runUser string) error {
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ?, run_user = ? WHERE id = ?`,
time.Now().Unix(), version, runUser, id)
return err
}
// SetAgentLoginUser sets the OS user terminals are opened as ("" = the agent's own).
func (db *DB) SetAgentLoginUser(id, user string) error {
_, err := db.sql.Exec(`UPDATE agents SET login_user = ? WHERE id = ?`, user, id)
return err
}
func (db *DB) DeleteAgent(id string) error {
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id)
return err
}
func (db *DB) RenameAgent(id, name string) error {
_, err := db.sql.Exec(`UPDATE agents SET name = ? WHERE id = ?`, name, id)
return err
}
// --- forward rules ---
// ForwardRule maps a port on the agent's localhost to a port on the host.
// Proto is "http" (long-poll), "tcp" (direct mux) or "udp" (direct).
type ForwardRule struct {
ID int64
AgentID string
ClientPort int
HostPort int
ListenAddr string
Proto string
NoUfw bool // the panel opens no ufw rule for this forward
Label string // optional note shown next to the rule
}
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
if r.Proto == "" {
r.Proto = "http"
}
res, err := db.sql.Exec(
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
rows, err := db.sql.Query(
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ForwardRule
for rows.Next() {
var r ForwardRule
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
all, err := db.ListForwardRules()
if err != nil {
return nil, err
}
var out []ForwardRule
for _, r := range all {
if r.AgentID == agentID {
out = append(out, r)
}
}
return out, nil
}
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
r := &ForwardRule{}
err := db.sql.QueryRow(
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules WHERE id = ?`, id).
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label)
if err == sql.ErrNoRows {
return nil, nil
}
return r, err
}
func (db *DB) DeleteForwardRule(id int64) error {
_, err := db.sql.Exec(`DELETE FROM forward_rules WHERE id = ?`, id)
return err
}
// --- ufw rules created by the panel ---
type UfwRule struct {
ID int64
Port int
Proto string
RuleID int64 // bound forward rule id, 0 for shared (the tunnel port), ufwManual for one added in Settings
}
// ufwManual is the RuleID of a ufw rule the admin added by hand: no forward
// owns it, so only the admin removes it.
const ufwManual = -1
// Manual reports whether the admin added the rule by hand.
func (r UfwRule) Manual() bool { return r.RuleID == ufwManual }
// SetUfwRuleManual hands a tracked rule over to the admin.
func (db *DB) SetUfwRuleManual(port int, proto string) error {
_, err := db.sql.Exec(`UPDATE ufw_rules SET rule_id = ? WHERE port = ? AND proto = ?`, ufwManual, port, proto)
return err
}
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
_, err := db.sql.Exec(
`INSERT OR IGNORE INTO ufw_rules(port, proto, rule_id, created_at) VALUES(?, ?, ?, ?)`,
port, proto, ruleID, time.Now().Unix())
return err
}
func (db *DB) HasUfwRule(port int, proto string) bool {
var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ufw_rules WHERE port = ? AND proto = ?`, port, proto).Scan(&n)
return n > 0
}
func (db *DB) scanUfwRules(query string, args ...any) ([]UfwRule, error) {
rows, err := db.sql.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []UfwRule
for rows.Next() {
var r UfwRule
if err := rows.Scan(&r.ID, &r.Port, &r.Proto, &r.RuleID); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (db *DB) ListUfwRules() ([]UfwRule, error) {
return db.scanUfwRules(`SELECT id, port, proto, rule_id FROM ufw_rules ORDER BY port, proto`)
}
// ListUfwRulesByRule returns ufw rules bound to a specific forward rule.
func (db *DB) ListUfwRulesByRule(ruleID int64) ([]UfwRule, error) {
return db.scanUfwRules(`SELECT id, port, proto, rule_id FROM ufw_rules WHERE rule_id = ?`, ruleID)
}
func (db *DB) GetUfwRule(id int64) (*UfwRule, error) {
r := &UfwRule{}
err := db.sql.QueryRow(`SELECT id, port, proto, rule_id FROM ufw_rules WHERE id = ?`, id).
Scan(&r.ID, &r.Port, &r.Proto, &r.RuleID)
if err == sql.ErrNoRows {
return nil, nil
}
return r, err
}
func (db *DB) DeleteUfwRule(id int64) error {
_, err := db.sql.Exec(`DELETE FROM ufw_rules WHERE id = ?`, id)
return err
}
// HostPortUsed reports whether any forward rule already uses this host port.
func (db *DB) HostPortUsed(hostPort int) bool {
var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM forward_rules WHERE host_port = ?`, hostPort).Scan(&n)
return n > 0
}
// --- audit ---
func (db *DB) Audit(adminID int64, agentID, action string) {
// Best-effort: an audit failure must not block the operation.
_, _ = db.sql.Exec(
`INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
adminID, agentID, action, time.Now().Unix())
}