Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
- reconnect safely after token rotation and retry leased results - reject malformed tasks and remove production cluster debug mutation - validate environment files and require immutable container images BREAKING CHANGE: Docker install, deploy, and Compose now require an immutable repository@sha256 image reference.
638 строки
21 KiB
Go
638 строки
21 KiB
Go
package webapp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"rocketgit.ru/rsmon/worker/internal/distworker"
|
|
)
|
|
|
|
// Phase 1 (MVP) defaults. Times match docs/distributed/worker-web-app.md
|
|
// section 5.5 (30-minute idle, 8-hour absolute).
|
|
const (
|
|
defaultSessionIdle = 30 * time.Minute
|
|
defaultSessionAbs = 8 * time.Hour
|
|
defaultHTTPListenAddr = "0.0.0.0:27401"
|
|
|
|
// Audit retention: 7 days per section 13.2 of the plan doc.
|
|
defaultAuditRetention = 7 * 24 * time.Hour
|
|
// Prune runs once per day; the timer survives a long-lived worker
|
|
// because the store is appended to from every state-changing handler.
|
|
defaultAuditPruneInterval = 24 * time.Hour
|
|
|
|
// Server-read-header timeout. Keeps slowloris at bay without
|
|
// truncating large form posts on the change-password page.
|
|
readHeaderTimeout = 10 * time.Second
|
|
|
|
// Cookie names. Kept short so they fit inside the browser cookie
|
|
// per-domain limit even when the operator is running the worker
|
|
// webapp next to other local services.
|
|
sessionCookieName = "rsmon_wsess"
|
|
csrfCookieName = "rsmon_wcsrf"
|
|
)
|
|
|
|
// Config holds the runtime knobs for the local webapp server. The
|
|
// fields mirror the relevant env-var settings (with the same names)
|
|
// so cmd/rsmon-worker/main.go can build a Config from os.Environ.
|
|
//
|
|
// Phase 1 (MVP) honors WORKER_HOST, WORKER_PORT, WORKER_LOGIN,
|
|
// WORKER_PASSWORD, and the RSMON_WEBAPP_DATA_DIR knob. Basic auth
|
|
// (WORKER_LOGIN/PASSWORD) is the primary auth path now that
|
|
// deployments want the webapp reachable on non-loopback addresses;
|
|
// the per-machine bcrypt user remains the fallback for fully offline
|
|
// single-operator installs.
|
|
type Config struct {
|
|
// Addr is the bind address for the HTTP listener. Operators
|
|
// typically set WORKER_HOST=0.0.0.0 + WORKER_PORT to expose the
|
|
// webapp on a public interface, with the network fronted by a
|
|
// reverse proxy / Traefik.
|
|
Addr string
|
|
|
|
// DataDir is the on-disk directory the SQLite store and any other
|
|
// state files live in. The directory is created on demand.
|
|
DataDir string
|
|
|
|
// SessionIdle / SessionAbs override the defaults above when set
|
|
// to a positive value.
|
|
SessionIdle time.Duration
|
|
SessionAbs time.Duration
|
|
|
|
// StorePath is the on-disk path of the SQLite file. When empty,
|
|
// the server derives it from DataDir + "webapp.db".
|
|
StorePath string
|
|
|
|
// BasicAuthLogin / BasicAuthPassword enable HTTP basic auth on
|
|
// the webapp when both are non-empty. Both must be set; a mixed
|
|
// state (XOR) is rejected by ValidateBasicAuth. Basic auth covers
|
|
// the /web/api/* JSON endpoints AND the login form itself, so the
|
|
// web UI works for an operator who only knows their credentials.
|
|
BasicAuthLogin string
|
|
BasicAuthPassword string
|
|
|
|
// ReleaseURL is the optional URL the worker polls to discover
|
|
// the latest published version of the worker binary. When empty
|
|
// the /updates page shows the placeholder "v1 (dev)". The URL
|
|
// is expected to respond with a JSON body containing a
|
|
// "tag_name" field (GitHub release JSON is the canonical
|
|
// shape). WORKER_RELEASE_URL sets this.
|
|
ReleaseURL string
|
|
}
|
|
|
|
// ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD
|
|
// are both set or both empty. A mixed state is a config bug and is
|
|
// rejected so an operator notices immediately. Both empty disables
|
|
// basic auth and falls back to the local bcrypt user.
|
|
func ValidateBasicAuth(login, password string) error {
|
|
loginSet := strings.TrimSpace(login) != ""
|
|
passSet := password != ""
|
|
if loginSet != passSet {
|
|
return fmt.Errorf(
|
|
"webapp: WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty (got login=%s password=%s)",
|
|
boolStr(loginSet), boolStr(passSet))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "set"
|
|
}
|
|
return "empty"
|
|
}
|
|
|
|
// ClusterEnabledFromEnv is a tiny helper that returns true when the
|
|
// WORKER_CLUSTER_ENABLED env var is set to a truthy value. The exact
|
|
// parsing rules match the cmd binary: only the literal string "true"
|
|
// (case-insensitive) is treated as enabled. This keeps the webapp
|
|
// package's env-handling dependency-free of the workercluster
|
|
// package.
|
|
func ClusterEnabledFromEnv(env map[string]string) bool {
|
|
v := strings.ToLower(strings.TrimSpace(env[envClusterEnabled]))
|
|
return v == truthyTrue || v == truthyOne || v == truthyYes
|
|
}
|
|
|
|
// Truthy literal constants hoisted to satisfy goconst. parseBool
|
|
// and ClusterEnabledFromEnv share the same vocabulary.
|
|
const (
|
|
truthyTrue = "true"
|
|
truthyOne = "1"
|
|
truthyYes = "yes"
|
|
)
|
|
|
|
// envOr returns the env var value or fallback.
|
|
func envOr(env map[string]string, key, fallback string) string {
|
|
if v, ok := env[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// ConfigFromEnv builds a Config from a process-style environment map.
|
|
// It rejects XOR WORKER_LOGIN/WORKER_PASSWORD (ValidateBasicAuth) and
|
|
// reuses distworker.HTTPConfigFromEnv so the webapp and the cmd
|
|
// binary agree on host/port/url semantics.
|
|
//
|
|
// WORKER_HOST defaults to distworker.DefaultHTTPHost (0.0.0.0) and
|
|
// WORKER_PORT to distworker.DefaultHTTPPort (27401). The previous
|
|
// Phase 1 policy of binding to loopback only was removed: production
|
|
// deployments now expose the webapp on a real interface, with TLS
|
|
// terminated by a reverse proxy.
|
|
func ConfigFromEnv(env map[string]string, defaultDataDir string) (Config, error) {
|
|
login := strings.TrimSpace(env["WORKER_LOGIN"])
|
|
password := env["WORKER_PASSWORD"]
|
|
if err := ValidateBasicAuth(login, password); err != nil {
|
|
return Config{}, err
|
|
}
|
|
httpCfg := distworker.HTTPConfigFromEnv()
|
|
host := httpCfg.Host
|
|
if raw := strings.TrimSpace(env["WORKER_HOST"]); raw != "" {
|
|
host = raw
|
|
}
|
|
port := httpCfg.Port
|
|
if raw := strings.TrimSpace(env["WORKER_PORT"]); raw != "" {
|
|
if v, err := strconv.Atoi(raw); err == nil && v > 0 && v <= 65535 {
|
|
port = v
|
|
}
|
|
}
|
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
|
dataDir := envOr(env, "RSMON_WEBAPP_DATA_DIR", defaultDataDir)
|
|
cfg := Config{
|
|
Addr: addr,
|
|
DataDir: dataDir,
|
|
BasicAuthLogin: login,
|
|
BasicAuthPassword: password,
|
|
}
|
|
if v := env["RSMON_WEBAPP_STORE_PATH"]; v != "" {
|
|
cfg.StorePath = v
|
|
}
|
|
cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
|
|
return cfg, nil
|
|
}
|
|
|
|
// parseBool returns true for the strings "true", "1", "yes" (any
|
|
// case, trimmed). Anything else is false. Used for opt-in feature
|
|
// flags wired through env vars without dragging in a config-package
|
|
// dependency.
|
|
func parseBool(v string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case truthyTrue, truthyOne, truthyYes:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ConfigFromEnvOrDefault builds a Config from the process
|
|
// environment via os.Getenv. Exposed so the cmd rsmon-worker binary
|
|
// can wire the webapp without passing an env map around.
|
|
func ConfigFromEnvOrDefault() Config {
|
|
env := map[string]string{}
|
|
for _, k := range []string{
|
|
envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
|
|
"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
|
|
"WORKER_CLUSTER_ENABLED",
|
|
envReleaseURL,
|
|
} {
|
|
if v := os.Getenv(k); v != "" {
|
|
env[k] = v
|
|
}
|
|
}
|
|
cfg, err := ConfigFromEnv(env, defaultDataDir())
|
|
if err != nil {
|
|
log.Printf("webapp: %v; falling back to safe defaults", err)
|
|
cfg = Config{Addr: defaultHTTPListenAddr}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// Deps are the in-process interfaces the webapp reads from the
|
|
// worker. Each field is optional; the page that needs it must
|
|
// tolerate a nil value. Splitting the deps from the runner keeps
|
|
// tests focused and lets cmd/rsmon-worker wire a thin facade.
|
|
type Deps struct {
|
|
Runner WorkerView
|
|
Cluster ClusterView // optional; nil means no cluster endpoints are registered
|
|
Version string
|
|
BuildDate string
|
|
Commit string
|
|
StartedAt time.Time
|
|
Logger *log.Logger
|
|
TokenRotator func(ctx context.Context) (newToken string, err error) // optional; nil disables rotation
|
|
|
|
// ReleaseHTTPClient is the *http.Client the /updates page uses
|
|
// to poll Config.ReleaseURL. When nil the page falls back to a
|
|
// 3s-timeout default client. cmd/rsmon-worker wires a shared
|
|
// client so transport-level settings (TLS, proxy) are honored
|
|
// without each handler opening its own connection pool.
|
|
ReleaseHTTPClient *http.Client
|
|
}
|
|
|
|
// ClusterView is the narrow surface webapp needs from the worker
|
|
// cluster. The concrete type lives in cmd/rsmon-worker; webapp only
|
|
// depends on this interface so it can be stubbed in tests without
|
|
// pulling in the raft package or bbolt.
|
|
type ClusterView interface {
|
|
Stats() ClusterStats
|
|
ClusterID() string
|
|
LocalAddr() string
|
|
}
|
|
|
|
// ClusterStats is the wire-stable view of a cluster. Field names
|
|
// mirror the JSON the GET /web/api/cluster/status endpoint returns.
|
|
//
|
|
// FSMConfigVersion / FSMOutboxLen / FSMPartition carry the FSM-side
|
|
// operator signals from plan section 6.1. The webapp's ClusterView
|
|
// adapter copies them from the workercluster ClusterStats so the JSON
|
|
// endpoint can surface the FSM state without taking a separate code
|
|
// path through the FSM type.
|
|
type ClusterStats struct {
|
|
NodeID string
|
|
LocalAddr string
|
|
State string
|
|
Leader string
|
|
Term uint64
|
|
AppliedIndex uint64
|
|
LastIndex uint64
|
|
NumPeers int
|
|
Voters []string
|
|
FSMChecks int
|
|
FSMMembers int
|
|
FSMConfigVersion uint64
|
|
FSMOutboxLen int
|
|
FSMPartition string
|
|
}
|
|
|
|
// WorkerView is the surface the webapp needs from the distworker
|
|
// runner. It is a narrow interface so tests can mock it without
|
|
// touching the websocket loop or pool plumbing.
|
|
type WorkerView interface {
|
|
HTTPConfig() distworker.HTTPConfig
|
|
Token() string
|
|
TokenRotatedAt() time.Time
|
|
WorkerID() string
|
|
RegionCode() string
|
|
WorkerVersion() string
|
|
WorkerCapabilities() []string
|
|
LastHeartbeatAck() time.Time
|
|
RecentResults(n int) []ResultRow
|
|
RecentNotifications(n int) []NotificationRow
|
|
// MasterStatus returns the most recent local selfcheck verdict
|
|
// and the wall-clock time it was produced. The first return
|
|
// value is nil when no probe has run yet. The webapp renders
|
|
// the verdict at /api/peer/status so peer workers can join
|
|
// the consensus (see
|
|
// docs/distributed/worker-to-worker-raft.md).
|
|
MasterStatus() (up *bool, observedAt time.Time)
|
|
}
|
|
|
|
// ResultRow is one row from the worker's in-memory result ring
|
|
// buffer. It is the same shape the checks page renders as a table.
|
|
type ResultRow struct {
|
|
MonitorID int64
|
|
CheckID int64
|
|
Kind string
|
|
Host string
|
|
State string
|
|
DurationMs int64
|
|
Error string
|
|
At time.Time
|
|
}
|
|
|
|
// NotificationRow is one row from the worker's in-memory notification ring.
|
|
type NotificationRow struct {
|
|
Kind string // "email", "telegram_private", "telegram_group"
|
|
Channel string
|
|
Subject string
|
|
Body string
|
|
OK bool
|
|
Error string
|
|
At time.Time
|
|
|
|
JobID string
|
|
Method string
|
|
Status string
|
|
DurationMs int
|
|
}
|
|
|
|
// Server is the local HTTP server for the worker webapp. It owns the
|
|
// SQLite store, the template bundle, the in-memory log buffer, and
|
|
// the inventory snapshot. A single instance is bound to a single
|
|
// Config and is safe for concurrent use after Start returns.
|
|
type Server struct {
|
|
cfg Config
|
|
store *Store
|
|
deps Deps
|
|
mux *http.ServeMux
|
|
templates *Templates
|
|
logBuffer *LogBuffer
|
|
inventory *Inventory
|
|
metrics *Metrics
|
|
cluster ClusterView
|
|
pruneStop chan struct{}
|
|
pruneWG sync.WaitGroup
|
|
|
|
releasePoller *releasePoller
|
|
|
|
// basicAuthHash is the SHA-256 hash of BasicAuthPassword (or the
|
|
// zero value when basic auth is not configured). Computed once in
|
|
// New so the middleware uses constant-time comparison. Login is
|
|
// kept plaintext in cfg because the comparison happens in the
|
|
// login form handler too.
|
|
basicAuthHash [32]byte
|
|
basicAuthOK bool
|
|
|
|
httpServer *http.Server
|
|
}
|
|
|
|
// New constructs a Server with the given config and dependencies.
|
|
// The store is opened (and the schema applied) synchronously; the
|
|
// caller must call Close to release the SQLite handle.
|
|
func New(cfg Config, deps *Deps) (*Server, error) { //nolint:gocritic // Config is widely passed by value in this package
|
|
if cfg.Addr == "" {
|
|
cfg.Addr = defaultHTTPListenAddr
|
|
}
|
|
if _, _, err := net.SplitHostPort(cfg.Addr); err != nil {
|
|
return nil, fmt.Errorf("webapp: bind address %q: %w", cfg.Addr, err)
|
|
}
|
|
if cfg.SessionIdle <= 0 {
|
|
cfg.SessionIdle = defaultSessionIdle
|
|
}
|
|
if cfg.SessionAbs <= 0 {
|
|
cfg.SessionAbs = defaultSessionAbs
|
|
}
|
|
if cfg.DataDir == "" {
|
|
cfg.DataDir = defaultDataDir()
|
|
}
|
|
if cfg.StorePath == "" {
|
|
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("webapp: mkdir data dir: %w", err)
|
|
}
|
|
cfg.StorePath = filepath.Join(cfg.DataDir, "webapp.db")
|
|
}
|
|
store, err := OpenStore(cfg.StorePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if deps.Logger == nil {
|
|
deps.Logger = log.New(os.Stderr, "webapp: ", log.LstdFlags|log.Lshortfile)
|
|
}
|
|
if deps.StartedAt.IsZero() {
|
|
deps.StartedAt = time.Now().UTC()
|
|
}
|
|
tmpl, err := loadTemplates()
|
|
if err != nil {
|
|
_ = store.Close()
|
|
return nil, err
|
|
}
|
|
s := &Server{
|
|
cfg: cfg,
|
|
store: store,
|
|
deps: *deps,
|
|
mux: http.NewServeMux(),
|
|
templates: tmpl,
|
|
logBuffer: NewLogBuffer(5000),
|
|
inventory: NewInventory(store, deps.Logger),
|
|
metrics: NewMetrics(),
|
|
cluster: deps.Cluster,
|
|
pruneStop: make(chan struct{}),
|
|
releasePoller: &releasePoller{},
|
|
}
|
|
if cfg.ReleaseURL != "" {
|
|
s.releasePoller.setURL(cfg.ReleaseURL)
|
|
}
|
|
if cfg.BasicAuthLogin != "" && cfg.BasicAuthPassword != "" {
|
|
s.basicAuthHash = sha256.Sum256([]byte(cfg.BasicAuthPassword))
|
|
s.basicAuthOK = true
|
|
}
|
|
s.routes()
|
|
s.httpServer = &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: s.securityHeaders(s.mux),
|
|
ReadHeaderTimeout: readHeaderTimeout,
|
|
IdleTimeout: 2 * time.Minute,
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// BasicAuthEnabled reports whether the server is configured with
|
|
// WORKER_LOGIN / WORKER_PASSWORD basic auth. Exposed for templates
|
|
// that need to vary the login form (username field + explanatory copy).
|
|
func (s *Server) BasicAuthEnabled() bool {
|
|
return s != nil && s.basicAuthOK
|
|
}
|
|
|
|
// BasicAuthLogin returns the configured WORKER_LOGIN. Used by the
|
|
// login form so an operator with curl / scripts can copy the value
|
|
// straight out of the /settings page.
|
|
func (s *Server) BasicAuthLogin() string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return s.cfg.BasicAuthLogin
|
|
}
|
|
|
|
// Store returns the embedded store for tests and for the cmd
|
|
// rsmon-worker binary that needs to provision the first user.
|
|
func (s *Server) Store() *Store { return s.store }
|
|
|
|
// LogBuffer returns the in-memory worker-log ring buffer so the
|
|
// worker process can attach an slog/JSON sink.
|
|
func (s *Server) LogBuffer() *LogBuffer { return s.logBuffer }
|
|
|
|
// SetCluster attaches a cluster subsystem after construction. The
|
|
// cluster admin endpoints (/web/api/cluster/status and the test-config
|
|
// applier) are not registered until SetCluster is called. Pass nil to
|
|
// detach. Intended for cmd/rsmon-worker to wire the cluster after
|
|
// webapp.New; tests should construct a fresh Server with the cluster
|
|
// already on the Deps.
|
|
func (s *Server) SetCluster(c ClusterView) { s.cluster = c }
|
|
|
|
// Cluster returns the attached cluster subsystem (or nil).
|
|
func (s *Server) Cluster() ClusterView { return s.cluster }
|
|
|
|
// Close shuts down the HTTP listener, the prune goroutine, and the
|
|
// embedded store. Safe to call multiple times.
|
|
func (s *Server) Close(ctx context.Context) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-s.pruneStop:
|
|
default:
|
|
close(s.pruneStop)
|
|
}
|
|
s.pruneWG.Wait()
|
|
if s.httpServer != nil {
|
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
if err := s.httpServer.Shutdown(shutdownCtx); err != nil &&
|
|
!errors.Is(err, http.ErrServerClosed) {
|
|
s.deps.Logger.Printf("shutdown: %v", err)
|
|
}
|
|
}
|
|
if s.store != nil {
|
|
_ = s.store.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Start launches the audit-prune goroutine and binds the HTTP
|
|
// listener. Blocks until ctx is canceled or the listener errors
|
|
// out. Call Close to ensure cleanup.
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
s.pruneWG.Add(1)
|
|
go s.pruneLoop(ctx)
|
|
|
|
// Run ListenAndServe in a goroutine so we can race it against ctx.
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
s.deps.Logger.Printf("listening on http://%s (data dir=%s)", s.cfg.Addr, s.cfg.DataDir)
|
|
if err := s.httpServer.ListenAndServe(); err != nil &&
|
|
!errors.Is(err, http.ErrServerClosed) {
|
|
errCh <- err
|
|
return
|
|
}
|
|
errCh <- nil
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = s.httpServer.Shutdown(shutdownCtx)
|
|
return nil
|
|
case err := <-errCh:
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Addr returns the actual bind address the server is using.
|
|
func (s *Server) Addr() string { return s.cfg.Addr }
|
|
|
|
// Handler returns the http.Handler the server registers with
|
|
// http.Server. Exposed for tests that want to wrap the mux in an
|
|
// httptest.Server so the bound port is observable. Production code
|
|
// should call ListenAndServe / Start instead.
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.securityHeaders(s.mux)
|
|
}
|
|
|
|
// SessionIdle / SessionAbs expose the configured timeouts so the
|
|
// handlers can compute the expiry without hard-coding.
|
|
func (s *Server) SessionIdle() time.Duration { return s.cfg.SessionIdle }
|
|
|
|
// SessionAbs returns the absolute session lifetime configured for
|
|
// this server. Used by handlers that need to compute the absolute
|
|
// cap of a session relative to its creation time.
|
|
func (s *Server) SessionAbs() time.Duration { return s.cfg.SessionAbs }
|
|
|
|
// ProvisionFirstRunIfNeeded mints a fresh first-run password and
|
|
// seeds the user row if no user exists yet. Prints the plaintext
|
|
// once via the supplied logger so the operator can read it from the
|
|
// worker log. Subsequent starts do nothing — the bcrypt hash on
|
|
// disk is the source of truth.
|
|
//
|
|
// When basic auth is configured the operator never types a password
|
|
// into the webapp login form, so the first-run bcrypt seed is
|
|
// unnecessary. We still create an anonymous user row so the basic-auth
|
|
// login flow has a stable user_id to anchor sessions on; the row's
|
|
// bcrypt hash is intentionally unusable.
|
|
//
|
|
// Called by cmd/rsmon-worker/main.go after New() and before
|
|
// Start() so the listener accepts the login form even before the
|
|
// worker has a websocket connection to the main app.
|
|
func ProvisionFirstRunIfNeeded(srv *Server, logger *log.Logger) error {
|
|
if srv == nil || srv.store == nil {
|
|
return fmt.Errorf("webapp: nil server")
|
|
}
|
|
ctx := context.Background()
|
|
existing, err := srv.store.GetUser(ctx)
|
|
if err == nil && existing != nil {
|
|
// User already provisioned — leave it alone.
|
|
return nil
|
|
}
|
|
if srv.basicAuthOK {
|
|
// Basic-auth operator never logs in via bcrypt; the user
|
|
// row is just a placeholder for session.user_id.
|
|
if err := srv.store.EnsureAnonymousUser(ctx); err != nil {
|
|
return fmt.Errorf("webapp: provision anonymous user: %w", err)
|
|
}
|
|
logger.Printf("webapp: WORKER_LOGIN/WORKER_PASSWORD basic auth enabled; bcrypt first-run password skipped")
|
|
return nil
|
|
}
|
|
plain, err := GenerateFirstRunPassword()
|
|
if err != nil {
|
|
return fmt.Errorf("webapp: mint first-run password: %w", err)
|
|
}
|
|
hash, err := HashPassword(plain)
|
|
if err != nil {
|
|
return fmt.Errorf("webapp: hash first-run password: %w", err)
|
|
}
|
|
if _, err := srv.store.CreateFirstRunUser(ctx, hash); err != nil {
|
|
return fmt.Errorf("webapp: create first-run user: %w", err)
|
|
}
|
|
logger.Printf("==============================================================")
|
|
logger.Printf("webapp: first-run password generated — print this NOW and store safely:")
|
|
logger.Printf("webapp: password = %s", plain)
|
|
logger.Printf("webapp: this password is shown only once. Log in and change it.")
|
|
logger.Printf("==============================================================")
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) pruneLoop(ctx context.Context) {
|
|
defer s.pruneWG.Done()
|
|
ticker := time.NewTicker(defaultAuditPruneInterval)
|
|
defer ticker.Stop()
|
|
// Run once shortly after startup so a long-lived worker does not
|
|
// wait a full day for the first prune.
|
|
first := time.NewTimer(5 * time.Minute)
|
|
defer first.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-s.pruneStop:
|
|
return
|
|
case <-first.C:
|
|
if n, err := s.store.PruneAudit(ctx, defaultAuditRetention); err != nil {
|
|
s.deps.Logger.Printf("audit prune: %v", err)
|
|
} else if n > 0 {
|
|
s.deps.Logger.Printf("audit prune: deleted %d old rows", n)
|
|
}
|
|
case <-ticker.C:
|
|
if n, err := s.store.PruneAudit(ctx, defaultAuditRetention); err != nil {
|
|
s.deps.Logger.Printf("audit prune: %v", err)
|
|
} else if n > 0 {
|
|
s.deps.Logger.Printf("audit prune: deleted %d old rows", n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// defaultDataDir returns the per-user state directory the worker
|
|
// webapp uses by default. Matches the path documented in the plan
|
|
// doc: $XDG_DATA_HOME/rsmon-worker (fallback ~/.local/share/rsmon-worker).
|
|
func defaultDataDir() string {
|
|
if v := os.Getenv("RSMON_WEBAPP_DATA_DIR"); v != "" {
|
|
return v
|
|
}
|
|
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
|
|
return filepath.Join(v, "rsmon-worker")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return "/var/lib/rsmon-worker"
|
|
}
|
|
return filepath.Join(home, ".local", "share", "rsmon-worker")
|
|
}
|