493 строки
15 KiB
Go
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()
|
|
}
|