package db import ( "database/sql" "fmt" "time" _ "modernc.org/sqlite" ) // Store wraps the SQLite database. type Store struct { db *sql.DB } // Open opens (or creates) the SQLite database at path and runs migrations. func Open(path string) (*Store, error) { conn, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite at %q: %w", path, err) } // SQLite works best with a single writer connection. conn.SetMaxOpenConns(1) // Recommended pragmas for reliability and performance. for _, pragma := range []string{ "PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON", } { if _, err := conn.Exec(pragma); err != nil { conn.Close() return nil, fmt.Errorf("pragma %q: %w", pragma, err) } } s := &Store{db: conn} if err := s.migrate(); err != nil { conn.Close() return nil, fmt.Errorf("migrate: %w", err) } return s, nil } // Close closes the underlying database connection. func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS host_ports ( ip TEXT NOT NULL, port INTEGER NOT NULL, scanned_at DATETIME NOT NULL, PRIMARY KEY (ip, port) ); CREATE TABLE IF NOT EXISTS snmp_targets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, ip TEXT NOT NULL, community TEXT NOT NULL DEFAULT 'public', version TEXT NOT NULL DEFAULT 'v2c', type TEXT NOT NULL DEFAULT 'generic', enabled INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS snmp_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_id INTEGER NOT NULL, oid_name TEXT NOT NULL, value TEXT NOT NULL DEFAULT '', scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(target_id, oid_name) ); CREATE TABLE IF NOT EXISTS module_states ( name TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS monitor_checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL, target TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS monitor_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, check_id INTEGER NOT NULL, status TEXT NOT NULL, latency_ms INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_monitor_results_check ON monitor_results(check_id, checked_at DESC); CREATE TABLE IF NOT EXISTS ad_computers ( id INTEGER PRIMARY KEY AUTOINCREMENT, cn TEXT NOT NULL UNIQUE, dn TEXT NOT NULL DEFAULT '', os TEXT NOT NULL DEFAULT '', os_version TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', last_logon DATETIME, ad_created DATETIME, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS hosts ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL UNIQUE, mac TEXT NOT NULL, vendor TEXT NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '', site TEXT NOT NULL, first_seen DATETIME NOT NULL, last_seen DATETIME NOT NULL, status TEXT NOT NULL DEFAULT 'online' ); CREATE TABLE IF NOT EXISTS scan_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, module TEXT NOT NULL, started_at DATETIME NOT NULL, ended_at DATETIME, status TEXT NOT NULL DEFAULT 'running', error TEXT NOT NULL DEFAULT '' ); `) return err } // Host represents a discovered network device. type Host struct { ID int64 IP string MAC string Vendor string Hostname string Site string FirstSeen time.Time LastSeen time.Time Status string } // UpsertHost inserts a new host or updates MAC, vendor and last_seen if the // IP already exists. first_seen is never overwritten on update. func (s *Store) UpsertHost(h Host) error { _, err := s.db.Exec(` INSERT INTO hosts (ip, mac, vendor, hostname, site, first_seen, last_seen, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'online') ON CONFLICT(ip) DO UPDATE SET mac = excluded.mac, vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END, hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE hostname END, last_seen = excluded.last_seen, status = 'online' `, h.IP, h.MAC, h.Vendor, h.Hostname, h.Site, h.FirstSeen.UTC(), h.LastSeen.UTC()) if err != nil { return fmt.Errorf("upsert host %s: %w", h.IP, err) } return nil } // ListHosts returns all hosts ordered by last_seen desc. // Pass an empty site to return all sites. func (s *Store) ListHosts(site string) ([]Host, error) { query := ` SELECT id, ip, mac, vendor, hostname, site, first_seen, last_seen, status FROM hosts` var args []any if site != "" { query += ` WHERE site = ?` args = append(args, site) } query += ` ORDER BY last_seen DESC` rows, err := s.db.Query(query, args...) if err != nil { return nil, fmt.Errorf("list hosts: %w", err) } defer rows.Close() var hosts []Host for rows.Next() { var h Host if err := rows.Scan( &h.ID, &h.IP, &h.MAC, &h.Vendor, &h.Hostname, &h.Site, &h.FirstSeen, &h.LastSeen, &h.Status, ); err != nil { return nil, fmt.Errorf("scan host row: %w", err) } hosts = append(hosts, h) } return hosts, rows.Err() } // CountHosts returns the total number of hosts in the database. func (s *Store) CountHosts() (int, error) { var n int if err := s.db.QueryRow(`SELECT COUNT(*) FROM hosts`).Scan(&n); err != nil { return 0, fmt.Errorf("count hosts: %w", err) } return n, nil } // BeginScan records the start of a module scan run and returns the run ID. func (s *Store) BeginScan(module string) (int64, error) { res, err := s.db.Exec(` INSERT INTO scan_runs (module, started_at) VALUES (?, ?) `, module, time.Now().UTC()) if err != nil { return 0, fmt.Errorf("begin scan: %w", err) } return res.LastInsertId() } // EndScan marks a scan run as completed (status ok or error). func (s *Store) EndScan(id int64, scanErr error) error { status, errMsg := "ok", "" if scanErr != nil { status, errMsg = "error", scanErr.Error() } _, err := s.db.Exec(` UPDATE scan_runs SET ended_at = ?, status = ?, error = ? WHERE id = ? `, time.Now().UTC(), status, errMsg, id) if err != nil { return fmt.Errorf("end scan %d: %w", id, err) } return nil } // ADComputer represents a computer object from Active Directory. type ADComputer struct { ID int64 CN string DN string OS string OSVersion string Owner string Department string LastLogon time.Time ADCreated time.Time UpdatedAt time.Time } // UpsertADComputer inserts or updates an AD computer record by CN. func (s *Store) UpsertADComputer(c ADComputer) error { _, err := s.db.Exec(` INSERT INTO ad_computers (cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(cn) DO UPDATE SET dn = excluded.dn, os = excluded.os, os_version = excluded.os_version, owner = excluded.owner, department = excluded.department, last_logon = excluded.last_logon, updated_at = excluded.updated_at `, c.CN, c.DN, c.OS, c.OSVersion, c.Owner, c.Department, nullTime(c.LastLogon), nullTime(c.ADCreated), time.Now().UTC()) if err != nil { return fmt.Errorf("upsert ad computer %s: %w", c.CN, err) } return nil } // ListADComputers returns all known AD computers. func (s *Store) ListADComputers() ([]ADComputer, error) { rows, err := s.db.Query(` SELECT id, cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at FROM ad_computers ORDER BY cn `) if err != nil { return nil, fmt.Errorf("list ad computers: %w", err) } defer rows.Close() var out []ADComputer for rows.Next() { var c ADComputer var lastLogon, adCreated *time.Time if err := rows.Scan(&c.ID, &c.CN, &c.DN, &c.OS, &c.OSVersion, &c.Owner, &c.Department, &lastLogon, &adCreated, &c.UpdatedAt); err != nil { return nil, err } if lastLogon != nil { c.LastLogon = *lastLogon } if adCreated != nil { c.ADCreated = *adCreated } out = append(out, c) } return out, rows.Err() } // UpdateHostnameFromAD sets the hostname of a host matching the given IP. func (s *Store) UpdateHostnameFromAD(hostname string) error { _, err := s.db.Exec(` UPDATE hosts SET hostname = ? WHERE hostname = '' AND ip IN ( SELECT ip FROM hosts WHERE LOWER(hostname) = LOWER(?) ) `, hostname, hostname) return err } func nullTime(t time.Time) any { if t.IsZero() { return nil } return t.UTC() } // LastScanTime returns when the given module last ran successfully. func (s *Store) LastScanTime(module string) (time.Time, error) { var t time.Time err := s.db.QueryRow(` SELECT ended_at FROM scan_runs WHERE module = ? AND status = 'ok' ORDER BY ended_at DESC LIMIT 1 `, module).Scan(&t) if err == sql.ErrNoRows { return time.Time{}, nil } return t, err } /* ── Site Monitoring ──────────────────────────────────────────────── */ // MonitorCheck is a configured availability check (ping/http/tcp). type MonitorCheck struct { ID int64 Name string Type string // ping | http | tcp Target string Enabled bool CreatedAt time.Time } // MonitorResult is a single check execution result. type MonitorResult struct { ID int64 CheckID int64 Status string // online | offline LatencyMS int Error string CheckedAt time.Time } // ListMonitorChecks returns all configured checks ordered by creation time. func (s *Store) ListMonitorChecks() ([]MonitorCheck, error) { rows, err := s.db.Query(` SELECT id, name, type, target, enabled, created_at FROM monitor_checks ORDER BY id `) if err != nil { return nil, fmt.Errorf("list monitor checks: %w", err) } defer rows.Close() var out []MonitorCheck for rows.Next() { var c MonitorCheck var ena int if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Target, &ena, &c.CreatedAt); err != nil { return nil, err } c.Enabled = ena == 1 out = append(out, c) } return out, rows.Err() } // AddMonitorCheck inserts a new check definition. func (s *Store) AddMonitorCheck(name, typ, target string) (int64, error) { res, err := s.db.Exec(` INSERT INTO monitor_checks (name, type, target) VALUES (?, ?, ?) `, name, typ, target) if err != nil { return 0, fmt.Errorf("add monitor check: %w", err) } return res.LastInsertId() } // DeleteMonitorCheck removes a check and all its results. func (s *Store) DeleteMonitorCheck(id int64) error { if _, err := s.db.Exec(`DELETE FROM monitor_results WHERE check_id = ?`, id); err != nil { return fmt.Errorf("delete monitor results: %w", err) } if _, err := s.db.Exec(`DELETE FROM monitor_checks WHERE id = ?`, id); err != nil { return fmt.Errorf("delete monitor check: %w", err) } return nil } // InsertMonitorResult stores one check execution result. func (s *Store) InsertMonitorResult(r MonitorResult) error { _, err := s.db.Exec(` INSERT INTO monitor_results (check_id, status, latency_ms, error, checked_at) VALUES (?, ?, ?, ?, ?) `, r.CheckID, r.Status, r.LatencyMS, r.Error, r.CheckedAt.UTC()) if err != nil { return fmt.Errorf("insert monitor result: %w", err) } return nil } // LatestMonitorResults returns the most recent result for each check_id. func (s *Store) LatestMonitorResults() (map[int64]MonitorResult, error) { rows, err := s.db.Query(` SELECT r.id, r.check_id, r.status, r.latency_ms, r.error, r.checked_at FROM monitor_results r INNER JOIN ( SELECT check_id, MAX(id) as max_id FROM monitor_results GROUP BY check_id ) latest ON r.id = latest.max_id `) if err != nil { return nil, fmt.Errorf("latest monitor results: %w", err) } defer rows.Close() out := make(map[int64]MonitorResult) for rows.Next() { var r MonitorResult if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil { return nil, err } out[r.CheckID] = r } return out, rows.Err() } // MonitorResultHistory returns the last limit results for a given check. func (s *Store) MonitorResultHistory(checkID int64, limit int) ([]MonitorResult, error) { rows, err := s.db.Query(` SELECT id, check_id, status, latency_ms, error, checked_at FROM monitor_results WHERE check_id = ? ORDER BY checked_at DESC LIMIT ? `, checkID, limit) if err != nil { return nil, fmt.Errorf("monitor history: %w", err) } defer rows.Close() var out []MonitorResult for rows.Next() { var r MonitorResult if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil { return nil, err } out = append(out, r) } // Reverse so oldest-first for sparkline rendering. for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out, rows.Err() } /* ── Module States ────────────────────────────────────────────────── */ // SetModuleEnabled stores the enabled state for a module. func (s *Store) SetModuleEnabled(name string, enabled bool) error { v := 0 if enabled { v = 1 } _, err := s.db.Exec(` INSERT INTO module_states (name, enabled) VALUES (?,?) ON CONFLICT(name) DO UPDATE SET enabled=excluded.enabled `, name, v) return err } // GetModuleEnabled returns whether a module is enabled (default true if not set). func (s *Store) GetModuleEnabled(name string) bool { var v int err := s.db.QueryRow(`SELECT enabled FROM module_states WHERE name=?`, name).Scan(&v) if err != nil { return true // default: enabled } return v == 1 } /* ── Port Scan ────────────────────────────────────────────────────── */ // UpsertHostPorts replaces all open ports for an IP. func (s *Store) UpsertHostPorts(ip string, ports []int) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM host_ports WHERE ip = ?`, ip); err != nil { return err } now := time.Now().UTC() for _, p := range ports { if _, err := tx.Exec(`INSERT INTO host_ports (ip, port, scanned_at) VALUES (?,?,?)`, ip, p, now); err != nil { return err } } return tx.Commit() } // GetHostPorts returns all open ports for an IP. func (s *Store) GetHostPorts(ip string) ([]int, error) { rows, err := s.db.Query(`SELECT port FROM host_ports WHERE ip = ? ORDER BY port`, ip) if err != nil { return nil, err } defer rows.Close() var ports []int for rows.Next() { var p int rows.Scan(&p) ports = append(ports, p) } return ports, rows.Err() } // ListAllHostPorts returns a map of IP → open ports. func (s *Store) ListAllHostPorts() (map[string][]int, error) { rows, err := s.db.Query(`SELECT ip, port FROM host_ports ORDER BY ip, port`) if err != nil { return nil, err } defer rows.Close() out := make(map[string][]int) for rows.Next() { var ip string var port int rows.Scan(&ip, &port) out[ip] = append(out[ip], port) } return out, rows.Err() } // MarkStaleHostsOffline marks hosts as offline if last_seen is older than threshold. func (s *Store) MarkStaleHostsOffline(olderThan time.Duration) (int, error) { threshold := time.Now().UTC().Add(-olderThan) res, err := s.db.Exec(`UPDATE hosts SET status='offline' WHERE status='online' AND last_seen < ?`, threshold) if err != nil { return 0, fmt.Errorf("mark stale offline: %w", err) } n, _ := res.RowsAffected() return int(n), nil } /* ── SNMP ─────────────────────────────────────────────────────────── */ // SNMPTarget is a configured SNMP polling target. type SNMPTarget struct { ID int64 Name string IP string Community string Version string Type string // generic | printer | switch Enabled bool CreatedAt time.Time } // SNMPResult is the latest value for one OID on one target. type SNMPResult struct { ID int64 TargetID int64 OIDName string Value string ScannedAt time.Time } // ListSNMPTargets returns all SNMP targets. func (s *Store) ListSNMPTargets() ([]SNMPTarget, error) { rows, err := s.db.Query(`SELECT id,name,ip,community,version,type,enabled,created_at FROM snmp_targets ORDER BY id`) if err != nil { return nil, err } defer rows.Close() var out []SNMPTarget for rows.Next() { var t SNMPTarget var ena int rows.Scan(&t.ID, &t.Name, &t.IP, &t.Community, &t.Version, &t.Type, &ena, &t.CreatedAt) t.Enabled = ena == 1 out = append(out, t) } return out, rows.Err() } // AddSNMPTarget inserts a new SNMP target. func (s *Store) AddSNMPTarget(name, ip, community, version, typ string) (int64, error) { res, err := s.db.Exec(`INSERT INTO snmp_targets (name,ip,community,version,type) VALUES (?,?,?,?,?)`, name, ip, community, version, typ) if err != nil { return 0, err } return res.LastInsertId() } // DeleteSNMPTarget removes a target and its results. func (s *Store) DeleteSNMPTarget(id int64) error { s.db.Exec(`DELETE FROM snmp_results WHERE target_id = ?`, id) _, err := s.db.Exec(`DELETE FROM snmp_targets WHERE id = ?`, id) return err } // UpsertSNMPResult inserts or updates one OID result. func (s *Store) UpsertSNMPResult(r SNMPResult) error { _, err := s.db.Exec(` INSERT INTO snmp_results (target_id, oid_name, value, scanned_at) VALUES (?,?,?,?) ON CONFLICT(target_id, oid_name) DO UPDATE SET value=excluded.value, scanned_at=excluded.scanned_at `, r.TargetID, r.OIDName, r.Value, r.ScannedAt.UTC()) return err } // LatestSNMPResults returns all latest OID values keyed by target_id. func (s *Store) LatestSNMPResults() (map[int64]map[string]string, error) { rows, err := s.db.Query(`SELECT target_id, oid_name, value FROM snmp_results ORDER BY target_id, oid_name`) if err != nil { return nil, err } defer rows.Close() out := make(map[int64]map[string]string) for rows.Next() { var tid int64 var k, v string rows.Scan(&tid, &k, &v) if out[tid] == nil { out[tid] = make(map[string]string) } out[tid][k] = v } return out, rows.Err() } // CleanupMonitorResults deletes old results, keeping the last keepPerCheck per check. func (s *Store) CleanupMonitorResults(keepPerCheck int) error { _, err := s.db.Exec(` DELETE FROM monitor_results WHERE id NOT IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY check_id ORDER BY checked_at DESC ) AS rn FROM monitor_results ) WHERE rn <= ? ) `, keepPerCheck) return err }