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") }