feat(state): add runtime state snapshot

This commit is contained in:
ahp
2026-10-15 14:29:19 +03:30
parent 4048507f26
commit 150a78bdde
2 changed files with 143 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
// Package state persists the runtime snapshot prx needs to describe and undo
// the currently applied routing configuration.
package state
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
"prx/internal/fsx"
)
// State is the on-disk snapshot.
type State struct {
Enabled bool `json:"enabled"`
WANInterface string `json:"wan_interface"`
Gateway4 string `json:"gateway_v4,omitempty"`
Gateway6 string `json:"gateway_v6,omitempty"`
Mark uint32 `json:"mark"`
TableV4 int `json:"table_v4"`
TableV6 int `json:"table_v6"`
PriorityV4 int `json:"priority_v4"`
PriorityV6 int `json:"priority_v6"`
BypassTableV4 int `json:"bypass_table_v4"`
BypassTableV6 int `json:"bypass_table_v6"`
BypassPrioV4 int `json:"bypass_priority_v4"`
BypassPrioV6 int `json:"bypass_priority_v6"`
BypassCIDRs []string `json:"bypass_cidrs,omitempty"`
EngineManaged bool `json:"engine_managed"`
EngineWasRunning bool `json:"engine_was_running"`
EnabledAt time.Time `json:"enabled_at"`
Version string `json:"version,omitempty"`
}
// Load reads the state. A missing file yields a zero (disabled) state.
//
// The returned state is never nil, even alongside an error, so callers cannot
// dereference a nil pointer after ignoring a read failure.
func Load(path string) (*State, error) {
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &State{}, nil
}
return &State{}, fmt.Errorf("read state %s: %w", path, err)
}
var s State
if err := json.Unmarshal(raw, &s); err != nil {
return &State{}, fmt.Errorf("parse state %s: %w", path, err)
}
return &s, nil
}
// Save writes the state atomically, creating the parent directory.
func Save(path string, s *State) error {
raw, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("encode state: %w", err)
}
raw = append(raw, '\n')
if err := fsx.WriteFileAtomic(path, raw, 0o644); err != nil {
return fmt.Errorf("write state %s: %w", path, err)
}
return nil
}
// Remove deletes the state file. A missing file is not an error.
func Remove(path string) error {
err := os.Remove(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
package state
import (
"os"
"path/filepath"
"testing"
)
func TestRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "run", "prx", "state.json")
s := &State{
Enabled: true,
WANInterface: "wlan0",
Gateway4: "192.168.1.1",
Mark: 0x100,
TableV4: 100,
TableV6: 101,
PriorityV4: 100,
PriorityV6: 100,
}
if err := Save(path, s); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !got.Enabled || got.WANInterface != "wlan0" || got.Mark != 0x100 {
t.Fatalf("round trip mismatch: %+v", got)
}
if err := Remove(path); err != nil {
t.Fatalf("Remove: %v", err)
}
empty, err := Load(path)
if err != nil {
t.Fatalf("Load after remove: %v", err)
}
if empty.Enabled {
t.Fatal("expected disabled state after removal")
}
if err := Remove(path); err != nil {
t.Fatalf("second Remove should be a no-op: %v", err)
}
}
// TestLoadCorruptNeverReturnsNil guards against the nil-pointer panic that a
// caller ignoring the error would otherwise hit on a corrupt state file.
func TestLoadCorruptNeverReturnsNil(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
if err := os.WriteFile(path, []byte("{ not json"), 0o644); err != nil {
t.Fatal(err)
}
st, err := Load(path)
if err == nil {
t.Fatal("expected a parse error for a corrupt state file")
}
if st == nil {
t.Fatal("Load must never return a nil state alongside an error")
}
if st.Enabled {
t.Fatal("expected a zero-value state")
}
}