154 lines
4.6 KiB
Go
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
|