Files
dnstest/backend/internal/store/store.go
robertas_stauskas a70f3262e0 Initial commit: DNS Test - DNS health checking tool
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
2026-03-20 13:39:57 +02:00

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
}