check / check (push) Canceled after 0s
Save wrote state.json.tmp with os.WriteFile and renamed it over state.json without syncing. After a power loss the rename could reach the disk before the data, leaving an empty state.json that Load cannot parse, so every start failed. Save now writes the temp file through its own handle, syncs and closes it, renames it, then syncs the data directory so the rename is on disk too. The new test starts from a leftover temp file longer than the state; a test cannot observe the syncs, which are confirmed by reading. Rule suppressed: gosec's file-path warning on the temp file's open, as Load already does. Model: opus-5-5
506 lines
13 KiB
Go
506 lines
13 KiB
Go
// Package state provides JSON file-based state persistence.
|
|
package state
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"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.
|
|
// NameserverAddresses holds the sorted addresses each nameserver's name
|
|
// resolves to, by nameserver name. A state file written before it
|
|
// existed loads with it nil. NXDomain is true when the domain's parent
|
|
// zone's servers answered that it does not exist; it then has no
|
|
// nameservers.
|
|
type DomainState struct {
|
|
Nameservers []string `json:"nameservers"`
|
|
NameserverAddresses map[string][]string `json:"nameserverAddresses"`
|
|
NXDomain bool `json:"nxdomain,omitempty"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
}
|
|
|
|
// NameserverRecordState holds one NS's response for a hostname.
|
|
// FailedTypes lists the record types whose query to the nameserver
|
|
// failed on this check: Records holds for them the records saved by the
|
|
// previous check, which are kept. UnknownTypes lists those of them whose
|
|
// records the previous check did not know either, as when the
|
|
// nameserver was new or failing then: Records holds nothing for them.
|
|
type NameserverRecordState struct {
|
|
Records map[string][]string `json:"records"`
|
|
FailedTypes []string `json:"failedTypes,omitempty"`
|
|
UnknownTypes []string `json:"unknownTypes,omitempty"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
}
|
|
|
|
// HostnameState holds per-nameserver monitoring state for a hostname.
|
|
// CNAMEAddresses holds the sorted addresses at the end of the name's
|
|
// CNAME chain, found when its nameservers answered with a CNAME and no
|
|
// address; it is empty otherwise. It is nil when they are not known: a
|
|
// state file written before it existed loads with it nil.
|
|
type HostnameState struct {
|
|
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
|
|
CNAMEAddresses []string `json:"cnameAddresses"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
}
|
|
|
|
// PortState holds the monitoring state for a port.
|
|
type PortState struct {
|
|
Open bool `json:"open"`
|
|
Hostnames []string `json:"hostnames"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
}
|
|
|
|
// UnmarshalJSON implements custom unmarshaling to handle both
|
|
// the old single-hostname format and the new multi-hostname
|
|
// format for backward compatibility with existing state files.
|
|
func (ps *PortState) UnmarshalJSON(data []byte) error {
|
|
// Use an alias to prevent infinite recursion.
|
|
type portStateAlias struct {
|
|
Open bool `json:"open"`
|
|
Hostnames []string `json:"hostnames"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
}
|
|
|
|
var alias portStateAlias
|
|
|
|
err := json.Unmarshal(data, &alias)
|
|
if err != nil {
|
|
return fmt.Errorf("unmarshaling port state: %w", err)
|
|
}
|
|
|
|
ps.Open = alias.Open
|
|
ps.Hostnames = alias.Hostnames
|
|
ps.LastChecked = alias.LastChecked
|
|
|
|
// If Hostnames is empty, try reading the old single-hostname
|
|
// format for backward compatibility.
|
|
if len(ps.Hostnames) == 0 {
|
|
var old struct {
|
|
Hostname string `json:"hostname"`
|
|
}
|
|
|
|
// Best-effort: ignore errors since the main unmarshal
|
|
// already succeeded.
|
|
if json.Unmarshal(data, &old) == nil && old.Hostname != "" {
|
|
ps.Hostnames = []string{old.Hostname}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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.
|
|
// Hostnames also holds each apex domain's own records, under the
|
|
// domain's name, which has an entry in Domains too.
|
|
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 {
|
|
err := state.checkDataDirWritable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// A state file saved before each record value was stored once can
|
|
// hold a hostname's CNAME once for every record type asked for.
|
|
// Each value is kept once, so the first check does not see a
|
|
// record change.
|
|
for _, hs := range snapshot.Hostnames {
|
|
for _, ns := range hs.RecordsByNameserver {
|
|
for recordType, values := range ns.Records {
|
|
slices.Sort(values)
|
|
ns.Records[recordType] = slices.Compact(values)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
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. The temp file is
|
|
// synced before the rename and the directory after it, or a crash
|
|
// can leave the renamed state file empty.
|
|
tmpPath := path + ".tmp"
|
|
|
|
//nolint:gosec // path is from trusted config
|
|
tmpFile, err := os.OpenFile(
|
|
tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermissions,
|
|
)
|
|
if err == nil {
|
|
_, err = tmpFile.Write(data)
|
|
err = errors.Join(err, tmpFile.Sync(), tmpFile.Close())
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
dir, err := os.Open(filepath.Dir(path))
|
|
if err == nil {
|
|
err = errors.Join(dir.Sync(), dir.Close())
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("syncing data directory: %w", err)
|
|
}
|
|
|
|
s.log.Debug("state saved to disk", "path", path)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetSnapshot returns a copy of the current snapshot, with copies of
|
|
// its maps, so that a caller can range over them while the state is
|
|
// written. The entries in the maps are not copied: an entry saved in the
|
|
// state must be replaced, never changed in place.
|
|
func (s *State) GetSnapshot() Snapshot {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
snap := *s.snapshot
|
|
snap.Domains = maps.Clone(snap.Domains)
|
|
snap.Hostnames = maps.Clone(snap.Hostnames)
|
|
snap.Ports = maps.Clone(snap.Ports)
|
|
snap.Certificates = maps.Clone(snap.Certificates)
|
|
|
|
return snap
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// DeleteDomainState removes a domain state entry.
|
|
func (s *State) DeleteDomainState(domain string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.snapshot.Domains, domain)
|
|
}
|
|
|
|
// GetAllDomainNames returns the names of all domain state entries.
|
|
func (s *State) GetAllDomainNames() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
names := make([]string, 0, len(s.snapshot.Domains))
|
|
for name := range s.snapshot.Domains {
|
|
names = append(names, name)
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// DeleteHostnameState removes a hostname state entry.
|
|
func (s *State) DeleteHostnameState(hostname string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.snapshot.Hostnames, hostname)
|
|
}
|
|
|
|
// GetAllHostnames returns the names of all hostname state entries,
|
|
// which include each apex domain's own records.
|
|
func (s *State) GetAllHostnames() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
names := make([]string, 0, len(s.snapshot.Hostnames))
|
|
for name := range s.snapshot.Hostnames {
|
|
names = append(names, name)
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// DeletePortState removes a port state entry.
|
|
func (s *State) DeletePortState(key string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.snapshot.Ports, key)
|
|
}
|
|
|
|
// GetAllPortKeys returns all port state keys.
|
|
func (s *State) GetAllPortKeys() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
keys := make([]string, 0, len(s.snapshot.Ports))
|
|
for k := range s.snapshot.Ports {
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
return keys
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// DeleteCertificateState removes a certificate state entry.
|
|
func (s *State) DeleteCertificateState(key string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.snapshot.Certificates, key)
|
|
}
|
|
|
|
// GetAllCertificateKeys returns all certificate state keys.
|
|
func (s *State) GetAllCertificateKeys() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
keys := make([]string, 0, len(s.snapshot.Certificates))
|
|
for k := range s.snapshot.Certificates {
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
return keys
|
|
}
|
|
|
|
// checkDataDirWritable creates the data directory if needed, then writes
|
|
// and removes the temp file that Save uses. It runs at startup so that an
|
|
// unwritable directory stops the process, instead of the process running
|
|
// with every save failing and only logged.
|
|
func (s *State) checkDataDirWritable() error {
|
|
dir := s.config.DataDir
|
|
tmpPath := s.config.StatePath() + ".tmp"
|
|
|
|
err := os.MkdirAll(dir, dirPermissions)
|
|
if err == nil {
|
|
err = os.WriteFile(tmpPath, nil, filePermissions)
|
|
}
|
|
|
|
if err == nil {
|
|
err = os.Remove(tmpPath)
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("data directory %s is not writable: %w", dir, err)
|
|
}
|
|
|
|
return nil
|
|
}
|