Files
2026-07-22 14:14:53 +03:00

154 lines
4.6 KiB
Go

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