// Package store persists model.State to disk as YAML under a file lock and // keeps a git history of every change, so the whole configuration (groups, // hosts, user metadata) can be restored on a fresh host after a disaster. // // Deliberately NOT stored here: private keys. Those are generated, packed // into an export archive, and wiped from disk in the same operation — only // public-key fingerprints are kept in state, so a stolen backup cannot leak // anyone's private key. package store import ( "fmt" "os" "os/exec" "path/filepath" "time" "github.com/gofrs/flock" "gopkg.in/yaml.v3" "ssh-access-tui/internal/model" ) const ( stateFileName = "state.yaml" lockFileName = "state.lock" ) // Store wraps a state directory (git repo) holding state.yaml. type Store struct { Dir string } // New returns a Store rooted at dir, creating the directory and git repo // (via Init) if they do not exist yet. func New(dir string) (*Store, error) { if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create state dir: %w", err) } s := &Store{Dir: dir} if err := s.ensureGitRepo(); err != nil { return nil, err } return s, nil } func (s *Store) statePath() string { return filepath.Join(s.Dir, stateFileName) } func (s *Store) lockPath() string { return filepath.Join(s.Dir, lockFileName) } func (s *Store) ensureGitRepo() error { if _, err := os.Stat(filepath.Join(s.Dir, ".git")); err == nil { return nil } cmd := exec.Command("git", "init", "-q", s.Dir) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("git init: %w: %s", err, out) } // Local commit identity so this works even with no global gitconfig. _ = runGit(s.Dir, "config", "user.name", "ssh-access-tui") _ = runGit(s.Dir, "config", "user.email", "ssh-access-tui@localhost") return nil } // Load reads state.yaml, returning an empty State if it does not exist yet. // Callers should hold a lock (see WithLock) around Load+mutate+Save. func (s *Store) Load() (*model.State, error) { data, err := os.ReadFile(s.statePath()) if os.IsNotExist(err) { return &model.State{}, nil } if err != nil { return nil, fmt.Errorf("read state: %w", err) } var st model.State if err := yaml.Unmarshal(data, &st); err != nil { return nil, fmt.Errorf("parse state: %w", err) } return &st, nil } // Save writes state.yaml and commits the change to the local git repo with // the given message (should describe the admin action, e.g. "add user bob"). func (s *Store) Save(st *model.State, commitMsg string) error { data, err := yaml.Marshal(st) if err != nil { return fmt.Errorf("marshal state: %w", err) } tmp := s.statePath() + ".tmp" if err := os.WriteFile(tmp, data, 0o640); err != nil { return fmt.Errorf("write state: %w", err) } if err := os.Rename(tmp, s.statePath()); err != nil { return fmt.Errorf("commit state file: %w", err) } if err := runGit(s.Dir, "add", stateFileName); err != nil { return fmt.Errorf("git add: %w", err) } // A no-op commit (nothing changed) is not an error. if err := runGit(s.Dir, "commit", "-q", "-m", commitMsg); err != nil { if !isNothingToCommit(err) { return fmt.Errorf("git commit: %w", err) } } return nil } // WithLock takes an exclusive file lock for the duration of fn, protecting // against a second concurrent admin session corrupting state.yaml. The tool // is designed for one admin operating at a time; this lock turns an // accidental second session into a clear "someone else is editing" error // instead of silent data loss. func (s *Store) WithLock(fn func() error) error { fl := flock.New(s.lockPath()) locked, err := fl.TryLock() if err != nil { return fmt.Errorf("acquire lock: %w", err) } if !locked { return fmt.Errorf("state is locked by another ssh-access-tui session (lock file: %s)", s.lockPath()) } defer fl.Unlock() return fn() } func runGit(dir string, args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%v: %s", err, out) } return nil } func isNothingToCommit(err error) bool { // git exits non-zero with "nothing to commit" when the working tree is // unchanged; treat that as success rather than surfacing it as failure. return err != nil && (contains(err.Error(), "nothing to commit") || contains(err.Error(), "nothing added")) } func contains(s, substr string) bool { return len(s) >= len(substr) && (func() bool { for i := 0; i+len(substr) <= len(s); i++ { if s[i:i+len(substr)] == substr { return true } } return false })() } // Now is a var so tests can override it; production code just wants time.Now. var Now = time.Now