Status page per machine: CPU load, RAM and disk of the client, sampled by the agent only while the page is open and visible (one reading serves every open tab), plus a button that has the agent measure its link to the host: ping, down and up. Online/offline now comes from the control polls the hub holds open, so a dropped agent shows offline at once instead of 45 s later, and the dashboard keeps the status column live. A forward rule can be created "without ufw": the panel opens nothing in ufw for it and so revokes nothing when it is deleted. The checkbox is shown only while the ufw integration is on. Dashboard row actions are icons; a status action is added.
475 lines
14 KiB
Go
475 lines
14 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 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`,
|
|
} {
|
|
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
|
|
}
|
|
|
|
// --- 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
|
|
}
|
|
|
|
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, created_at)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
|
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, 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 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); 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 FROM forward_rules WHERE id = ?`, id).
|
|
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw)
|
|
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, or 0 for shared/manual (e.g. tunnel 8446)
|
|
}
|
|
|
|
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())
|
|
}
|