Некоторые проверки не удались
CI / test (push) Successful in 5m17s
Docker / Build and publish worker image (push) Has been cancelled
parseBoolFalseDefault returned false only for explicit falsy literals (including the empty string) and true otherwise, but the ComposeEnabled call site negated it. The double error cancelled for an unset variable (empty -> false -> !false -> true) but inverted every explicit value: WORKER_COMPOSE_ENABLED=true disabled the subsystem while =false enabled it. Rename the helper to parseBoolTrueDefault, drop the empty string from the falsy set so unset stays on, and drop the negation. Add a table-driven regression test pinning unset/true/1/yes -> on and false/0/no/off (any case, trimmed) -> off.
678 строки
23 KiB
Go
678 строки
23 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
|
|
|
|
// ComposeEnabled turns the Docker Compose discovery + management
|
|
// subsystem on. Defaults to true (WORKER_COMPOSE_ENABLED=false to
|
|
// disable): the refresher no-ops when the Docker daemon is absent,
|
|
// so leaving it on is safe on non-Docker hosts. When enabled, the
|
|
// /compose page lists every Compose project on the host and the
|
|
// /web/api/compose/* endpoints drive up/down/stop/restart/pull.
|
|
ComposeEnabled bool
|
|
}
|
|
|
|
// 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])
|
|
// Compose discovery defaults to on; only an explicit falsy literal
|
|
// disables it. An unset env var (empty) keeps the feature on.
|
|
cfg.ComposeEnabled = parseBoolTrueDefault(env[envComposeEnabled])
|
|
return cfg, nil
|
|
}
|
|
|
|
// parseBoolTrueDefault reports true for any value that is not an
|
|
// explicit falsy literal. The empty string (unset env var) is true,
|
|
// so a feature using this parser is on unless the operator opts out.
|
|
// This mirrors parseBool's literal set but inverts the default, which
|
|
// is what ComposeEnabled needs.
|
|
func parseBoolTrueDefault(v string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "false", "0", "no", "off":
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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",
|
|
envComposeEnabled,
|
|
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
|
|
compose *ComposeRefresher
|
|
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),
|
|
compose: NewComposeRefresher(cfg.ComposeEnabled, 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 }
|
|
|
|
// Compose returns the Compose discovery/management refresher so tests
|
|
// and the cmd binary can drive it (e.g. inject a fixture discovery).
|
|
func (s *Server) Compose() *ComposeRefresher { return s.compose }
|
|
|
|
// 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.compose != nil {
|
|
s.compose.Stop()
|
|
}
|
|
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)
|
|
|
|
// Start the Compose discovery loop alongside the listener. It no-ops
|
|
// when disabled or when Docker is absent, so it is always safe to
|
|
// start. The first refresh runs immediately so /compose has data on
|
|
// the first request.
|
|
s.compose.Start(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")
|
|
}
|