// client2server - Persistent storage (SQLite) // // Tables: // users(id, username, password_hash, role, created_at) // events(id, router_id, event_type, hostname, payload_json, ts, received_at) // commands(id, router_id, command, args_json, issued_by, status, result, ts) // alerts(id, router_id, kind, message, created_at, acknowledged_at) package main import ( "database/sql" "encoding/json" "fmt" "log" "sync" "time" _ "modernc.org/sqlite" ) var ( db *sql.DB dbMu sync.Mutex ) func initStore(path string) error { d, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") if err != nil { return fmt.Errorf("open sqlite: %w", err) } d.SetMaxOpenConns(1) // SQLite single-writer; many readers OK if err := d.Ping(); err != nil { return fmt.Errorf("ping sqlite: %w", err) } db = d schema := ` 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 'user', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, router_id TEXT NOT NULL, event_type TEXT NOT NULL, hostname TEXT, payload_json TEXT, ts DATETIME, received_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_events_router ON events(router_id); CREATE INDEX IF NOT EXISTS idx_events_ts ON events(received_at); CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); CREATE TABLE IF NOT EXISTS commands ( id TEXT PRIMARY KEY, router_id TEXT NOT NULL, command TEXT NOT NULL, args_json TEXT, issued_by TEXT, status TEXT NOT NULL, result_json TEXT, ts DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_commands_router ON commands(router_id); CREATE INDEX IF NOT EXISTS idx_commands_ts ON commands(ts); CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, router_id TEXT NOT NULL, kind TEXT NOT NULL, message TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, acknowledged_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_alerts_unack ON alerts(acknowledged_at); ` if _, err := db.Exec(schema); err != nil { return fmt.Errorf("schema: %w", err) } // Default admin user (username: admin, password: admin) // Only if no users exist var count int if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { return err } if count == 0 { hash, err := HashPassword("admin") if err != nil { return err } _, err = db.Exec( "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", "admin", hash, "system_admin", ) if err != nil { return err } log.Println("created default admin user (username=admin password=admin) — CHANGE IT") } return nil } func saveEvent(ev RouterEvent) error { dbMu.Lock() defer dbMu.Unlock() payload, _ := json.Marshal(ev.Payload) var hostname *string if ev.Hostname != "" { hostname = &ev.Hostname } // Use ReceivedAt as fallback so listEvents never sees a zero time. ts := ev.ReceivedAt if !ev.Timestamp.IsZero() { ts = ev.Timestamp } _, err := db.Exec( `INSERT OR REPLACE INTO events (id, router_id, event_type, hostname, payload_json, ts, received_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ev.ID, ev.RouterID, ev.EventType, hostname, string(payload), ts, ev.ReceivedAt, ) return err } func listEvents(limit int, routerID, eventType string) ([]RouterEvent, error) { dbMu.Lock() defer dbMu.Unlock() q := "SELECT id, router_id, event_type, COALESCE(hostname, ''), COALESCE(payload_json, '{}'), ts, received_at FROM events WHERE 1=1" args := []interface{}{} if routerID != "" { q += " AND router_id = ?" args = append(args, routerID) } if eventType != "" { q += " AND event_type = ?" args = append(args, eventType) } q += " ORDER BY received_at DESC LIMIT ?" args = append(args, limit) rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() out := make([]RouterEvent, 0, limit) for rows.Next() { var ev RouterEvent var payloadJSON string var ts time.Time if err := rows.Scan(&ev.ID, &ev.RouterID, &ev.EventType, &ev.Hostname, &payloadJSON, &ts, &ev.ReceivedAt); err != nil { return nil, err } _ = json.Unmarshal([]byte(payloadJSON), &ev.Payload) ev.Timestamp = ts out = append(out, ev) } return out, nil } func saveCommand(cmd RouterCommand, issuedBy, status string) error { dbMu.Lock() defer dbMu.Unlock() argsJSON, _ := json.Marshal(cmd.Args) _, err := db.Exec( `INSERT OR REPLACE INTO commands (id, router_id, command, args_json, issued_by, status) VALUES (?, ?, ?, ?, ?, ?)`, cmd.ID, cmd.RouterID, cmd.Command, string(argsJSON), issuedBy, status, ) return err } func updateCommandResult(cmdID, status string, result *CommandResult) error { dbMu.Lock() defer dbMu.Unlock() var resultJSON []byte if result != nil { resultJSON, _ = json.Marshal(result) } _, err := db.Exec( "UPDATE commands SET status = ?, result_json = ? WHERE id = ?", status, string(resultJSON), cmdID, ) return err } func listCommands(limit int, routerID string) ([]map[string]interface{}, error) { dbMu.Lock() defer dbMu.Unlock() q := `SELECT id, router_id, command, COALESCE(args_json, '{}'), COALESCE(issued_by, ''), status, COALESCE(result_json, ''), ts FROM commands WHERE 1=1` args := []interface{}{} if routerID != "" { q += " AND router_id = ?" args = append(args, routerID) } q += " ORDER BY ts DESC LIMIT ?" args = append(args, limit) rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() out := []map[string]interface{}{} for rows.Next() { var id, routerID, command, argsJSON, issuedBy, status, resultJSON, ts string if err := rows.Scan(&id, &routerID, &command, &argsJSON, &issuedBy, &status, &resultJSON, &ts); err != nil { return nil, err } entry := map[string]interface{}{ "id": id, "router_id": routerID, "command": command, "issued_by": issuedBy, "status": status, "ts": ts, } var args map[string]string _ = json.Unmarshal([]byte(argsJSON), &args) entry["args"] = args if resultJSON != "" { var res map[string]interface{} _ = json.Unmarshal([]byte(resultJSON), &res) entry["result"] = res } out = append(out, entry) } return out, nil } func createAlert(routerID, kind, message string) { dbMu.Lock() defer dbMu.Unlock() _, _ = db.Exec( `INSERT INTO alerts (router_id, kind, message) VALUES (?, ?, ?)`, routerID, kind, message, ) } func listAlerts(unackOnly bool, limit int) ([]map[string]interface{}, error) { dbMu.Lock() defer dbMu.Unlock() q := `SELECT id, router_id, kind, message, created_at, COALESCE(acknowledged_at, '') FROM alerts` if unackOnly { q += ` WHERE acknowledged_at IS NULL` } q += ` ORDER BY created_at DESC LIMIT ?` rows, err := db.Query(q, limit) if err != nil { return nil, err } defer rows.Close() out := []map[string]interface{}{} for rows.Next() { var id int64 var routerID, kind, message, createdAt, ackedAt string if err := rows.Scan(&id, &routerID, &kind, &message, &createdAt, &ackedAt); err != nil { return nil, err } entry := map[string]interface{}{ "id": id, "router_id": routerID, "kind": kind, "message": message, "created_at": createdAt, } if ackedAt != "" { entry["acknowledged_at"] = ackedAt } out = append(out, entry) } return out, nil } func acknowledgeAlert(id int64) error { dbMu.Lock() defer dbMu.Unlock() _, err := db.Exec("UPDATE alerts SET acknowledged_at = CURRENT_TIMESTAMP WHERE id = ?", id) return err }