Files
worker/internal/webapp/store.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

493 строки
15 KiB
Go

package webapp
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
// Pure-Go SQLite driver. Imported with the blank identifier so the
// driver registers itself with database/sql under the name "sqlite".
// modernc.org/sqlite is CGO-free which keeps the worker binary
// portable (no glibc/musl split) and matches the constraint in
// docs/distributed/worker-web-app.md section 13.1.
_ "modernc.org/sqlite"
)
// schemaSQL defines the embedded store tables for the Phase 1 MVP.
// The store keeps:
//
// - first-run webapp user with bcrypt hash and requires_change flag
// - session cookies (server-side copy + idle/absolute expiry)
// - audit log rows (actor, role, auth_mode, ip, ua, action, target,
// before_hash, after_hash, ts)
// - discovered-app inventory cache (json blob per app)
//
// All tables use INTEGER PRIMARY KEY so GORM-style IDs work; the
// store itself is hand-written SQL because the scope is tiny and we
// want zero coupling to GORM.
const schemaSQL = `
CREATE TABLE IF NOT EXISTS webapp_users (
id INTEGER PRIMARY KEY,
bcrypt_hash TEXT NOT NULL,
requires_change INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_change_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webapp_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
csrf_token TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '',
ua TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webapp_sessions_user
ON webapp_sessions(user_id);
CREATE TABLE IF NOT EXISTS webapp_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
auth_mode TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
ua TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
before_hash TEXT NOT NULL DEFAULT '',
after_hash TEXT NOT NULL DEFAULT '',
ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webapp_audit_ts
ON webapp_audit(ts);
CREATE TABLE IF NOT EXISTS webapp_apps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
source TEXT NOT NULL,
pid INTEGER NOT NULL DEFAULT 0,
ports TEXT NOT NULL DEFAULT '',
start_ts INTEGER NOT NULL DEFAULT 0,
last_seen INTEGER NOT NULL,
json_blob TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_webapp_apps_name
ON webapp_apps(name);
`
// Store is the embedded webapp database. It is safe for concurrent
// use because the underlying *sql.DB is.
//
// All exported methods take a context for cancellation; passing
// context.Background() is fine for the Phase 1 callers (background
// pruning timer + per-request handlers).
type Store struct {
db *sql.DB
}
// OpenStore opens (or creates) the embedded SQLite database at the
// given path. For tests, pass "file::memory:?cache=shared" or just
// ":memory:". The returned Store must be closed via Close().
func OpenStore(path string) (*Store, error) {
if path == "" {
return nil, errors.New("webapp: empty store path")
}
// _pragma options: enforce foreign keys is irrelevant for this
// single-table-per-feature design; we do however turn on WAL for
// on-disk files because the audit write path is concurrent with
// the login handler and we do not want readers to block writers.
dsn := path
if !strings.HasPrefix(path, "file:") && path != ":memory:" {
dsn = path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("webapp: open sqlite: %w", err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("webapp: ping sqlite: %w", err)
}
if _, err := db.Exec(schemaSQL); err != nil {
_ = db.Close()
return nil, fmt.Errorf("webapp: apply schema: %w", err)
}
return &Store{db: db}, nil
}
// Close releases the underlying database handle. Safe to call once.
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// User is the single-user model for the Phase 1 webapp. The worker
// webapp is single-tenant (one operator per worker) so a single
// row is the whole table.
type User struct {
ID int64
BcryptHash string
RequiresChange bool
CreatedAt time.Time
LastChangeAt time.Time
}
// GetUser returns the single webapp user, or sql.ErrNoRows if no
// user has been provisioned yet (first run).
func (s *Store) GetUser(ctx context.Context) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, bcrypt_hash, requires_change, created_at, last_change_at
FROM webapp_users ORDER BY id ASC LIMIT 1`)
u := &User{}
var requiresChange int
var created, changed int64
if err := row.Scan(&u.ID, &u.BcryptHash, &requiresChange, &created, &changed); err != nil {
return nil, err
}
u.RequiresChange = requiresChange != 0
u.CreatedAt = time.Unix(created, 0).UTC()
u.LastChangeAt = time.Unix(changed, 0).UTC()
return u, nil
}
// CreateUser provisions the first (and only) webapp user with the
// requires_change flag set to the given value. Returns the new user
// row on success.
//
// Callers should pick the flag value:
// - false: subsequent-start path; the operator knows the password
// and does not need to rotate it again.
// - true: first-run path; the operator must update the random
// initial password before reaching any other page.
func (s *Store) CreateUser(ctx context.Context, bcryptHash string, requiresChange bool) (*User, error) {
flagVal := 0
if requiresChange {
flagVal = 1
}
now := time.Now().UTC()
res, err := s.db.ExecContext(ctx,
`INSERT INTO webapp_users (bcrypt_hash, requires_change, created_at, last_change_at)
VALUES (?, ?, ?, ?)`,
bcryptHash, flagVal, now.Unix(), now.Unix())
if err != nil {
return nil, fmt.Errorf("webapp: insert user: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("webapp: last insert id: %w", err)
}
return &User{
ID: id,
BcryptHash: bcryptHash,
RequiresChange: requiresChange,
CreatedAt: now,
LastChangeAt: now,
}, nil
}
// CreateFirstRunUser provisions the single webapp user with the
// requires_change flag set so the operator is forced to update the
// password before any other page is reachable. Used by the cmd
// binary on first start after minting the random initial password.
func (s *Store) CreateFirstRunUser(ctx context.Context, bcryptHash string) (*User, error) {
return s.CreateUser(ctx, bcryptHash, true)
}
// EnsureAnonymousUser provisions a placeholder user row with an
// unusable bcrypt hash when no user exists yet. Used by the basic-auth
// login flow: the session row needs a user_id foreign key target, but
// the operator authenticates with WORKER_LOGIN/WORKER_PASSWORD, not a
// bcrypt password. Idempotent — returns nil if a row already exists.
//
// The hash is a fixed bcrypt of a random unguessable value so even an
// attacker who reads the SQLite file cannot derive a usable password.
func (s *Store) EnsureAnonymousUser(ctx context.Context) error {
existing, err := s.GetUser(ctx)
if err == nil && existing != nil {
return nil
}
unusable, err := HashPassword("!" + newAnonymousSecret() + "!")
if err != nil {
return fmt.Errorf("webapp: hash anonymous password: %w", err)
}
_, err = s.CreateUser(ctx, unusable, false)
if err != nil {
return fmt.Errorf("webapp: create anonymous user: %w", err)
}
return nil
}
func newAnonymousSecret() string {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
// Fall back to a time-derived secret — still unguessable
// from outside the worker process because we only need the
// hash to be non-recoverable, not the plaintext.
return fmt.Sprintf("anon-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
// UpdatePassword rotates the user's bcrypt hash and clears the
// requires_change flag. beforeHash is recorded for the audit row.
func (s *Store) UpdatePassword(ctx context.Context, userID int64, newHash string) error {
now := time.Now().UTC()
res, err := s.db.ExecContext(ctx,
`UPDATE webapp_users SET bcrypt_hash = ?, requires_change = 0, last_change_at = ? WHERE id = ?`,
newHash, now.Unix(), userID)
if err != nil {
return fmt.Errorf("webapp: update password: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("webapp: rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("webapp: user %d not found", userID)
}
return nil
}
// MarkRequiresChange flips the requires_change flag back on. Used by
// the password-change handler when the new password fails the
// minimal-strength check (so the operator is forced to retry).
func (s *Store) MarkRequiresChange(ctx context.Context, userID int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE webapp_users SET requires_change = 1 WHERE id = ?`, userID)
return err
}
// Session is a row from webapp_sessions.
type Session struct {
ID string
UserID int64
CSRFToken string
IP string
UA string
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
}
// CreateSession inserts a new session row and returns it.
func (s *Store) CreateSession(ctx context.Context, sess *Session) error {
if sess.ID == "" || sess.CSRFToken == "" {
return errors.New("webapp: session id and csrf token are required")
}
if sess.CreatedAt.IsZero() {
sess.CreatedAt = time.Now().UTC()
}
if sess.LastSeenAt.IsZero() {
sess.LastSeenAt = sess.CreatedAt
}
if sess.ExpiresAt.IsZero() {
return errors.New("webapp: session expires_at is required")
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO webapp_sessions
(id, user_id, csrf_token, ip, ua, created_at, last_seen_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
sess.ID, sess.UserID, sess.CSRFToken, sess.IP, sess.UA,
sess.CreatedAt.Unix(), sess.LastSeenAt.Unix(), sess.ExpiresAt.Unix())
if err != nil {
return fmt.Errorf("webapp: insert session: %w", err)
}
return nil
}
// GetSession fetches a session by id. Returns sql.ErrNoRows if the
// session is unknown or expired.
func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, user_id, csrf_token, ip, ua, created_at, last_seen_at, expires_at
FROM webapp_sessions WHERE id = ?`, id)
sess := &Session{}
var created, lastSeen, expires int64
if err := row.Scan(&sess.ID, &sess.UserID, &sess.CSRFToken, &sess.IP, &sess.UA,
&created, &lastSeen, &expires); err != nil {
return nil, err
}
sess.CreatedAt = time.Unix(created, 0).UTC()
sess.LastSeenAt = time.Unix(lastSeen, 0).UTC()
sess.ExpiresAt = time.Unix(expires, 0).UTC()
if time.Now().UTC().After(sess.ExpiresAt) {
return nil, sql.ErrNoRows
}
return sess, nil
}
// TouchSession updates last_seen_at and slides the absolute expiry
// forward by the idle window, if and only if the session is still
// inside its absolute cap.
func (s *Store) TouchSession(ctx context.Context, id string, idle, absolute time.Duration) error {
now := time.Now().UTC()
row := s.db.QueryRowContext(ctx,
`SELECT last_seen_at FROM webapp_sessions WHERE id = ?`, id)
var lastSeen int64
if err := row.Scan(&lastSeen); err != nil {
return err
}
created := time.Unix(lastSeen, 0).UTC()
newLast := now
newExp := now.Add(idle)
absCap := created.Add(absolute)
if newExp.After(absCap) {
newExp = absCap
}
_, err := s.db.ExecContext(ctx,
`UPDATE webapp_sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
newLast.Unix(), newExp.Unix(), id)
return err
}
// DeleteSession removes the session by id. Used on logout.
func (s *Store) DeleteSession(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM webapp_sessions WHERE id = ?`, id)
return err
}
// AuditEntry is a single audit-log row.
type AuditEntry struct {
ID int64
Actor string
Role string
AuthMode string
IP string
UA string
Action string
Target string
BeforeHash string
AfterHash string
TS time.Time
}
// WriteAudit appends a row to webapp_audit.
func (s *Store) WriteAudit(ctx context.Context, e *AuditEntry) error {
if e.Action == "" {
return errors.New("webapp: audit action is required")
}
if e.TS.IsZero() {
e.TS = time.Now().UTC()
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO webapp_audit
(actor, role, auth_mode, ip, ua, action, target, before_hash, after_hash, ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Actor, e.Role, e.AuthMode, e.IP, e.UA, e.Action, e.Target,
e.BeforeHash, e.AfterHash, e.TS.Unix())
if err != nil {
return fmt.Errorf("webapp: insert audit: %w", err)
}
return nil
}
// RecentAudit returns the most recent n audit rows in reverse-chron
// order. Used by tests; the UI does not need this view in Phase 1.
func (s *Store) RecentAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, actor, role, auth_mode, ip, ua, action, target,
before_hash, after_hash, ts
FROM webapp_audit ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close() //nolint:errcheck
var out []AuditEntry
for rows.Next() {
e := AuditEntry{}
var ts int64
if err := rows.Scan(&e.ID, &e.Actor, &e.Role, &e.AuthMode, &e.IP, &e.UA,
&e.Action, &e.Target, &e.BeforeHash, &e.AfterHash, &ts); err != nil {
return nil, err
}
e.TS = time.Unix(ts, 0).UTC()
out = append(out, e)
}
return out, rows.Err()
}
// PruneAudit deletes audit rows older than retention. Called from a
// daily timer in the server.
func (s *Store) PruneAudit(ctx context.Context, retention time.Duration) (int64, error) {
cutoff := time.Now().UTC().Add(-retention).Unix()
res, err := s.db.ExecContext(ctx,
`DELETE FROM webapp_audit WHERE ts < ?`, cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// App is the inventory cache row.
type App struct {
ID int64
Name string
Source string
PID int
Ports string
StartTS int64
LastSeen time.Time
JSONBlob string
}
// ReplaceApps deletes the entire inventory and re-inserts the given
// snapshot. Called from the inventory refresh loop. Wrapped in a
// transaction so the page never reads a half-replaced list.
func (s *Store) ReplaceApps(ctx context.Context, apps []App) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM webapp_apps`); err != nil {
_ = tx.Rollback()
return err
}
for _, a := range apps {
if _, err := tx.ExecContext(ctx,
`INSERT INTO webapp_apps (name, source, pid, ports, start_ts, last_seen, json_blob)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
a.Name, a.Source, a.PID, a.Ports, a.StartTS, a.LastSeen.Unix(), a.JSONBlob); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
// ListApps returns the inventory rows in insertion order. Used by the
// discovered-apps handler.
func (s *Store) ListApps(ctx context.Context) ([]App, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, source, pid, ports, start_ts, last_seen, json_blob
FROM webapp_apps ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close() //nolint:errcheck
var out []App
for rows.Next() {
a := App{}
var lastSeen int64
if err := rows.Scan(&a.ID, &a.Name, &a.Source, &a.PID, &a.Ports,
&a.StartTS, &lastSeen, &a.JSONBlob); err != nil {
return nil, err
}
a.LastSeen = time.Unix(lastSeen, 0).UTC()
out = append(out, a)
}
return out, rows.Err()
}