Go backend (miekg/dns) + Nuxt 3 frontend (Tailwind CSS v4). 8 check categories, 52 checks total: - Overview: @ record, WWW, MX with ASN/provider lookup - Domain Registration: expiry, registrar (RDAP + whois fallback) - Parent Delegation: NS records, glue, consistency - Nameservers: 17 checks (reachability, auth, recursion, TCP/UDP, AXFR, etc.) - SOA: serial consistency, timing values - Mail (MX): 11 checks (CNAME, PTR, public IPs, consistency) - Mail Auth: SPF, DKIM, DMARC - WWW: A record, CNAME Features: - SSE streaming (results appear as each category completes) - SQLite history (modernc.org/sqlite) - Rate limiting, CORS, request logging - Dark mode, responsive design
197 lines
5.4 KiB
Go
197 lines
5.4 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/intodns/backend/internal/checker"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// HistoryEntry represents a saved DNS check result (without full report).
|
|
type HistoryEntry struct {
|
|
ID int64 `json:"id"`
|
|
Domain string `json:"domain"`
|
|
Timestamp string `json:"timestamp"`
|
|
DurationMs int64 `json:"duration_ms"`
|
|
Summary struct {
|
|
Pass int `json:"pass"`
|
|
Warn int `json:"warn"`
|
|
Fail int `json:"fail"`
|
|
Info int `json:"info"`
|
|
} `json:"summary"`
|
|
ClientIP string `json:"client_ip,omitempty"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
}
|
|
|
|
// FullHistoryEntry includes the parsed report.
|
|
type FullHistoryEntry struct {
|
|
HistoryEntry
|
|
Report *checker.Report `json:"report"`
|
|
}
|
|
|
|
// Store wraps SQLite for persisting DNS check results.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewStore opens the SQLite database at dbPath and creates the schema.
|
|
func NewStore(dbPath string) (*Store, error) {
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
|
|
// Enable WAL mode for better concurrency.
|
|
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("set WAL mode: %w", err)
|
|
}
|
|
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS checks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
domain TEXT NOT NULL,
|
|
timestamp TEXT NOT NULL,
|
|
duration_ms INTEGER NOT NULL,
|
|
summary_pass INTEGER NOT NULL DEFAULT 0,
|
|
summary_warn INTEGER NOT NULL DEFAULT 0,
|
|
summary_fail INTEGER NOT NULL DEFAULT 0,
|
|
summary_info INTEGER NOT NULL DEFAULT 0,
|
|
client_ip TEXT NOT NULL DEFAULT '',
|
|
user_agent TEXT NOT NULL DEFAULT '',
|
|
report_json TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_checks_domain ON checks(domain);
|
|
CREATE INDEX IF NOT EXISTS idx_checks_timestamp ON checks(timestamp);
|
|
`
|
|
if _, err := db.Exec(schema); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("create schema: %w", err)
|
|
}
|
|
|
|
// Migrate: add columns if they don't exist (safe for existing DBs).
|
|
for _, col := range []string{
|
|
"ALTER TABLE checks ADD COLUMN client_ip TEXT NOT NULL DEFAULT ''",
|
|
"ALTER TABLE checks ADD COLUMN user_agent TEXT NOT NULL DEFAULT ''",
|
|
} {
|
|
db.Exec(col) // ignore errors — column may already exist
|
|
}
|
|
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// SaveReport serializes the report to JSON and inserts a row.
|
|
func (s *Store) SaveReport(report *checker.Report, clientIP, userAgent string) error {
|
|
reportJSON, err := json.Marshal(report)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal report: %w", err)
|
|
}
|
|
|
|
_, err = s.db.Exec(
|
|
`INSERT INTO checks (domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent, report_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
report.Domain,
|
|
report.Timestamp,
|
|
report.DurationMs,
|
|
report.Summary.Pass,
|
|
report.Summary.Warn,
|
|
report.Summary.Fail,
|
|
report.Summary.Info,
|
|
clientIP,
|
|
userAgent,
|
|
string(reportJSON),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert check: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetHistory returns the latest N check entries for a specific domain.
|
|
func (s *Store) GetHistory(domain string, limit int) ([]HistoryEntry, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent
|
|
FROM checks WHERE domain = ? ORDER BY id DESC LIMIT ?`,
|
|
domain, limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query history: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanEntries(rows)
|
|
}
|
|
|
|
// GetRecent returns the latest N check entries across all domains.
|
|
func (s *Store) GetRecent(limit int) ([]HistoryEntry, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent
|
|
FROM checks ORDER BY id DESC LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query recent: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanEntries(rows)
|
|
}
|
|
|
|
// GetReport returns a single history entry with the full parsed report.
|
|
func (s *Store) GetReport(id int64) (*FullHistoryEntry, error) {
|
|
row := s.db.QueryRow(
|
|
`SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent, report_json
|
|
FROM checks WHERE id = ?`,
|
|
id,
|
|
)
|
|
|
|
var e FullHistoryEntry
|
|
var reportJSON string
|
|
err := row.Scan(
|
|
&e.ID, &e.Domain, &e.Timestamp, &e.DurationMs,
|
|
&e.Summary.Pass, &e.Summary.Warn, &e.Summary.Fail, &e.Summary.Info,
|
|
&e.ClientIP, &e.UserAgent,
|
|
&reportJSON,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan report: %w", err)
|
|
}
|
|
|
|
var report checker.Report
|
|
if err := json.Unmarshal([]byte(reportJSON), &report); err != nil {
|
|
return nil, fmt.Errorf("unmarshal report: %w", err)
|
|
}
|
|
e.Report = &report
|
|
|
|
return &e, nil
|
|
}
|
|
|
|
// Close closes the underlying database connection.
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
func scanEntries(rows *sql.Rows) ([]HistoryEntry, error) {
|
|
var entries []HistoryEntry
|
|
for rows.Next() {
|
|
var e HistoryEntry
|
|
if err := rows.Scan(
|
|
&e.ID, &e.Domain, &e.Timestamp, &e.DurationMs,
|
|
&e.Summary.Pass, &e.Summary.Warn, &e.Summary.Fail, &e.Summary.Info,
|
|
&e.ClientIP, &e.UserAgent,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan entry: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("rows iteration: %w", err)
|
|
}
|
|
return entries, nil
|
|
}
|