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.
288 lines
6.5 KiB
Go
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
|
|
}
|