dnswatcher/internal/state/state.go
sneak 144a2df665 Initial scaffold with per-nameserver DNS monitoring model
Full project structure following upaas conventions: uber/fx DI, go-chi
routing, slog logging, Viper config. State persisted as JSON file with
per-nameserver record tracking for inconsistency detection. Stub
implementations for resolver, portcheck, tlscheck, and watcher.
2026-02-19 21:05:39 +01:00

288 lines
6.5 KiB
Go

// Package state provides JSON file-based state persistence.
package state
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/logger"
)
// filePermissions for the state file.
const filePermissions = 0o600
// dirPermissions for the data directory.
const dirPermissions = 0o700
// stateVersion is the current state file format version.
const stateVersion = 1
// Params contains dependencies for State.
type Params struct {
fx.In
Logger *logger.Logger
Config *config.Config
}
// DomainState holds the monitoring state for an apex domain.
type DomainState struct {
Nameservers []string `json:"nameservers"`
LastChecked time.Time `json:"lastChecked"`
}
// NameserverRecordState holds one NS's response for a hostname.
type NameserverRecordState struct {
Records map[string][]string `json:"records"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
}
// HostnameState holds per-nameserver monitoring state for a hostname.
type HostnameState struct {
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
LastChecked time.Time `json:"lastChecked"`
}
// PortState holds the monitoring state for a port.
type PortState struct {
Open bool `json:"open"`
Hostname string `json:"hostname"`
LastChecked time.Time `json:"lastChecked"`
}
// CertificateState holds TLS certificate monitoring state.
type CertificateState struct {
CommonName string `json:"commonName"`
Issuer string `json:"issuer"`
NotAfter time.Time `json:"notAfter"`
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
}
// Snapshot is the complete monitoring state persisted to disk.
type Snapshot struct {
Version int `json:"version"`
LastUpdated time.Time `json:"lastUpdated"`
Domains map[string]*DomainState `json:"domains"`
Hostnames map[string]*HostnameState `json:"hostnames"`
Ports map[string]*PortState `json:"ports"`
Certificates map[string]*CertificateState `json:"certificates"`
}
// State manages the monitoring state with file persistence.
type State struct {
mu sync.RWMutex
snapshot *Snapshot
log *slog.Logger
config *config.Config
}
// New creates a new State instance and loads existing state from disk.
func New(
lifecycle fx.Lifecycle,
params Params,
) (*State, error) {
state := &State{
log: params.Logger.Get(),
config: params.Config,
snapshot: &Snapshot{
Version: stateVersion,
Domains: make(map[string]*DomainState),
Hostnames: make(map[string]*HostnameState),
Ports: make(map[string]*PortState),
Certificates: make(map[string]*CertificateState),
},
}
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
return state.Load()
},
OnStop: func(_ context.Context) error {
return state.Save()
},
})
return state, nil
}
// Load reads the state from disk.
func (s *State) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
path := s.config.StatePath()
//nolint:gosec // path is from trusted config
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
s.log.Info(
"no existing state file, starting fresh",
"path", path,
)
return nil
}
return fmt.Errorf("reading state file: %w", err)
}
var snapshot Snapshot
err = json.Unmarshal(data, &snapshot)
if err != nil {
return fmt.Errorf("parsing state file: %w", err)
}
s.snapshot = &snapshot
s.log.Info("loaded state from disk", "path", path)
return nil
}
// Save writes the current state to disk atomically.
func (s *State) Save() error {
s.mu.RLock()
defer s.mu.RUnlock()
s.snapshot.LastUpdated = time.Now().UTC()
data, err := json.MarshalIndent(s.snapshot, "", " ")
if err != nil {
return fmt.Errorf("marshaling state: %w", err)
}
path := s.config.StatePath()
err = os.MkdirAll(filepath.Dir(path), dirPermissions)
if err != nil {
return fmt.Errorf("creating data directory: %w", err)
}
// Atomic write: write to temp file, then rename
tmpPath := path + ".tmp"
err = os.WriteFile(tmpPath, data, filePermissions)
if err != nil {
return fmt.Errorf("writing temp state file: %w", err)
}
err = os.Rename(tmpPath, path)
if err != nil {
return fmt.Errorf("renaming state file: %w", err)
}
s.log.Debug("state saved to disk", "path", path)
return nil
}
// GetSnapshot returns a copy of the current snapshot.
func (s *State) GetSnapshot() Snapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return *s.snapshot
}
// SetDomainState updates the state for a domain.
func (s *State) SetDomainState(
domain string,
ds *DomainState,
) {
s.mu.Lock()
defer s.mu.Unlock()
s.snapshot.Domains[domain] = ds
}
// GetDomainState returns the state for a domain.
func (s *State) GetDomainState(
domain string,
) (*DomainState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
ds, ok := s.snapshot.Domains[domain]
return ds, ok
}
// SetHostnameState updates the state for a hostname.
func (s *State) SetHostnameState(
hostname string,
hs *HostnameState,
) {
s.mu.Lock()
defer s.mu.Unlock()
s.snapshot.Hostnames[hostname] = hs
}
// GetHostnameState returns the state for a hostname.
func (s *State) GetHostnameState(
hostname string,
) (*HostnameState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
hs, ok := s.snapshot.Hostnames[hostname]
return hs, ok
}
// SetPortState updates the state for a port.
func (s *State) SetPortState(key string, ps *PortState) {
s.mu.Lock()
defer s.mu.Unlock()
s.snapshot.Ports[key] = ps
}
// GetPortState returns the state for a port.
func (s *State) GetPortState(key string) (*PortState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
ps, ok := s.snapshot.Ports[key]
return ps, ok
}
// SetCertificateState updates the state for a certificate.
func (s *State) SetCertificateState(
key string,
cs *CertificateState,
) {
s.mu.Lock()
defer s.mu.Unlock()
s.snapshot.Certificates[key] = cs
}
// GetCertificateState returns the state for a certificate.
func (s *State) GetCertificateState(
key string,
) (*CertificateState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
cs, ok := s.snapshot.Certificates[key]
return cs, ok
}