// 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 }