Files
worker/internal/webapp/server.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

647 строки
22 KiB
Go

package webapp
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"rsgit.ru/rsmon/rsmon/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
// DebugClusterApply gates the /web/api/cluster/apply-test-config
// endpoint. When false (the default) the route is registered but
// the handler returns 404 so the endpoint is invisible in
// production. Operators who want to poke the cluster FSM during
// development set WORKER_CLUSTER_DEBUG_APPLY=true. The endpoint
// must NEVER be reachable in production — it appends hardcoded
// log entries to the Raft FSM without going through the real
// config-adoption producer.
DebugClusterApply bool
// 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.DebugClusterApply = parseBool(env[envClusterDebugApply])
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",
envClusterDebugApply, 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
ApplyTestConfig() (uint64, error)
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 buffer. Phase 1 only emits selfcheck alerts; main-app-issued
// notifications still live in the main app's DB.
type NotificationRow struct {
Kind string // "email", "telegram_private", "telegram_group"
Channel string
Subject string
Body string
OK bool
Error string
At time.Time
}
// 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")
}