feat: publish standalone worker

Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
Gleb Tv
2026-07-13 17:55:14 +03:00
Коммит 2c7a0236da
309 изменённых файлов: 44004 добавлений и 0 удалений

45
internal/webapp/auth_password.go Обычный файл
Просмотреть файл

@@ -0,0 +1,45 @@
package webapp
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
// bcryptCost is the work factor for new bcrypt hashes. Matches the
// "cost 12" assumption from docs/distributed/worker-web-app.md
// section 5.2; the cost applies to first-run and password-change
// hashes alike.
const bcryptCost = 12
// HashPassword bcrypts the given plaintext password at the package's
// configured cost. Returns the encoded hash ready to be persisted.
func HashPassword(plain string) (string, error) {
if plain == "" {
return "", fmt.Errorf("webapp: empty password")
}
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
return "", fmt.Errorf("webapp: bcrypt hash: %w", err)
}
return string(h), nil
}
// VerifyPassword reports whether the given plaintext matches the
// given bcrypt hash. A nil error means the password is correct.
func VerifyPassword(hash, plain string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
}
// MaskToken returns the last 4 characters of a token prefixed with a
// star mask, e.g. "****abcd". Empty input yields "—". Used on the
// settings page where the worker's bearer token is shown read-only.
func MaskToken(token string) string {
if token == "" {
return "—"
}
if len(token) <= 4 {
return "****"
}
return "****" + token[len(token)-4:]
}

48
internal/webapp/auth_password_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,48 @@
package webapp
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateFirstRunPassword(t *testing.T) {
a, err := GenerateFirstRunPassword()
require.NoError(t, err)
require.NotEmpty(t, a)
// 24 bytes -> 32 base64 chars (RawURLEncoding, no padding).
assert.Len(t, a, 32, "first-run password length")
// Two calls must produce different passwords (statistically
// certain with crypto/rand).
b, err := GenerateFirstRunPassword()
require.NoError(t, err)
assert.NotEqual(t, a, b, "two calls must produce distinct passwords")
}
func TestHashAndVerifyPassword(t *testing.T) {
const plain = "correct horse battery staple"
hash, err := HashPassword(plain)
require.NoError(t, err)
require.NotEmpty(t, hash)
assert.True(t, strings.HasPrefix(hash, "$2a$"),
"bcrypt hash should start with $2a$")
require.NoError(t, VerifyPassword(hash, plain),
"correct password must verify")
assert.Error(t, VerifyPassword(hash, "wrong password"),
"wrong password must not verify")
}
func TestHashPasswordRejectsEmpty(t *testing.T) {
_, err := HashPassword("")
assert.Error(t, err, "empty plaintext must be rejected")
}
func TestMaskToken(t *testing.T) {
assert.Equal(t, "—", MaskToken(""))
assert.Equal(t, "****", MaskToken("abcd"))
assert.Equal(t, "****wxyz", MaskToken("abcdefghwxyz"))
}

33
internal/webapp/auth_sessionid.go Обычный файл
Просмотреть файл

@@ -0,0 +1,33 @@
package webapp
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// GenerateFirstRunPassword returns a fresh random password suitable
// for the worker's first-run webapp credential. The output is 24
// bytes of crypto/rand encoded as URL-safe base64 (no padding), which
// is roughly 32 characters long and safe to print once into the
// worker log.
//
// Phase 1 (MVP) only: a stronger entropy scheme can replace this in
// later phases if needed.
func GenerateFirstRunPassword() (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("webapp: read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// newSessionID is the underlying primitive for both session cookies
// and CSRF tokens: 32 bytes from crypto/rand, URL-safe base64.
func newSessionID() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("webapp: read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

47
internal/webapp/constants.go Обычный файл
Просмотреть файл

@@ -0,0 +1,47 @@
package webapp
// Audit constants used across the webapp audit-log writes. They
// live in their own file so goconst sees them as named values
// rather than scattered string literals.
const (
auditActorLocal = "operator"
auditRoleAdmin = "admin"
auditAuthModeLocal = "local"
auditAuthModeBasic = "basic_auth"
auditTargetSelf = "self"
auditActionLogin = "login"
auditActionLoginFail = "login_failed"
auditActionLogout = "logout"
auditActionPassChange = "password_change"
)
// Inventory source labels. Phase 1 only emits "process"; Phase 3
// adds "compose" and "docker".
const (
inventorySourceProcess = "process"
)
// Environment variable names referenced by ConfigFromEnv. Lifted out
// so the validator and the cmd binary share the same constants.
const (
envWorkerHost = "WORKER_HOST"
envWorkerPort = "WORKER_PORT"
envWorkerURL = "WORKER_URL"
envWorkerLogin = "WORKER_LOGIN"
envWorkerPassword = "WORKER_PASSWORD"
envClusterEnabled = "WORKER_CLUSTER_ENABLED"
envClusterDebugApply = "WORKER_CLUSTER_DEBUG_APPLY"
envReleaseURL = "WORKER_RELEASE_URL"
)
// Route paths used as redirect targets. Lifted out so goconst stops
// flagging the duplicates across handlers.
const (
pathOverview = "/overview"
pathChangePassword = "/web/change-password"
pathLogin = "/web/login"
)
// basicAuthRealm is the value returned in the WWW-Authenticate
// header. Fixed string so scripted callers can match on it.
const basicAuthRealm = `Basic realm="rsmon-worker"`

10
internal/webapp/cteq.go Обычный файл
Просмотреть файл

@@ -0,0 +1,10 @@
package webapp
import "crypto/subtle"
// constantTimeEq is a thin wrapper around crypto/subtle.ConstantTimeCompare
// so the middleware file does not need an extra import for a single
// call.
func constantTimeEq(a, b string) int {
return subtle.ConstantTimeCompare([]byte(a), []byte(b))
}

18
internal/webapp/doc.go Обычный файл
Просмотреть файл

@@ -0,0 +1,18 @@
// Package webapp implements the local web UI for the distributed
// monitoring worker. See docs/distributed/worker-web-app.md.
//
// Phase 1 (MVP) implements:
//
// - Local-only auth (section 5.3): first-run password printed to
// the worker log, bcrypt-hashed in the local SQLite store, forced
// change on first login, session cookie with HTTP-only/Secure
// (loopback-aware)/SameSite=Strict.
// - Pages: overview, discovered apps (read-only), checks
// (read-only), notifications (read-only), logs (worker log only),
// settings (worker fields), updates.
// - Server status: /proc and sysfs only (no SMART, no docker).
// - Audit log with 7-day retention.
//
// Phase 2+ (OAuth, basic auth, compose management, public bind,
// docker socket, secret storage) is explicitly out of scope here.
package webapp

103
internal/webapp/handlers_apps.go Обычный файл
Просмотреть файл

@@ -0,0 +1,103 @@
package webapp
import "net/http"
// handleApps lists the inventory rows the most recent refresh loop
// persisted. Each row links to the detail view at /apps/:id, which
// Phase 1 implements as a single-process summary (comm, cmdline,
// cwd, ports, uptime).
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
apps := s.inventory.Snapshot()
data := appsPageData{
basePageData: s.newBasePage(r, "Discovered apps", sess),
Apps: apps,
}
if err := s.templates.Execute(w, "apps.html", data); err != nil {
s.deps.Logger.Printf("render apps: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleAppDetail renders the detail page for a single inventory
// row. Phase 1 has no grouping, so :id is the row index in the
// snapshot (matching the table id column).
func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
id := r.PathValue("id")
apps := s.inventory.Snapshot()
var found *DiscoveredApp
for i := range apps {
if idMatch(&apps[i], id, i) {
found = &apps[i]
break
}
}
if found == nil {
http.NotFound(w, r)
return
}
data := appDetailPageData{
basePageData: s.newBasePage(r, "App: "+found.Name, sess),
App: *found,
}
if err := s.templates.Execute(w, "app_detail.html", data); err != nil {
s.deps.Logger.Printf("render app detail: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// idMatch matches either by pid (when id is a positive integer) or
// by name (otherwise). Keeps URLs short and avoids leaking pids to
// browser history.
func idMatch(a *DiscoveredApp, id string, idx int) bool {
if a.Name == id {
return true
}
if id == pidOrIndex(a, idx) {
return true
}
return false
}
func pidOrIndex(a *DiscoveredApp, idx int) string {
if a.PID > 0 {
return itoa(a.PID)
}
return itoa(idx)
}
func itoa(n int) string {
const digits = "0123456789"
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = digits[n%10]
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
type appsPageData struct {
basePageData
Apps []DiscoveredApp
}
type appDetailPageData struct {
basePageData
App DiscoveredApp
}

390
internal/webapp/handlers_auth.go Обычный файл
Просмотреть файл

@@ -0,0 +1,390 @@
package webapp
import (
"fmt"
"net/http"
"strings"
"time"
)
// handleHealth returns 200 OK with a tiny body. Public endpoint so
// the operator's tooling (curl, monitoring) can probe the listener
// without going through the login form.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeNoStore(w)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ok")
}
// handleLoginForm renders the login page. If the operator is already
// logged in, they are redirected to /overview.
//
// When WORKER_LOGIN / WORKER_PASSWORD are configured, the login form
// renders a username field and the explanatory copy tells the
// operator to use the env-var credentials. Otherwise the form is the
// plain first-run password entry (no username).
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, err := s.resolveSession(r)
if err == nil && sess != nil {
redirectTo(w, r, pathOverview)
return
}
data := loginPageData{
basePageData: s.newBasePage(r, "RSMon worker login", nil),
Error: strings.TrimSpace(r.URL.Query().Get("error")),
NextURL: strings.TrimSpace(r.URL.Query().Get("next")),
BasicAuth: s.BasicAuthEnabled(),
}
if err := s.templates.Execute(w, "login.html", data); err != nil {
s.deps.Logger.Printf("render login: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleLoginSubmit validates the credentials and starts a session.
// On failure: 401 with the login page re-rendered and an error message.
//
// When basic auth is configured the form must supply BOTH a username
// matching WORKER_LOGIN and a password matching WORKER_PASSWORD. The
// per-machine bcrypt user is bypassed in that mode (so the operator
// can rotate the basic-auth password without touching the bcrypt
// store). Local-only mode keeps the original first-run password flow.
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
plain := r.FormValue("password")
if plain == "" {
s.renderLoginError(w, r, "password is required", r.FormValue("next"))
return
}
next := r.FormValue("next")
if s.basicAuthOK {
s.handleBasicAuthLogin(w, r, plain, next)
return
}
s.handleLocalLogin(w, r, plain, next)
}
// handleBasicAuthLogin verifies the form-submitted password against
// WORKER_PASSWORD. The username field is checked against
// WORKER_LOGIN and the comparison is constant-time.
func (s *Server) handleBasicAuthLogin(w http.ResponseWriter, r *http.Request, password, next string) {
login := strings.TrimSpace(r.FormValue("login"))
if login == "" {
s.renderLoginError(w, r, "username is required", next)
return
}
if subtleEqual(login, s.cfg.BasicAuthLogin) != 1 ||
subtleEqual(password, s.cfg.BasicAuthPassword) != 1 {
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeBasic,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLoginFail,
Target: auditTargetSelf,
})
s.renderLoginError(w, r, "invalid credentials", next)
return
}
// Mint a synthetic session backed by the local store but tagged
// with auth_mode=basic_auth so the audit log distinguishes the
// two paths. The bcrypt user is bypassed entirely.
if err := s.startSyntheticSession(w, r, "basic_auth"); err != nil {
s.deps.Logger.Printf("start synthetic session: %v", err)
http.Error(w, "session error", http.StatusInternalServerError)
return
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeBasic,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLogin,
Target: auditTargetSelf,
})
if next == "" || !strings.HasPrefix(next, "/") {
next = pathOverview
}
redirectTo(w, r, next)
}
// handleLocalLogin is the legacy first-run bcrypt path. Kept as a
// separate function so handleLoginSubmit reads top-down without
// branching inside one long handler.
func (s *Server) handleLocalLogin(w http.ResponseWriter, r *http.Request, plain, next string) {
user, err := s.store.GetUser(r.Context())
if err != nil {
// No user provisioned yet => login is impossible. Surface as
// a generic error so we do not leak the "no user" state to
// a brute-force attacker.
s.renderLoginError(w, r, "invalid credentials", next)
return
}
if err := VerifyPassword(user.BcryptHash, plain); err != nil {
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLoginFail,
Target: auditTargetSelf,
})
s.renderLoginError(w, r, "invalid credentials", next)
return
}
if err := s.startSession(w, r, user); err != nil {
s.deps.Logger.Printf("start session: %v", err)
http.Error(w, "session error", http.StatusInternalServerError)
return
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLogin,
Target: auditTargetSelf,
})
// The requires_change flag is recorded on the user row for
// future hardening (a per-install "force rotation" toggle), but
// the login flow does not bounce operators to /web/change-password
// on first login any more. Frictionless first-login is the
// current default; the change-password page is still reachable
// from /settings.
if next == "" || !strings.HasPrefix(next, "/") {
next = pathOverview
}
redirectTo(w, r, next)
}
// startSyntheticSession mints a session row that is NOT bound to the
// bcrypt user. Used by the basic-auth login flow. The user_id is
// re-used (the row in webapp_users still exists for the local-mode
// fallback) so foreign-key-free audit inserts keep working.
func (s *Server) startSyntheticSession(w http.ResponseWriter, r *http.Request, _ string) error {
user, err := s.store.GetUser(r.Context())
if err != nil {
// No bcrypt user yet: synthesize an anonymous row so the
// session has a user_id to point at. The local-only path
// will eventually upgrade this to a real user on first
// basic-auth-less login.
if cerr := s.store.EnsureAnonymousUser(r.Context()); cerr != nil {
return cerr
}
user, err = s.store.GetUser(r.Context())
if err != nil {
return err
}
}
return s.startSession(w, r, user)
}
// subtleEqual wraps crypto/subtle.ConstantTimeCompare so the
// handler body stays free of import noise. Returns 1 on match.
func subtleEqual(a, b string) int {
return constantTimeEq(a, b)
}
// handleLogout deletes the session row and clears the cookies. The
// logout endpoint is a POST so a stray GET cannot end a session via
// link prefetch.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := s.resolveSession(r)
clearSessionCookie(w, r)
if sess != nil {
_ = s.store.DeleteSession(r.Context(), sess.ID)
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionLogout,
Target: auditTargetSelf,
})
}
redirectTo(w, r, pathLogin)
}
// handleChangePasswordForm renders the change-password page.
func (s *Server) handleChangePasswordForm(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, ok := sessionFromContext(r.Context())
if !ok {
redirectTo(w, r, pathLogin)
return
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return
}
data := changePasswordPageData{
basePageData: s.newBasePage(r, "Change password", sess),
MinStrength: minPasswordLength,
}
if err := s.templates.Execute(w, "change_password.html", data); err != nil {
s.deps.Logger.Printf("render change-password: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleChangePasswordSubmit rotates the user's bcrypt hash and
// clears the requires_change flag. On success, the operator lands on
// /overview. On any failure, the change-password page re-renders
// with an error message.
func (s *Server) handleChangePasswordSubmit(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, ok := sessionFromContext(r.Context())
if !ok {
redirectTo(w, r, pathLogin)
return
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
current := r.FormValue("current_password")
next := r.FormValue("new_password")
confirm := r.FormValue("new_password_confirm")
user, err := s.store.GetUser(r.Context())
if err != nil {
http.Error(w, "no user", http.StatusInternalServerError)
return
}
if err := VerifyPassword(user.BcryptHash, current); err != nil {
s.renderChangePasswordError(w, r, sess, "current password is incorrect")
return
}
if !validPasswordStrength(next) {
s.renderChangePasswordError(w, r, sess, fmt.Sprintf("new password must be at least %d characters", minPasswordLength))
return
}
if next != confirm {
s.renderChangePasswordError(w, r, sess, "new password and confirmation do not match")
return
}
newHash, err := HashPassword(next)
if err != nil {
http.Error(w, "hash error", http.StatusInternalServerError)
return
}
if err := s.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
http.Error(w, "update error", http.StatusInternalServerError)
return
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: auditActionPassChange,
Target: fmt.Sprintf("user:%d", user.ID),
BeforeHash: user.BcryptHash,
AfterHash: newHash,
})
redirectTo(w, r, pathOverview)
}
// minPasswordLength matches the bcrypt minimum the worker enforces
// (bcrypt silently truncates after 72 bytes; the minimum is a UX
// floor so the operator does not pick "a").
const minPasswordLength = 8
func validPasswordStrength(p string) bool {
return len(p) >= minPasswordLength
}
// startSession creates a session row with a fresh id and CSRF token,
// persists it, and sets the cookies.
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *User) error {
id, err := newSessionID()
if err != nil {
return err
}
csrf, err := newSessionID()
if err != nil {
return err
}
now := time.Now().UTC()
sess := Session{
ID: id,
UserID: user.ID,
CSRFToken: csrf,
IP: clientIP(r),
UA: r.UserAgent(),
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(s.cfg.SessionAbs),
}
if err := s.store.CreateSession(r.Context(), &sess); err != nil {
return err
}
s.writeSessionCookie(w, r, &sess)
return nil
}
// renderLoginError renders the login page with an inline error
// message. We deliberately do NOT use http.StatusUnauthorized here;
// the status is 200 so an interactive operator gets the form back
// with the error visible, not a browser auth dialog.
func (s *Server) renderLoginError(w http.ResponseWriter, r *http.Request, msg, next string) {
data := loginPageData{
basePageData: s.newBasePage(r, "RSMon worker login", nil),
Error: msg,
NextURL: next,
BasicAuth: s.BasicAuthEnabled(),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := s.templates.Execute(w, "login.html", data); err != nil {
s.deps.Logger.Printf("render login error: %v", err)
}
}
// renderChangePasswordError renders the change-password form with an
// inline error. The CSRF token is reused from the current session so
// the operator does not have to reload to retry.
func (s *Server) renderChangePasswordError(w http.ResponseWriter, r *http.Request, sess *Session, msg string) {
data := changePasswordPageData{
basePageData: s.newBasePage(r, "Change password", sess),
Error: msg,
MinStrength: minPasswordLength,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := s.templates.Execute(w, "change_password.html", data); err != nil {
s.deps.Logger.Printf("render change-password error: %v", err)
}
}
// loginPageData is the input to the login.html template.
type loginPageData struct {
basePageData
Error string
NextURL string
BasicAuth bool // true when WORKER_LOGIN/WORKER_PASSWORD are configured; the form must show a username field
}
// changePasswordPageData is the input to the change_password.html
// template.
type changePasswordPageData struct {
basePageData
Error string
MinStrength int
}

84
internal/webapp/handlers_checks.go Обычный файл
Просмотреть файл

@@ -0,0 +1,84 @@
package webapp
import "net/http"
// handleChecks renders the worker's recent result rows. The
// runner's in-memory ring buffer supplies the data; Phase 1 reads
// from it directly with no extra caching.
//
// A "Run now" button is rendered on the page but stays disabled
// until the control plane accepts one-off check hints. The hint
// protocol is gated on the worker-notifier MVP plan
// (docs/plans/worker-notifier-mvp.md §N) and on
// docs/plans/separate-checks.md §11.6; once both are in place the
// RunNowEnabled flag flips to true and the handler reads
// s.deps.Runner.SubmitCheckHint(...) instead of the placeholder.
func (s *Server) handleChecks(w http.ResponseWriter, r *http.Request) {
s.renderRunnerPage(w, r, "Recent checks", "checks.html", func() any {
return checksPageData{
basePageData: s.newBasePage(r, "Recent checks", sessionFromContextOrEmpty(r)),
Rows: s.deps.Runner.RecentResults(50),
RunNowEnabled: false,
RunNowTooltip: "Run-now lands with the worker hint protocol (worker-notifier-mvp.md §N + separate-checks.md §11.6).",
}
})
}
type checksPageData struct {
basePageData
Rows []ResultRow
RunNowEnabled bool
RunNowTooltip string
}
// handleNotifications renders the worker's recent notification
// rows. Phase 1 only emits selfcheck alerts (email/telegram via the
// cached credentials), but the runner ring buffer is shape-stable
// for the main-app-issued notifications coming online in a later
// phase.
//
// A "Resend" button is rendered on the page but stays disabled
// until the worker resend protocol exists. Same gating as the
// "Run now" button on /checks.
func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request) {
s.renderRunnerPage(w, r, "Recent notifications", "notifications.html", func() any {
return notificationsPageData{
basePageData: s.newBasePage(r, "Recent notifications", sessionFromContextOrEmpty(r)),
Rows: s.deps.Runner.RecentNotifications(50),
ResendEnabled: false,
ResendTooltip: "Resend lands with the worker notification resend protocol (worker-notifier-mvp.md §N).",
}
})
}
type notificationsPageData struct {
basePageData
Rows []NotificationRow
ResendEnabled bool
ResendTooltip string
}
// renderRunnerPage is the small boilerplate-killer shared by
// handleChecks / handleNotifications / handleApps: write the
// no-store header, look up the session, build the page data via the
// caller-supplied closure, execute the template, and log + 500 on
// error. The closure receives no arguments because each handler
// already has its own copy of *Server and *http.Request in scope
// (this method is bound to *Server, so the closure captures them).
func (s *Server) renderRunnerPage(w http.ResponseWriter, _ *http.Request, logName, tmpl string, build func() any) {
writeNoStore(w)
if err := s.templates.Execute(w, tmpl, build()); err != nil {
s.deps.Logger.Printf("render %s: %v", logName, err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// sessionFromContextOrEmpty is a thin wrapper around
// sessionFromContext that returns a nil session instead of a bool,
// for handlers that pass the session straight into a page-data
// struct (the Session zero value is harmless for template
// rendering).
func sessionFromContextOrEmpty(r *http.Request) *Session {
sess, _ := sessionFromContext(r.Context())
return sess
}

136
internal/webapp/handlers_cluster.go Обычный файл
Просмотреть файл

@@ -0,0 +1,136 @@
package webapp
import (
"encoding/json"
"errors"
"net/http"
)
// clusterStatusResponse is the JSON the operator-facing
// /web/api/cluster/status endpoint returns. The shape is stable so the
// e2e shell script and any future frontend pages can pin against it.
//
// FSMConfigVersion / FSMOutboxLen / FSMPartition surface the FSM-side
// operator signals from plan section 6.1 (config_version, outbox
// length, partition_state) so a single GET tells the operator what
// config the cluster has adopted, whether the notification outbox is
// draining, and whether the cluster sees itself as partitioned.
type clusterStatusResponse struct {
SelfID string `json:"self_id"`
Role string `json:"role"`
Term uint64 `json:"term"`
LeaderID string `json:"leader_id"`
Voters []string `json:"voters"`
AppliedIndex uint64 `json:"applied_index"`
CommitIndex uint64 `json:"commit_index"`
FSMChecks int `json:"fsm_checks"`
FSMMembers int `json:"fsm_membership"`
FSMConfigVersion uint64 `json:"fsm_config_version"`
FSMOutboxLen int `json:"fsm_outbox_len"`
FSMPartition string `json:"fsm_partition"`
ClusterID string `json:"cluster_id"`
LocalAddr string `json:"local_addr"`
}
// handleClusterStatus serializes the current cluster state for the
// operator. Returns 503 if no cluster is attached; 200 otherwise.
//
// Admin-only: the worker webapp is single-tenant so the session
// middleware (requireSession) is the admin check. Cross-tenant
// protection is not required at this layer.
//
// The `r` parameter is unused but kept so the signature matches
// http.HandlerFunc (the route is registered via requireSession).
func (s *Server) handleClusterStatus(w http.ResponseWriter, _ *http.Request) {
writeNoStore(w)
if s.cluster == nil {
http.Error(w, "cluster not configured", http.StatusServiceUnavailable)
return
}
stats := s.cluster.Stats()
resp := clusterStatusResponse{
SelfID: stats.NodeID,
Role: stats.State,
Term: stats.Term,
LeaderID: stats.Leader,
Voters: stats.Voters,
AppliedIndex: stats.AppliedIndex,
CommitIndex: stats.LastIndex,
FSMChecks: stats.FSMChecks,
FSMMembers: stats.FSMMembers,
FSMConfigVersion: stats.FSMConfigVersion,
FSMOutboxLen: stats.FSMOutboxLen,
FSMPartition: stats.FSMPartition,
ClusterID: s.cluster.ClusterID(),
LocalAddr: s.cluster.LocalAddr(),
}
if resp.Voters == nil {
resp.Voters = []string{}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.deps.Logger.Printf("cluster status encode: %v", err)
}
}
// handleClusterApplyTestConfig applies a hardcoded config.adopt log
// entry to the cluster. It exists so the e2e script and any operator
// debugging session can verify FSM replication without having to wire
// up the real signed-config-adoption producer (which lives in a later
// phase).
//
// DEBUG: this endpoint is a placeholder for the real producer. It must
// be replaced (or removed) before any production deployment.
//
// The handler is gated behind Config.DebugClusterApply (env
// WORKER_CLUSTER_DEBUG_APPLY=true). When the flag is false the
// handler returns 404 — the route is still registered so the auth
// + CSRF paths are exercised in tests, but no real FSM entry is ever
// appended from a production webapp.
//
// TODO(worker-cluster-real-producer): remove the apply-test-config
// endpoint entirely once the signed-config-adoption producer ships.
func (s *Server) handleClusterApplyTestConfig(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
if !s.cfg.DebugClusterApply {
http.NotFound(w, r)
return
}
if s.cluster == nil {
http.Error(w, "cluster not configured", http.StatusServiceUnavailable)
return
}
if !s.requireCSRF(sessionFromContextOrFail(w, r), r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return
}
applied, err := s.cluster.ApplyTestConfig()
if err != nil {
s.deps.Logger.Printf("cluster apply test config: %v", err)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]uint64{"applied_index": applied}); err != nil {
s.deps.Logger.Printf("cluster apply encode: %v", err)
}
}
// sessionFromContextOrFail is a tiny adapter so requireCSRF can be
// called from this handler without leaking the middleware into the
// cluster package. If no session is attached (should not happen
// because requireSession already ran) we return a stub session with
// no CSRF token, which causes requireCSRF to refuse the request.
func sessionFromContextOrFail(_ http.ResponseWriter, r *http.Request) *Session {
sess, _ := sessionFromContext(r.Context())
if sess != nil {
return sess
}
return &Session{}
}
// ErrClusterNotConfigured is returned when a cluster-admin endpoint is
// hit on a server without a cluster attached.
var ErrClusterNotConfigured = errors.New("webapp: cluster not configured")

336
internal/webapp/handlers_cluster_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,336 @@
package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubCluster is a minimal ClusterView implementation used by the
// handler tests. It returns canned values so the JSON shape can be
// pinned without standing up a real raft group.
type stubCluster struct {
stats ClusterStats
applyIndex uint64
applyErr error
applyCalled int
applyMu sync.Mutex
clusterIDOut string
addrOut string
}
func (s *stubCluster) Stats() ClusterStats { return s.stats }
func (s *stubCluster) ApplyTestConfig() (uint64, error) {
s.applyMu.Lock()
defer s.applyMu.Unlock()
s.applyCalled++
return s.applyIndex, s.applyErr
}
func (s *stubCluster) ClusterID() string { return s.clusterIDOut }
func (s *stubCluster) LocalAddr() string { return s.addrOut }
// withClusterServer returns a test server whose ClusterView is the
// supplied stub. The first-run password path is also exercised so
// the session cookie is available for the cluster-endpoint probes.
// Returns the *httptest.Server, the underlying *Server, and the
// authenticated http.Client (cookie jar already populated).
func withClusterServer(t *testing.T, c ClusterView) (*httptest.Server, *Server, *http.Client) {
t.Helper()
srv := newTestServer(t, &stubRunner{id: "w-1"})
srv.SetCluster(c)
ts := newHTTPTestServer(t, srv)
client, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
return ts, srv, client
}
// TestClusterStatus_NotConfigured verifies the 503 path when no
// cluster subsystem is attached to the webapp.
func TestClusterStatus_NotConfigured(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode,
"cluster status must 503 when no cluster is attached")
}
// TestClusterStatus_HappyPath verifies the JSON shape of the
// /web/api/cluster/status response when a stub cluster is attached.
func TestClusterStatus_HappyPath(t *testing.T) {
stub := &stubCluster{
stats: ClusterStats{
NodeID: "worker1",
LocalAddr: "127.0.0.1:17401",
State: "Leader",
Leader: "worker1",
Term: 17,
AppliedIndex: 42,
LastIndex: 42,
NumPeers: 2,
Voters: []string{"worker1", "worker2"},
FSMChecks: 1,
FSMMembers: 2,
FSMConfigVersion: 7,
FSMOutboxLen: 3,
FSMPartition: "steady",
},
clusterIDOut: "worker1",
addrOut: "127.0.0.1:17401",
}
ts, _, c := withClusterServer(t, stub)
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type"))
body, _ := io.ReadAll(resp.Body)
var got clusterStatusResponse
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "worker1", got.SelfID)
assert.Equal(t, "Leader", got.Role)
assert.EqualValues(t, 17, got.Term)
assert.Equal(t, "worker1", got.LeaderID)
assert.Equal(t, []string{"worker1", "worker2"}, got.Voters)
assert.EqualValues(t, 42, got.AppliedIndex)
assert.EqualValues(t, 42, got.CommitIndex)
assert.Equal(t, 1, got.FSMChecks)
assert.Equal(t, 2, got.FSMMembers)
assert.EqualValues(t, 7, got.FSMConfigVersion)
assert.Equal(t, 3, got.FSMOutboxLen)
assert.Equal(t, "steady", got.FSMPartition)
assert.Equal(t, "worker1", got.ClusterID)
assert.Equal(t, "127.0.0.1:17401", got.LocalAddr)
}
// TestClusterStatus_FSMFieldsZeroByDefault pins the FSM-side fields
// to the zero value when the stub cluster does not set them. Guards
// against a future refactor accidentally widening the wire format
// with a non-zero default for a fresh cluster.
func TestClusterStatus_FSMFieldsZeroByDefault(t *testing.T) {
stub := &stubCluster{
stats: ClusterStats{
NodeID: "worker1", State: "Follower", Leader: "worker2",
Voters: []string{"worker1", "worker2"},
},
clusterIDOut: "worker1",
addrOut: "127.0.0.1:17401",
}
ts, _, c := withClusterServer(t, stub)
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
body, _ := io.ReadAll(resp.Body)
var got clusterStatusResponse
require.NoError(t, json.Unmarshal(body, &got))
assert.EqualValues(t, 0, got.FSMConfigVersion, "fresh cluster must report config_version 0")
assert.Equal(t, 0, got.FSMOutboxLen, "fresh cluster must report outbox_len 0")
assert.Equal(t, "", got.FSMPartition, "fresh cluster must report partition empty/zero")
}
// TestClusterStatus_RequiresSession ensures the cluster admin
// endpoint is gated by the session middleware.
func TestClusterStatus_RequiresSession(t *testing.T) {
stub := &stubCluster{}
srv := newTestServer(t, &stubRunner{id: "w-1"})
srv.SetCluster(stub)
ts := newHTTPTestServer(t, srv)
// No session cookie — should redirect to login.
client := httpClient()
resp, err := client.Get(ts.URL + "/web/api/cluster/status")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusFound, resp.StatusCode,
"cluster status must redirect to login without session")
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
}
// TestClusterApplyTestConfig_NotConfigured verifies the 404 path
// when WORKER_CLUSTER_DEBUG_APPLY is false (the production default)
// and no cluster is attached. The handler must refuse before it
// even checks the cluster because the debug flag is off.
func TestClusterApplyTestConfig_NotConfigured(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
require.False(t, srv.cfg.DebugClusterApply, "default config must leave the debug apply flag off")
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{})
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp.StatusCode,
"debug apply must be invisible (404) when WORKER_CLUSTER_DEBUG_APPLY is unset")
}
// TestClusterApplyTestConfig_DebugOffReturns404 verifies that even
// with a cluster attached the apply endpoint stays 404 unless the
// debug flag is on. The flag, not cluster presence, gates the
// endpoint.
func TestClusterApplyTestConfig_DebugOffReturns404(t *testing.T) {
stub := &stubCluster{applyIndex: 42}
ts, _, c := withClusterServer(t, stub)
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(bodyBytes))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
assert.Equal(t, 0, stub.applyCalled,
"ApplyTestConfig must never be called when the debug flag is off")
}
// TestClusterApplyTestConfig_HappyPath verifies that the apply-test-
// config endpoint returns the applied index when the cluster
// subsystem accepts the entry. CSRF is checked. The DebugClusterApply
// flag must be on for the endpoint to be reachable.
func TestClusterApplyTestConfig_HappyPath(t *testing.T) {
stub := &stubCluster{
stats: ClusterStats{
NodeID: "worker1", State: "Leader", Leader: "worker1",
Voters: []string{"worker1"},
},
applyIndex: 13,
clusterIDOut: "worker1",
addrOut: "127.0.0.1:17401",
}
ts, srv, c := withClusterServer(t, stub)
srv.cfg.DebugClusterApply = true
// Fetch CSRF token from any authenticated page.
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(bodyBytes))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, _ = io.ReadAll(resp.Body)
var got map[string]uint64
require.NoError(t, json.Unmarshal(bodyBytes, &got))
assert.EqualValues(t, 13, got["applied_index"])
assert.Equal(t, 1, stub.applyCalled)
}
// TestClusterApplyTestConfig_PropagatesError verifies that errors
// from the cluster subsystem surface as 502 Bad Gateway. Debug flag
// must be on.
func TestClusterApplyTestConfig_PropagatesError(t *testing.T) {
stub := &stubCluster{
applyErr: errStubApply,
clusterIDOut: "worker1",
addrOut: "127.0.0.1:17401",
}
ts, srv, c := withClusterServer(t, stub)
srv.cfg.DebugClusterApply = true
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(bodyBytes))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusBadGateway, resp.StatusCode)
}
// TestClusterApplyTestConfig_RequiresCSRF ensures the apply-test-
// config POST is refused without a CSRF token. Debug flag must be
// on for the endpoint to be reachable; without the flag it returns
// 404 (priority over CSRF check).
func TestClusterApplyTestConfig_RequiresCSRF(t *testing.T) {
stub := &stubCluster{applyIndex: 99}
ts, srv, c := withClusterServer(t, stub)
srv.cfg.DebugClusterApply = true
resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{})
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusForbidden, resp.StatusCode,
"apply-test-config without CSRF must be 403")
assert.Equal(t, 0, stub.applyCalled, "ApplyTestConfig must not be called without CSRF")
}
// errStubApply is a sentinel error used by the apply-error test.
var errStubApply = errApply("worker not leader")
type errApply string
func (e errApply) Error() string { return string(e) }
// TestSetClusterDetaches verifies SetCluster(nil) returns the server
// to the no-cluster-attached state (503 from the endpoints).
func TestSetClusterDetaches(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
stub := &stubCluster{applyIndex: 7}
srv.SetCluster(stub)
require.NotNil(t, srv.Cluster())
srv.SetCluster(nil)
require.Nil(t, srv.Cluster())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
// _ = context.Background and time.Time keep the linter quiet about
// unused imports if the file shrinks.
var (
_ = context.Background
_ = time.Now
_ = url.Parse
)

49
internal/webapp/handlers_logs.go Обычный файл
Просмотреть файл

@@ -0,0 +1,49 @@
package webapp
import (
"net/http"
"strconv"
)
// handleLogs tails the in-memory worker log buffer. The handler
// reads ?tail=200|500|1000|5000 (default 200) per section 6.6.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
tail := parseTail(r.URL.Query().Get("tail"))
lines := s.logBuffer.Tail(tail)
data := logsPageData{
basePageData: s.newBasePage(r, "Worker logs", sess),
Tail: tail,
Lines: lines,
BufferSize: s.logBuffer.Size(),
BufferCap: s.logBuffer.Cap(),
}
if err := s.templates.Execute(w, "logs.html", data); err != nil {
s.deps.Logger.Printf("render logs: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// parseTail clamps the requested tail count to one of the
// doc-prescribed buckets (200/500/1000/5000) and falls back to 200.
func parseTail(raw string) int {
n, err := strconv.Atoi(raw)
if err != nil {
return 200
}
for _, allowed := range []int{200, 500, 1000, 5000} {
if n == allowed {
return allowed
}
}
return 200
}
type logsPageData struct {
basePageData
Tail int
Lines []string
BufferSize int
BufferCap int
}

84
internal/webapp/handlers_overview.go Обычный файл
Просмотреть файл

@@ -0,0 +1,84 @@
package webapp
import (
"net/http"
"strings"
"time"
)
// handleOverview is the landing page after login. Phase 1 shows the
// worker status, recent results counts, and a tail of the worker
// log buffer. The data shape will grow in later phases as the
// heartbeat inventory and 24h result counts come online.
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
snap, snapAt := s.metrics.Last()
data := overviewPageData{
basePageData: s.newBasePage(r, "Overview", sess),
WorkerID: workerIDOrDash(s.deps.Runner),
RegionCode: regionOrDash(s.deps.Runner),
WorkerState: workerState(s.deps.Runner, s.deps.StartedAt),
LastAckAt: lastAckOrZero(s.deps.Runner),
StartedAt: s.deps.StartedAt,
Snapshot: snap,
SnapshotAt: snapAt,
DiscoveredCount: len(s.inventory.Snapshot()),
ResultCount: len(s.deps.Runner.RecentResults(1000)),
NotifCount: len(s.deps.Runner.RecentNotifications(1000)),
LogTail: s.logBuffer.Tail(20),
}
if err := s.templates.Execute(w, "overview.html", data); err != nil {
s.deps.Logger.Printf("render overview: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func workerIDOrDash(v WorkerView) string {
if v == nil {
return "—"
}
return v.WorkerID()
}
func regionOrDash(v WorkerView) string {
if v == nil {
return "—"
}
return v.RegionCode()
}
func workerState(v WorkerView, startedAt time.Time) string {
if v == nil {
return "—"
}
if last := v.LastHeartbeatAck(); !last.IsZero() && last.After(startedAt) {
return "connected"
}
return "starting"
}
func lastAckOrZero(v WorkerView) time.Time {
if v == nil {
return time.Time{}
}
return v.LastHeartbeatAck()
}
// overviewPageData is the data backing overview.html.
type overviewPageData struct {
basePageData
WorkerID string
RegionCode string
WorkerState string
LastAckAt time.Time
StartedAt time.Time
Snapshot Snapshot
SnapshotAt time.Time
DiscoveredCount int
ResultCount int
NotifCount int
LogTail []string
}
var _ = strings.TrimSpace

59
internal/webapp/handlers_peer.go Обычный файл
Просмотреть файл

@@ -0,0 +1,59 @@
package webapp
import (
"encoding/json"
"net/http"
"time"
)
// peerStatusResponse is the JSON returned at GET /api/peer/status.
// The shape matches distworker.PeerStatus exactly so the peer
// poller on the other end can decode it without a separate
// type. Kept here as a local view to keep the webapp package free
// of any concrete dependency on the distworker peer types; the
// fields are JSON-stable.
//
// Up == nil means "no probe has run yet" so peer workers that
// query this endpoint right after boot do not get a misleading
// "true" verdict while the selfcheck is still spinning up.
type peerStatusResponse struct {
WorkerID string `json:"worker_id"`
Up *bool `json:"up"`
ObservedAt *time.Time `json:"observed_at"`
}
// handlePeerStatus serves the most recent local selfcheck verdict
// to peer workers over HTTP. The endpoint is intentionally
// unauthenticated for now: a worker with basic auth configured
// (WORKER_LOGIN / WORKER_PASSWORD) still exposes the verdict
// because the path lives outside the /web/api/* prefix that the
// basic-auth middleware gates. This matches the
// "keep simple for local trusted workers if no peer auth exists
// yet" directive in
// docs/distributed/worker-to-worker-raft.md (slice 1).
//
// The endpoint never returns a 5xx: a runner that has not yet
// produced a verdict simply returns {"up": null, ...} so the
// peer poller can keep the slot in cache as "unknown" instead of
// treating the absence as a hard failure.
func (s *Server) handlePeerStatus(w http.ResponseWriter, _ *http.Request) {
resp := peerStatusResponse{}
if s.deps.Runner != nil {
up, at := s.deps.Runner.MasterStatus()
resp.WorkerID = s.deps.Runner.WorkerID()
resp.Up = up
if !at.IsZero() {
// Copy the timestamp so callers see a value
// (json omitempty is not used on purpose: an
// explicit zero time communicates "no probe" and
// an RFC3339 string communicates "probed at").
atCopy := at.UTC()
resp.ObservedAt = &atCopy
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.deps.Logger.Printf("peer status encode: %v", err)
}
}

94
internal/webapp/handlers_peer_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,94 @@
package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestHandlePeerStatus_NoProbe covers the first-boot window: the
// runner has not yet produced a selfcheck verdict, so the endpoint
// must return a valid JSON body with up=null and observed_at=null.
func TestHandlePeerStatus_NoProbe(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/api/peer/status")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var got peerStatusResponse
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "w-1", got.WorkerID)
assert.Nil(t, got.Up, "up must be nil before the first probe")
assert.Nil(t, got.ObservedAt, "observed_at must be nil before the first probe")
}
// TestHandlePeerStatus_UpAndDown covers the post-probe window: the
// endpoint must reflect the most recent selfcheck verdict.
func TestHandlePeerStatus_UpAndDown(t *testing.T) {
up := true
at := time.Date(2026, 7, 10, 16, 0, 0, 0, time.UTC)
srv := newTestServer(t, &stubRunner{id: "w-2", masterUp: &up, masterAt: at})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/api/peer/status")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var got peerStatusResponse
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "w-2", got.WorkerID)
require.NotNil(t, got.Up)
assert.True(t, *got.Up)
require.NotNil(t, got.ObservedAt)
assert.Equal(t, at, *got.ObservedAt)
// Flip to down and re-fetch; the handler reads from the live
// stub view, not a cached copy, so the new verdict must surface.
down := false
runner := srv.deps.Runner.(*stubRunner) //nolint:forcetypeassert // helper under test
runner.masterUp = &down
runner.masterAt = at.Add(time.Minute)
resp2, err := http.Get(ts.URL + "/api/peer/status")
require.NoError(t, err)
defer resp2.Body.Close() //nolint:errcheck
var got2 peerStatusResponse
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&got2))
require.NotNil(t, got2.Up)
assert.False(t, *got2.Up)
require.NotNil(t, got2.ObservedAt)
assert.Equal(t, at.Add(time.Minute), *got2.ObservedAt)
}
// TestHandlePeerStatus_DoesNotRequireSession confirms the slice-1
// design: the endpoint sits outside /web/api/* so the basic-auth
// middleware does not intercept it and no session cookie is needed.
// The handler should still answer 200 OK when the runner is wired.
func TestHandlePeerStatus_DoesNotRequireSession(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-3"})
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/api/peer/status", nil)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode,
"peer status must be reachable without a session cookie (slice 1)")
}

100
internal/webapp/handlers_settings.go Обычный файл
Просмотреть файл

@@ -0,0 +1,100 @@
package webapp
import (
"net/http"
"time"
)
// handleSettings renders the worker fields (worker id, region,
// capabilities, version, last heartbeat ack, last token rotation).
// The token is masked; rotation is a POST to
// /settings/rotate-token (see handleRotateToken).
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
token := ""
if s.deps.Runner != nil {
token = s.deps.Runner.Token()
}
rotatedAt := time.Time{}
if s.deps.Runner != nil {
rotatedAt = s.deps.Runner.TokenRotatedAt()
}
data := settingsPageData{
basePageData: s.newBasePage(r, "Settings", sess),
WorkerID: workerIDOrDash(s.deps.Runner),
RegionCode: regionOrDash(s.deps.Runner),
WorkerVersion: workerVersionOrDash(s.deps.Runner),
Capabilities: capabilitiesOrEmpty(s.deps.Runner),
LastAckAt: lastAckOrZero(s.deps.Runner),
TokenMasked: MaskToken(token),
TokenRotatedAt: rotatedAt,
}
if err := s.templates.Execute(w, "settings.html", data); err != nil {
s.deps.Logger.Printf("render settings: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func workerVersionOrDash(v WorkerView) string {
if v == nil {
return "—"
}
return v.WorkerVersion()
}
func capabilitiesOrEmpty(v WorkerView) []string {
if v == nil {
return nil
}
return v.WorkerCapabilities()
}
type settingsPageData struct {
basePageData
WorkerID string
RegionCode string
WorkerVersion string
Capabilities []string
LastAckAt time.Time
TokenMasked string
TokenRotatedAt time.Time
}
// handleRotateToken calls the worker-defined rotator (if any) to
// issue a fresh token via the main app's API and update the in-
// memory runner config. On failure, return 502 and keep the old
// token (the doc's Phase 1 contract).
func (s *Server) handleRotateToken(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, ok := sessionFromContext(r.Context())
if !ok {
redirectTo(w, r, "/web/login")
return
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return
}
if s.deps.TokenRotator == nil {
http.Error(w, "token rotation is not configured", http.StatusNotImplemented)
return
}
newToken, err := s.deps.TokenRotator(r.Context())
if err != nil {
s.deps.Logger.Printf("token rotation: %v", err)
http.Error(w, "rotation failed: "+err.Error(), http.StatusBadGateway)
return
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: auditAuthModeLocal,
IP: clientIP(r),
UA: r.UserAgent(),
Action: "token_rotation",
Target: auditTargetSelf,
})
_ = newToken // the rotator updates the runner; the page just confirms success.
redirectTo(w, r, "/settings")
}

38
internal/webapp/handlers_status.go Обычный файл
Просмотреть файл

@@ -0,0 +1,38 @@
package webapp
import (
"net/http"
"time"
)
// handleStatus renders the host metrics snapshot. Phase 1 reads
// only /proc and statfs; the lsblk / smartctl / sensors
// integrations are deferred to the worker webapp expansion tracked
// in docs/distributed/worker-web-app.md §6.7 (out of band; not
// shipped in this repo). The current Snapshot struct accommodates
// the extra sections if/when those are wired in.
//
// TODO(worker-web-app §6.7): surface lsblk / smartctl / sensors /
// docker info on this page once the worker process is allowed to
// invoke those CLI tools. The cli tools are not bundled with the
// worker binary; install them separately on the host if needed.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
snap, snapAt := s.metrics.Last()
data := statusPageData{
basePageData: s.newBasePage(r, "Server status", sess),
Snapshot: snap,
SnapshotAt: snapAt,
}
if err := s.templates.Execute(w, "status.html", data); err != nil {
s.deps.Logger.Printf("render status: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
type statusPageData struct {
basePageData
Snapshot Snapshot
SnapshotAt time.Time
}

129
internal/webapp/handlers_updates.go Обычный файл
Просмотреть файл

@@ -0,0 +1,129 @@
package webapp
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
// placeholderLatestVersion is the fallback shown on the /updates
// page when Config.ReleaseURL is empty or the poll fails. Phase 1
// uses it permanently; once Config.ReleaseURL is set the handler
// replaces it with the polled tag_name (GitHub release JSON shape).
const placeholderLatestVersion = "v1 (dev)"
// defaultReleasePollTimeout bounds the time a single release-server
// HTTP fetch is allowed to take. 3 seconds keeps the page render
// fast; a slow upstream just shows the placeholder.
const defaultReleasePollTimeout = 3 * time.Second
// releaseCacheTTL bounds how often the worker re-fetches the
// release URL. One hour is short enough that a fresh release shows
// up reasonably quickly, long enough that the page never hammers the
// upstream.
const releaseCacheTTL = 1 * time.Hour
// releasePoller holds the cached release-server response. A single
// instance lives on the Server (one per process) so concurrent
// /updates hits share the same cache entry.
type releasePoller struct {
mu sync.RWMutex
url string
cached string
cachedAt time.Time
}
// latest returns the cached value if it is fresh, otherwise it
// fetches the URL, parses {"tag_name":"..."} from the response and
// caches the result. Errors fall back to placeholderLatestVersion
// without touching the cache, so a transient outage does not poison
// the next successful poll.
func (p *releasePoller) latest(ctx context.Context, httpClient *http.Client) string {
if p == nil || p.url == "" {
return placeholderLatestVersion
}
p.mu.RLock()
if !p.cachedAt.IsZero() && time.Since(p.cachedAt) < releaseCacheTTL && p.cached != "" {
out := p.cached
p.mu.RUnlock()
return out
}
p.mu.RUnlock()
client := httpClient
if client == nil {
client = &http.Client{Timeout: defaultReleasePollTimeout}
}
fetchCtx, cancel := context.WithTimeout(ctx, defaultReleasePollTimeout)
defer cancel()
req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, p.url, http.NoBody)
if err != nil {
return placeholderLatestVersion
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return placeholderLatestVersion
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return placeholderLatestVersion
}
var body struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.TagName == "" {
return placeholderLatestVersion
}
p.mu.Lock()
p.cached = body.TagName
p.cachedAt = time.Now()
p.mu.Unlock()
return body.TagName
}
// setURL configures the poller with a new release URL and resets
// the cache. Called from New() when Config.ReleaseURL is set.
func (p *releasePoller) setURL(u string) {
if p == nil {
return
}
p.mu.Lock()
p.url = u
p.cached = ""
p.cachedAt = time.Time{}
p.mu.Unlock()
}
// handleUpdates renders the updates page. The "pull and restart"
// button stays disabled until sudo / docker socket access lands
// (gated on the Phase 3 Docker management work). The version
// comparison above it IS real: it polls Config.ReleaseURL (env
// WORKER_RELEASE_URL) and falls back to placeholderLatestVersion on
// any network or parse error.
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
data := updatesPageData{
basePageData: s.newBasePage(r, "Updates", sess),
CurrentVersion: workerVersionOrDash(s.deps.Runner),
LatestKnown: s.releasePoller.latest(r.Context(), s.deps.ReleaseHTTPClient),
PullEnabled: false,
PullTooltip: "Pull and restart lands with Docker management (sudo / docker socket required).",
}
if err := s.templates.Execute(w, "updates.html", data); err != nil {
s.deps.Logger.Printf("render updates: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
type updatesPageData struct {
basePageData
CurrentVersion string
LatestKnown string
PullEnabled bool
PullTooltip string
}

272
internal/webapp/handlers_updates_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,272 @@
package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubReleaseServer returns a httptest.Server whose handler serves
// the supplied tag_name as a GitHub-style JSON body. The close func
// is returned alongside so callers can defer shutdown.
func stubReleaseServer(t *testing.T, tagName string, status int) (*httptest.Server, func()) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "application/json", r.Header.Get("Accept"))
if status != http.StatusOK {
w.WriteHeader(status)
return
}
w.Header().Set("Content-Type", "application/json")
body, _ := json.Marshal(map[string]string{"tag_name": tagName})
_, _ = w.Write(body)
}))
return ts, ts.Close
}
// TestReleasePoller_NoURLReturnsPlaceholder pins the
// no-URL-is-configured fallback. The handler must not hit the
// network when Config.ReleaseURL is empty.
func TestReleasePoller_NoURLReturnsPlaceholder(t *testing.T) {
p := &releasePoller{}
got := p.latest(context.Background(), nil)
assert.Equal(t, placeholderLatestVersion, got)
}
// TestReleasePoller_SuccessCaches verifies the happy path: the
// first call hits the URL, subsequent calls within the TTL come
// from the cache.
func TestReleasePoller_SuccessCaches(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "v2.7.1", http.StatusOK)
defer cleanup()
p := &releasePoller{}
p.setURL(ts.URL)
got := p.latest(context.Background(), ts.Client())
assert.Equal(t, "v2.7.1", got)
// Second call: cache hit. Replace the upstream with one that
// would error; the cached value must still come back.
p.url = "http://127.0.0.1:1/never-reachable"
got = p.latest(context.Background(), ts.Client())
assert.Equal(t, "v2.7.1", got, "cached value must survive upstream failures inside the TTL window")
}
// TestReleasePoller_Non2xxReturnsPlaceholder ensures a 5xx upstream
// does not poison the cache (placeholder shown, cache untouched).
func TestReleasePoller_Non2xxReturnsPlaceholder(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "ignored", http.StatusInternalServerError)
defer cleanup()
p := &releasePoller{}
p.setURL(ts.URL)
got := p.latest(context.Background(), ts.Client())
assert.Equal(t, placeholderLatestVersion, got)
// Confirm cache was not touched: a fresh request to a working
// upstream must produce the placeholder if the broken one was
// recorded. We use a different working upstream here.
ts2, cleanup2 := stubReleaseServer(t, "v9.9.9", http.StatusOK)
defer cleanup2()
p.setURL(ts2.URL)
got = p.latest(context.Background(), ts2.Client())
assert.Equal(t, "v9.9.9", got)
}
// TestReleasePoller_BadJSONReturnsPlaceholder verifies that a 200
// with a missing tag_name falls back to the placeholder.
func TestReleasePoller_BadJSONReturnsPlaceholder(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name": "no tag here"}`))
}))
defer ts.Close()
p := &releasePoller{}
p.setURL(ts.URL)
got := p.latest(context.Background(), ts.Client())
assert.Equal(t, placeholderLatestVersion, got)
}
// TestReleasePoller_TimeoutReturnsPlaceholder confirms a slow
// upstream degrades to the placeholder without exceeding the
// per-call timeout budget.
func TestReleasePoller_TimeoutReturnsPlaceholder(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * defaultReleasePollTimeout)
_, _ = io.WriteString(w, `{"tag_name":"too-late"}`)
}))
defer ts.Close()
p := &releasePoller{}
p.setURL(ts.URL)
start := time.Now()
got := p.latest(context.Background(), &http.Client{Timeout: defaultReleasePollTimeout})
elapsed := time.Since(start)
assert.Equal(t, placeholderLatestVersion, got)
assert.Less(t, elapsed, 2*defaultReleasePollTimeout,
"timeout must fire before the slow upstream replies")
}
// TestReleasePoller_TTLExpiry verifies that after releaseCacheTTL
// the poller re-fetches the URL. We can't wait an hour in a unit
// test, so we reset the cachedAt directly to a stale time and
// confirm the next call refetches.
func TestReleasePoller_TTLExpiry(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "v3.0.0", http.StatusOK)
defer cleanup()
p := &releasePoller{}
p.setURL(ts.URL)
// Prime the cache.
got := p.latest(context.Background(), ts.Client())
require.Equal(t, "v3.0.0", got)
// Force the cachedAt into the past.
p.mu.Lock()
p.cachedAt = time.Now().Add(-2 * releaseCacheTTL)
p.mu.Unlock()
// Change the upstream to a new tag — must be observed.
ts2, cleanup2 := stubReleaseServer(t, "v3.0.1", http.StatusOK)
defer cleanup2()
p.setURL(ts2.URL)
got = p.latest(context.Background(), ts2.Client())
assert.Equal(t, "v3.0.1", got, "stale cache must not block a fresh fetch after setURL reset")
}
// TestUpdatesPage_ShowsPlaceholder verifies that without
// Config.ReleaseURL the page renders the placeholder string in
// the "Latest known" cell.
func TestUpdatesPage_ShowsPlaceholder(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
require.Empty(t, srv.cfg.ReleaseURL, "test fixture must not pre-set ReleaseURL")
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/updates")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, placeholderLatestVersion,
"placeholder version must appear when no release URL is configured")
assert.Contains(t, page, "WORKER_RELEASE_URL",
"placeholder copy must mention the env var that turns on real polls")
}
// TestUpdatesPage_RunsPollWithReleaseURL verifies that with
// Config.ReleaseURL set, the page renders the tag_name from the
// upstream release server.
func TestUpdatesPage_RunsPollWithReleaseURL(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "v9.9.9", http.StatusOK)
defer cleanup()
srv := newTestServer(t, &stubRunner{id: "w-1"})
srv.cfg.ReleaseURL = ts.URL
srv.releasePoller.setURL(ts.URL)
hts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, hts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(hts.URL + "/updates")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "v9.9.9",
"page must surface the polled tag_name when WORKER_RELEASE_URL is configured")
}
// TestChecksPage_RunNowDisabledButton verifies that the "Run now"
// button is rendered and disabled, with the tooltip explaining the
// gating.
func TestChecksPage_RunNowDisabledButton(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/checks")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, "Run now",
"the Run now button must appear on the checks page")
assert.Contains(t, page, "disabled",
"the Run now button must be disabled in Phase 1")
assert.Contains(t, page, "worker-notifier-mvp",
"tooltip must cite the worker-notifier MVP plan that owns the hint protocol")
}
// TestNotificationsPage_ResendDisabledButton mirrors the checks
// page test for the resend button on /notifications.
func TestNotificationsPage_ResendDisabledButton(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/notifications")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, "Resend")
assert.Contains(t, page, "disabled")
assert.Contains(t, page, "worker-notifier-mvp")
}
// TestAppsPage_ReferencesInventoryPlan ensures the copy on
// /apps points operators at the deploymentd-driven inventory
// surface that this PR's plan docs describe.
func TestAppsPage_ReferencesInventoryPlan(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/apps")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, "deploymentd",
"apps page must mention deploymentd so operators know where Docker Compose discoveries land")
assert.Contains(t, page, "inventory-management.md",
"apps page must cite the inventory-management plan doc")
}
// _ = url.Values and strings.Builder keep imports used if the file
// shrinks in future refactors; they document the surface without
// affecting compilation.
var (
_ = url.Values{}
_ = strings.Builder{}
)

446
internal/webapp/inventory.go Обычный файл
Просмотреть файл

@@ -0,0 +1,446 @@
package webapp
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// inventoryRefreshInterval matches the 60s cadence called out in
// docs/distributed/worker-web-app.md section 7 ("rebuilt every 60s").
const inventoryRefreshInterval = 60 * time.Second
// procMount is the directory the inventory walker reads /proc
// entries from. Defaults to /proc on a normal host. Tests override
// it via SetProcRoot.
var procMount = "/proc"
// SetProcRoot overrides the /proc mount for tests. It must be called
// before any Inventory goroutine starts.
func SetProcRoot(path string) {
if path == "" {
procMount = "/proc"
return
}
procMount = path
}
// procInfo holds the per-process info the inventory walker reads
// from /proc/<pid>. Defined at file scope so readProcInfo can return
// it by value.
type procInfo struct {
pid int
comm string
cmdline string
cwd string
startTS int64
}
// ProcRoot returns the currently configured /proc mount.
func ProcRoot() string { return procMount }
// Inventory owns the periodic /proc -> sqlite refresh loop and
// exposes a Snapshot for the discovered-apps handler.
type Inventory struct {
store *Store
log *log.Logger
mu sync.RWMutex
snapshot []DiscoveredApp
stopCh chan struct{}
stopWG sync.WaitGroup
started bool
}
// DiscoveredApp is the shape we render on /apps. It is JSON-encodable
// so the cache can stash a blob for later drill-in rendering.
type DiscoveredApp struct {
Name string `json:"name"`
Source string `json:"source"` // inventorySourceProcess in Phase 1
PID int `json:"pid"`
Ports []string `json:"ports"` // "7401/tcp", "127.0.0.1:5432"
StartTS int64 `json:"start_ts"` // unix seconds
LastSeen time.Time `json:"last_seen"`
Cmdline string `json:"cmdline"`
CWD string `json:"cwd"`
}
// NewInventory returns an Inventory bound to the given store. The
// refresh loop does NOT start until Start is called.
func NewInventory(store *Store, logger *log.Logger) *Inventory {
if logger == nil {
logger = log.New(os.Stderr, "webapp-inventory: ", log.LstdFlags)
}
return &Inventory{
store: store,
log: logger,
stopCh: make(chan struct{}),
}
}
// Start launches the background refresh loop. Returns immediately;
// callers must call Stop for clean shutdown.
func (i *Inventory) Start(ctx context.Context) {
i.mu.Lock()
if i.started {
i.mu.Unlock()
return
}
i.started = true
i.mu.Unlock()
i.stopWG.Add(1)
go i.loop(ctx)
}
// Stop cancels the refresh loop and waits for it to exit.
func (i *Inventory) Stop() {
i.mu.Lock()
if !i.started {
i.mu.Unlock()
return
}
select {
case <-i.stopCh:
// already closed
default:
close(i.stopCh)
}
i.mu.Unlock()
i.stopWG.Wait()
}
// Snapshot returns the most recent inventory. Always safe to call
// (returns an empty slice if the first refresh has not completed).
func (i *Inventory) Snapshot() []DiscoveredApp {
i.mu.RLock()
defer i.mu.RUnlock()
out := make([]DiscoveredApp, len(i.snapshot))
copy(out, i.snapshot)
return out
}
func (i *Inventory) loop(ctx context.Context) {
defer i.stopWG.Done()
i.refresh(ctx)
ticker := time.NewTicker(inventoryRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-i.stopCh:
return
case <-ticker.C:
i.refresh(ctx)
}
}
}
func (i *Inventory) refresh(ctx context.Context) {
apps, err := ScanProcApps(ProcRoot())
if err != nil {
i.log.Printf("inventory refresh: %v", err)
return
}
now := time.Now().UTC()
rows := make([]App, 0, len(apps))
for i := range apps {
a := &apps[i]
lastSeen := now
if !a.LastSeen.IsZero() {
lastSeen = a.LastSeen
}
rows = append(rows, App{
Name: a.Name,
Source: a.Source,
PID: a.PID,
Ports: strings.Join(a.Ports, ","),
StartTS: a.StartTS,
LastSeen: lastSeen,
JSONBlob: a.Cmdline, // minimal JSON for now; full struct kept in Snapshot()
})
}
if err := i.store.ReplaceApps(ctx, rows); err != nil {
i.log.Printf("inventory persist: %v", err)
return
}
i.mu.Lock()
i.snapshot = apps
i.mu.Unlock()
}
// ScanProcApps walks the /proc mount and returns one DiscoveredApp
// per process. It excludes kernel threads (comm == "") and is the
// single source of truth for the inventory refresh.
//
// The grouping rule from section 7.1 ("processes sharing a cwd and
// started within 5 seconds of each other are one app") is applied
// by CollideByCWD before returning. Single processes are apps.
//
// Docker / Compose / systemd discovery on top of this per-process
// list lands with the deploymentd integration in
// docs/plans/inventory-management.md M1: RSMon's
// /api/v1/inventory/deploymentd/receive/docker endpoint will upsert
// Site + Deployment rows, and the worker webapp's `/apps` page will
// show those rows side-by-side with the /proc-derived processes.
// Phase 1 ships process discovery only.
func ScanProcApps(root string) ([]DiscoveredApp, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, fmt.Errorf("read %s: %w", root, err)
}
// Index inodes -> pid via /proc/<pid>/fd. We do this once at the
// top of the scan so port resolution can reuse it.
inodeOwner := map[uint64]int{}
var procs []procInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue // not a pid directory
}
pi, ok := readProcInfo(root, pid)
if !ok {
continue
}
procs = append(procs, pi)
if pids, err := readSocketOwners(root, pid); err == nil {
for _, ino := range pids {
inodeOwner[ino] = pid
}
}
}
// Resolve listeners -> pid (and hence the proc above).
listeners, err := readListeners(root, inodeOwner)
if err != nil {
return nil, fmt.Errorf("read listeners: %w", err)
}
// Build the discovered-apps list. Phase 1 has no grouping: each
// process is its own app. Section 7.1 grouping (cwd + start
// window) is deferred because it requires a stable cwd per
// process which root-only /proc/<pid>/cwd symlinks cannot give
// for other users' processes.
apps := make([]DiscoveredApp, 0, len(procs))
now := time.Now().UTC()
for i := range procs {
p := &procs[i]
apps = append(apps, DiscoveredApp{
Name: p.comm,
Source: inventorySourceProcess,
PID: p.pid,
Ports: listeners[p.pid],
StartTS: p.startTS,
LastSeen: now,
Cmdline: p.cmdline,
CWD: p.cwd,
})
}
sort.Slice(apps, func(i, j int) bool { return apps[i].PID < apps[j].PID })
return apps, nil
}
func readProcInfo(root string, pid int) (procInfo, bool) {
pi := procInfo{pid: pid}
// comm (15-char truncated, but we want a friendly name).
if data, err := os.ReadFile(filepath.Join(root, strconv.Itoa(pid), "comm")); err == nil {
pi.comm = strings.TrimSpace(string(data))
}
if pi.comm == "" {
// Kernel thread, or vanished. Skip.
return pi, false
}
if data, err := os.ReadFile(filepath.Join(root, strconv.Itoa(pid), "cmdline")); err == nil {
// cmdline is NUL-separated; replace NULs with spaces for
// display.
pi.cmdline = strings.TrimSpace(strings.ReplaceAll(string(data), "\x00", " "))
}
// cwd is a symlink. Reading it requires permission; tolerate EACCES.
if target, err := os.Readlink(filepath.Join(root, strconv.Itoa(pid), "cwd")); err == nil {
pi.cwd = target
}
// stat: field 22 is starttime in clock ticks since boot. We don't
// need a wall-clock start for Phase 1 (the page just renders
// "uptime so-and-so" via boot time), so we only parse comm here.
return pi, true
}
// readSocketOwners walks /proc/<pid>/fd looking for socket:[inode]
// entries. The inode is then matched against /proc/net/tcp to find
// the listening socket. The pid map is the source of truth for
// socket-to-pid translation.
func readSocketOwners(root string, pid int) ([]uint64, error) {
fdDir := filepath.Join(root, strconv.Itoa(pid), "fd")
entries, err := os.ReadDir(fdDir)
if err != nil {
return nil, err
}
var out []uint64
for _, e := range entries {
target, err := os.Readlink(filepath.Join(fdDir, e.Name()))
if err != nil {
continue
}
const prefix = "socket:["
if !strings.HasPrefix(target, prefix) {
continue
}
raw := strings.TrimSuffix(strings.TrimPrefix(target, prefix), "]")
ino, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
continue
}
out = append(out, ino)
}
return out, nil
}
// listenerRow mirrors a single line of /proc/net/tcp (or tcp6).
type listenerRow struct {
inode uint64
local string
rem string
state string
}
// readListeners walks /proc/net/tcp{,6} and returns a map from pid
// to a slice of "ip:port/proto" strings. Only LISTEN state (0A) is
// surfaced in Phase 1.
func readListeners(root string, owner map[uint64]int) (map[int][]string, error) {
out := map[int][]string{}
for _, proto := range []string{"tcp", "tcp6"} {
path := filepath.Join(root, "net", proto)
rows, err := readProcNet(path)
if err != nil {
// /proc/net/tcp6 may not exist on older kernels; tolerate.
if os.IsNotExist(err) {
continue
}
return nil, err
}
for _, r := range rows {
if r.state != "0A" {
continue
}
pid, ok := owner[r.inode]
if !ok {
continue
}
out[pid] = append(out[pid], r.local+"/"+proto)
}
}
return out, nil
}
// readProcNet parses the columnar /proc/net/tcp{,6} format. The
// header is skipped and only the first eight columns are read:
//
// sl local_address rem_address st ...
//
// The local_address and rem_address fields are 4- or 16-byte hex
// followed by a colon and the hex port; we reconstruct a
// "ip:port" string suitable for display.
//
// The inode column (index 9) is hex (matches the address format)
// while /proc/<pid>/fd symlinks carry the same inode in decimal.
// Both reduce to the same uint64 so the map in readListeners
// matches them transparently.
func readProcNet(path string) ([]listenerRow, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close() //nolint:errcheck
var rows []listenerRow
scanner := bufioNewScanner(f)
first := true
for scanner.Scan() {
line := scanner.Text()
if first {
first = false
if strings.HasPrefix(line, " sl") {
continue
}
}
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
ino, err := strconv.ParseUint(fields[9], 16, 64)
if err != nil {
continue
}
rows = append(rows, listenerRow{
local: decodeHexAddrPort(fields[1], len(fields[1]) > 8),
rem: decodeHexAddrPort(fields[2], true),
state: fields[3],
inode: ino,
})
}
return rows, scanner.Err()
}
// decodeHexAddrPort reverses the standard /proc/net encoding:
//
// "0100007F:0C50" -> "127.0.0.1:3152" (IPv4 little-endian)
// "00000000000000000000000000000000:1F90" -> "[::]:8080"
//
// isV6 is unused in Phase 1; tcp and tcp6 rows are both decoded by
// the trailing ":port" split. We assume 32-char (v4-mapped v6) hex
// addresses collapse to v4 strings for the common case.
func decodeHexAddrPort(raw string, _ bool) string {
idx := strings.LastIndex(raw, ":")
if idx < 0 {
return raw
}
portHex := raw[idx+1:]
addrHex := raw[:idx]
port, err := strconv.ParseUint(portHex, 16, 16)
if err != nil {
return raw
}
if len(addrHex) == 8 {
// IPv4 little-endian: the kernel writes each octet
// low-byte-first. "0100007F" means octets 1,0,0,127 which
// in network order is "127.0.0.1".
var b [4]byte
for i := 0; i < 4; i++ {
v, err := strconv.ParseUint(addrHex[2*i:2*i+2], 16, 8)
if err != nil {
return raw
}
b[i] = byte(v)
}
return fmt.Sprintf("%d.%d.%d.%d:%d", b[3], b[2], b[1], b[0], port)
}
if len(addrHex) == 32 {
// IPv6: 8 16-bit groups in network order. Note the bytes
// within each 16-bit group are still little-endian at the
// kernel level, but IPv6 display is typically shown with the
// per-group word order rather than the per-byte order, so
// this matches what the operator sees in `ss -tlnp`.
var groups [8]uint16
for i := 0; i < 8; i++ {
v, err := strconv.ParseUint(addrHex[4*i:4*i+4], 16, 16)
if err != nil {
return raw
}
groups[i] = uint16(v)
}
return fmt.Sprintf("[%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x]:%d",
groups[0], groups[1], groups[2], groups[3],
groups[4], groups[5], groups[6], groups[7], port)
}
return raw
}

159
internal/webapp/inventory_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,159 @@
package webapp
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// makeFakeProc builds a minimal /proc tree suitable for
// ScanProcApps. It writes:
//
// - a "comm" file for each pid
// - a "cmdline" file (NUL-separated)
// - a few sockets under fd/ so the listener scan can match
//
// We deliberately skip the cwd symlink (root-only) and accept that
// the readlink call returns an error; ScanProcApps must tolerate
// EACCES / ENOENT for permission-denied fds.
func makeFakeProc(t *testing.T, pids []fakeProcEntry) string {
t.Helper()
root := t.TempDir()
for _, e := range pids {
pdir := filepath.Join(root, strconv.Itoa(e.pid))
require.NoError(t, os.MkdirAll(pdir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte(e.comm+"\n"), 0o644))
if e.cmdline != "" {
require.NoError(t, os.WriteFile(filepath.Join(pdir, "cmdline"), []byte(e.cmdline), 0o644))
}
if len(e.sockets) > 0 {
fdDir := filepath.Join(pdir, "fd")
require.NoError(t, os.MkdirAll(fdDir, 0o755))
for i, sock := range e.sockets {
// fake inode numbers are arbitrary, but must match the
// /proc/net/tcp "inode" column for ScanProcApps to
// resolve them. Use a stable mapping.
target := "socket:[" + strconv.FormatInt(sock, 10) + "]"
require.NoError(t, os.Symlink(target, filepath.Join(fdDir, strconv.Itoa(i))))
}
}
}
// /proc/net/tcp with state 0A (LISTEN) entries pointing at the
// fake inodes. We do this last so test setup is sequential.
require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755))
var lines []string
lines = append(lines, " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ")
for _, e := range pids {
for _, ino := range e.sockets {
// "0100007F:1E61" -> 127.0.0.1:7777 in little-endian hex.
lines = append(lines, fakeTCPLine(ino, "0100007F:1E61"))
}
}
require.NoError(t, os.WriteFile(
filepath.Join(root, "net", "tcp"),
[]byte(joinLines(lines)),
0o644))
return root
}
type fakeProcEntry struct {
pid int
comm string
cmdline string
sockets []int64 // fake inode numbers
}
func fakeTCPLine(inode int64, local string) string {
// 4 hex chars for tx/rx queue (always 0), 8 hex for tr/tm->when,
// 8 hex for retrnsmt, 1 hex for uid (0), 1 hex for timeout (0),
// then inode (10 hex zero-padded). The trailing fields are zero-
// filled so the scanner skips them.
inodeHex := strconv.FormatInt(inode, 16)
for len(inodeHex) < 8 {
inodeHex = "0" + inodeHex
}
return " 0: " + local + " 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 " + inodeHex + " 0 0 0 0 0"
}
func joinLines(ls []string) string {
out := ""
for i, l := range ls {
if i > 0 {
out += "\n"
}
out += l
}
return out
}
func TestScanProcAppsEmpty(t *testing.T) {
root := t.TempDir()
apps, err := ScanProcApps(root)
require.NoError(t, err)
assert.Empty(t, apps)
}
func TestScanProcAppsSingleProcess(t *testing.T) {
root := makeFakeProc(t, []fakeProcEntry{
{pid: 42, comm: "rsmon-worker", cmdline: "rsmon-worker --foo\x00bar", sockets: []int64{1001}},
})
apps, err := ScanProcApps(root)
require.NoError(t, err)
require.Len(t, apps, 1)
assert.Equal(t, "rsmon-worker", apps[0].Name)
assert.Equal(t, 42, apps[0].PID)
assert.Equal(t, "rsmon-worker --foo bar", apps[0].Cmdline)
// ports slice should have the resolved address.
assert.Contains(t, apps[0].Ports, "127.0.0.1:7777/tcp")
}
func TestScanProcAppsSkipsKernelThreads(t *testing.T) {
// A pid directory with no comm file is treated as a vanished
// process; ScanProcApps must skip it rather than panic.
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "1"), 0o755))
apps, err := ScanProcApps(root)
require.NoError(t, err)
assert.Empty(t, apps)
}
func TestInventoryStoreReplace(t *testing.T) {
dir := t.TempDir()
store, err := OpenStore(filepath.Join(dir, "webapp.db"))
require.NoError(t, err)
defer store.Close() //nolint:errcheck
inv := NewInventory(store, nil)
_ = inv // currently no public method to inject scanned rows;
// we exercise ReplaceApps directly via the store.
now := mustParseTime(t)
rows := []App{
{Name: "rsmon-worker", Source: "process", PID: 1, Ports: "7401/tcp", LastSeen: now},
{Name: "postgres", Source: "process", PID: 2, Ports: "5432/tcp", LastSeen: now},
}
ctx := context.Background()
require.NoError(t, store.ReplaceApps(ctx, rows))
got, err := store.ListApps(ctx)
require.NoError(t, err)
assert.Len(t, got, 2)
assert.Equal(t, "rsmon-worker", got[0].Name)
require.NoError(t, store.ReplaceApps(ctx, []App{
{Name: "redis", Source: "process", PID: 3, Ports: "6379/tcp", LastSeen: now},
}))
got, err = store.ListApps(ctx)
require.NoError(t, err)
assert.Len(t, got, 1)
assert.Equal(t, "redis", got[0].Name)
}
func mustParseTime(t *testing.T) time.Time {
t.Helper()
return time.Now().UTC()
}

99
internal/webapp/logbuffer.go Обычный файл
Просмотреть файл

@@ -0,0 +1,99 @@
package webapp
import (
"strings"
"sync"
)
// LogBuffer is a small in-memory ring buffer the webapp uses to
// expose the last N worker log lines on the Logs page. The worker
// process feeds it via slog/JSON or by calling Append directly.
//
// Capacity is bounded; old entries are evicted FIFO.
type LogBuffer struct {
mu sync.RWMutex
buf []string
capN int
offset int
full bool
}
// NewLogBuffer returns an empty LogBuffer that holds up to capN
// lines. capN <= 0 falls back to a sensible default.
func NewLogBuffer(capN int) *LogBuffer {
if capN <= 0 {
capN = 5000
}
return &LogBuffer{buf: make([]string, 0, capN), capN: capN}
}
// Append adds a single line to the buffer. Newlines are stripped so
// multi-line log records do not split into separate rows.
func (l *LogBuffer) Append(line string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
line = strings.TrimRight(line, "\n")
if line == "" {
return
}
if len(l.buf) < l.capN {
l.buf = append(l.buf, line)
return
}
l.full = true
l.buf[l.offset] = line
l.offset = (l.offset + 1) % l.capN
}
// Tail returns the most recent n lines in chronological order. If n
// is larger than the buffer capacity, only the held lines are
// returned. n <= 0 returns an empty slice.
func (l *LogBuffer) Tail(n int) []string {
if l == nil || n <= 0 {
return nil
}
l.mu.RLock()
defer l.mu.RUnlock()
size := len(l.buf)
if size == 0 {
return nil
}
if n > size {
n = size
}
out := make([]string, n)
if !l.full {
// Buffer not yet wrapped: just slice the tail.
copy(out, l.buf[size-n:])
return out
}
// Buffer is wrapped. The oldest line lives at l.offset; the
// newest line lives at (offset - 1 + capN) % capN.
idx := (l.offset - n + l.capN) % l.capN
for i := 0; i < n; i++ {
out[i] = l.buf[idx]
idx = (idx + 1) % l.capN
}
return out
}
// Size reports the current number of stored lines.
func (l *LogBuffer) Size() int {
if l == nil {
return 0
}
l.mu.RLock()
defer l.mu.RUnlock()
return len(l.buf)
}
// Cap reports the maximum number of lines the buffer holds.
func (l *LogBuffer) Cap() int {
if l == nil {
return 0
}
return l.capN
}

88
internal/webapp/logbuffer_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,88 @@
package webapp
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLogBufferAppendTail(t *testing.T) {
buf := NewLogBuffer(5)
for i := 0; i < 12; i++ {
buf.Append("line " + itoa(i))
}
// capacity 5, should hold the last 5 lines: 7..11
got := buf.Tail(5)
require.Len(t, got, 5)
assert.Equal(t, "line 7", got[0])
assert.Equal(t, "line 11", got[4])
assert.Equal(t, 5, buf.Cap())
assert.Equal(t, 5, buf.Size())
}
func TestLogBufferTailSmall(t *testing.T) {
buf := NewLogBuffer(100)
for i := 0; i < 3; i++ {
buf.Append("x" + itoa(i))
}
got := buf.Tail(2)
require.Len(t, got, 2)
assert.Equal(t, "x1", got[0])
assert.Equal(t, "x2", got[1])
}
func TestLogBufferTailEmpty(t *testing.T) {
buf := NewLogBuffer(10)
assert.Nil(t, buf.Tail(5))
assert.Equal(t, 0, buf.Size())
}
func TestLogBufferTailZero(t *testing.T) {
buf := NewLogBuffer(10)
buf.Append("hello")
assert.Nil(t, buf.Tail(0))
}
func TestLogBufferAppendStripsNewline(t *testing.T) {
buf := NewLogBuffer(10)
buf.Append("hello\n")
got := buf.Tail(1)
require.Len(t, got, 1)
assert.Equal(t, "hello", got[0])
}
func TestFormatBytes(t *testing.T) {
assert.Equal(t, "0 B", fmtBytes(0))
assert.Equal(t, "1023 B", fmtBytes(1023))
assert.Equal(t, "1.00 KiB", fmtBytes(1024))
assert.Equal(t, "1.50 KiB", fmtBytes(1536))
assert.Equal(t, "1.00 MiB", fmtBytes(1024*1024))
assert.Equal(t, "4.00 GiB", fmtBytes(4*1024*1024*1024))
}
func TestFormatPercent(t *testing.T) {
assert.Equal(t, "0.00%", fmtPercent(0))
assert.Equal(t, "50.00%", fmtPercent(50))
assert.Equal(t, "100.00%", fmtPercent(100))
}
func TestFormatDuration(t *testing.T) {
assert.Equal(t, "0s", fmtDuration(0))
assert.Equal(t, "59s", fmtDuration(59*1_000_000_000))
assert.Equal(t, "1m 0s", fmtDuration(60*1_000_000_000))
assert.Equal(t, "1h 0m 0s", fmtDuration(60*60*1_000_000_000))
assert.Equal(t, "1d 0h 0m 0s", fmtDuration(24*60*60*1_000_000_000))
assert.Equal(t, "2d 3h 4m 5s", fmtDuration((2*24+3)*3600*1_000_000_000+(4*60+5)*1_000_000_000))
}
func TestParseTail(t *testing.T) {
assert.Equal(t, 200, parseTail(""))
assert.Equal(t, 200, parseTail("garbage"))
assert.Equal(t, 200, parseTail("199")) // not in the allowed set
assert.Equal(t, 200, parseTail("200"))
assert.Equal(t, 500, parseTail("500"))
assert.Equal(t, 1000, parseTail("1000"))
assert.Equal(t, 5000, parseTail("5000"))
assert.Equal(t, 200, parseTail("5001"))
}

446
internal/webapp/metrics.go Обычный файл
Просмотреть файл

@@ -0,0 +1,446 @@
package webapp
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Metrics owns the host-level status sample used by the overview and
// server-status pages. Phase 1 reads only /proc and statfs over
// mount points. CLI tools (lsblk, smartctl, sensors) are Phase 5.
type Metrics struct {
mu sync.RWMutex
last Snapshot
lastAt time.Time
stopCh chan struct{}
stopWG sync.WaitGroup
started bool
}
// Snapshot is the JSON-friendly view of host metrics the templates
// render. The fields are picked so the status table on /status and
// the cards on /overview share a single type.
type Snapshot struct {
CPU CPUSample `json:"cpu"`
Memory MemorySample `json:"memory"`
Load LoadSample `json:"load"`
Uptime time.Duration `json:"uptime"`
BootAt time.Time `json:"boot_at"`
Networks []NetDev `json:"networks"`
Disks []DiskSample `json:"disks"`
}
// CPUSample reports aggregate CPU usage since the last sample. The
// fields are percentages normalised to 0..100.
type CPUSample struct {
UserPct float64 `json:"user_pct"`
NicePct float64 `json:"nice_pct"`
SystemPct float64 `json:"system_pct"`
IdlePct float64 `json:"idle_pct"`
IOWaitPct float64 `json:"iowait_pct"`
StealPct float64 `json:"steal_pct"`
TotalPct float64 `json:"total_pct"`
}
// MemorySample reports bytes of physical RAM, swap, and various
// accounting fields from /proc/meminfo.
type MemorySample struct {
Total uint64 `json:"total_bytes"`
Available uint64 `json:"available_bytes"`
Free uint64 `json:"free_bytes"`
Buffers uint64 `json:"buffers_bytes"`
Cached uint64 `json:"cached_bytes"`
SwapTotal uint64 `json:"swap_total_bytes"`
SwapFree uint64 `json:"swap_free_bytes"`
UsedPct float64 `json:"used_pct"`
AvailablePct float64 `json:"available_pct"`
}
// LoadSample is the 1/5/15 minute load averages from /proc/loadavg.
type LoadSample struct {
One float64 `json:"load1"`
Five float64 `json:"load5"`
Fifteen float64 `json:"load15"`
}
// NetDev is one row from /proc/net/dev.
type NetDev struct {
Name string `json:"name"`
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
RxPkt uint64 `json:"rx_packets"`
TxPkt uint64 `json:"tx_packets"`
RxErr uint64 `json:"rx_errors"`
TxErr uint64 `json:"tx_errors"`
RxDrop uint64 `json:"rx_dropped"`
TxDrop uint64 `json:"tx_dropped"`
}
// DiskSample is a single mount point from /proc/mounts with disk
// usage from statfs(2).
type DiskSample struct {
Mount string `json:"mount"`
Device string `json:"device"`
FSType string `json:"fstype"`
Total uint64 `json:"total_bytes"`
Free uint64 `json:"free_bytes"`
Used uint64 `json:"used_bytes"`
UsedPct float64 `json:"used_pct"`
}
// NewMetrics constructs an empty Metrics sampler. The loop is not
// started until Start is called.
func NewMetrics() *Metrics {
return &Metrics{
stopCh: make(chan struct{}),
}
}
// Start launches a sample loop. The first sample is taken
// immediately so /status never renders "no data yet".
func (m *Metrics) Start(ctx context.Context) {
m.mu.Lock()
if m.started {
m.mu.Unlock()
return
}
m.started = true
m.mu.Unlock()
m.sample(ctx)
m.stopWG.Add(1)
go m.loop(ctx)
}
// Stop cancels the sample loop and waits for it to exit.
func (m *Metrics) Stop() {
m.mu.Lock()
if !m.started {
m.mu.Unlock()
return
}
select {
case <-m.stopCh:
default:
close(m.stopCh)
}
m.mu.Unlock()
m.stopWG.Wait()
}
// Last returns the most recent snapshot and the time it was taken.
// Always safe to call; returns a zero-value snapshot if the first
// sample has not yet completed.
func (m *Metrics) Last() (Snapshot, time.Time) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.last, m.lastAt
}
func (m *Metrics) loop(ctx context.Context) {
defer m.stopWG.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
case <-ticker.C:
m.sample(ctx)
}
}
}
func (m *Metrics) sample(_ context.Context) {
snap, err := CollectSnapshot(ProcRoot())
if err != nil {
return // best-effort
}
m.mu.Lock()
m.last = snap
m.lastAt = time.Now().UTC()
m.mu.Unlock()
}
// CollectSnapshot reads the /proc mount once and returns a Snapshot.
// Exposed at package scope so tests can drive it directly with a
// fixture /proc tree.
func CollectSnapshot(root string) (Snapshot, error) {
now := time.Now().UTC()
cpu, err := readProcStat(filepath.Join(root, "stat"))
if err != nil {
return Snapshot{}, fmt.Errorf("read stat: %w", err)
}
mem, err := readMemInfo(filepath.Join(root, "meminfo"))
if err != nil {
return Snapshot{}, fmt.Errorf("read meminfo: %w", err)
}
load, err := readLoadAvg(filepath.Join(root, "loadavg"))
if err != nil {
return Snapshot{}, fmt.Errorf("read loadavg: %w", err)
}
uptime, err := readUptime(filepath.Join(root, "uptime"))
if err != nil {
return Snapshot{}, fmt.Errorf("read uptime: %w", err)
}
net, err := readNetDev(filepath.Join(root, "net", "dev"))
if err != nil {
return Snapshot{}, fmt.Errorf("read net/dev: %w", err)
}
disks, err := readMounts(filepath.Join(root, "mounts"))
if err != nil {
return Snapshot{}, fmt.Errorf("read mounts: %w", err)
}
for i := range disks {
if err := statDisk(disks[i].Mount, &disks[i]); err != nil {
// statfs may fail for some pseudo mounts (proc, sys);
// we leave Total/Free/Used at zero in that case so the
// page renders an empty row rather than a hard error.
continue
}
}
bootAt := now.Add(-uptime)
return Snapshot{
CPU: cpu,
Memory: mem,
Load: load,
Uptime: uptime,
BootAt: bootAt,
Networks: net,
Disks: disks,
}, nil
}
// readProcStat reads the aggregate "cpu " row of /proc/stat and
// returns percentages. /proc/stat is cumulative since boot, so a
// single read yields busy/total ratios only if we remember the
// previous delta. Phase 1 does not keep history; the per-CPU
// "busy since boot" snapshot is rendered on the page as a static
// "load since boot" indicator instead of a live % value.
//
// The function takes a "previous" sample for delta math; if prev
// is the zero value, the function returns zero percentages.
func readProcStat(path string) (CPUSample, error) {
f, err := os.Open(path)
if err != nil {
return CPUSample{}, err
}
defer f.Close() //nolint:errcheck
var (
user, nice, system, idle, iowait, steal uint64
agg bool
)
scanner := bufioNewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 8 {
return CPUSample{}, fmt.Errorf("short cpu line: %q", line)
}
agg = true
user, _ = strconv.ParseUint(fields[1], 10, 64)
nice, _ = strconv.ParseUint(fields[2], 10, 64)
system, _ = strconv.ParseUint(fields[3], 10, 64)
idle, _ = strconv.ParseUint(fields[4], 10, 64)
iowait, _ = strconv.ParseUint(fields[5], 10, 64)
steal, _ = strconv.ParseUint(fields[7], 10, 64)
break
}
if err := scanner.Err(); err != nil {
return CPUSample{}, err
}
if !agg {
return CPUSample{}, fmt.Errorf("no aggregate cpu line in %s", path)
}
total := user + nice + system + idle + iowait + steal
if total == 0 {
return CPUSample{}, nil
}
return CPUSample{
UserPct: pct(user, total),
NicePct: pct(nice, total),
SystemPct: pct(system, total),
IdlePct: pct(idle, total),
IOWaitPct: pct(iowait, total),
StealPct: pct(steal, total),
TotalPct: pct(total-(idle+iowait), total),
}, nil
}
func pct(part, total uint64) float64 {
if total == 0 {
return 0
}
return float64(part) * 100 / float64(total)
}
// readMemInfo parses /proc/meminfo. Units are kB; we convert to
// bytes on the way out so the page never has to multiply.
func readMemInfo(path string) (MemorySample, error) {
f, err := os.Open(path)
if err != nil {
return MemorySample{}, err
}
defer f.Close() //nolint:errcheck
values := map[string]uint64{}
scanner := bufioNewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
key := strings.TrimSuffix(fields[0], ":")
v, err := strconv.ParseUint(fields[1], 10, 64)
if err != nil {
continue
}
values[key] = v * 1024
}
if err := scanner.Err(); err != nil {
return MemorySample{}, err
}
mem := MemorySample{
Total: values["MemTotal"],
Available: values["MemAvailable"],
Free: values["MemFree"],
Buffers: values["Buffers"],
Cached: values["Cached"],
SwapTotal: values["SwapTotal"],
SwapFree: values["SwapFree"],
}
if mem.Total > 0 {
used := mem.Total - mem.Available
mem.UsedPct = pct(used, mem.Total)
mem.AvailablePct = pct(mem.Available, mem.Total)
}
return mem, nil
}
func readLoadAvg(path string) (LoadSample, error) {
data, err := os.ReadFile(path)
if err != nil {
return LoadSample{}, err
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return LoadSample{}, fmt.Errorf("short loadavg line: %q", data)
}
one, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return LoadSample{}, err
}
five, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return LoadSample{}, err
}
fifteen, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
return LoadSample{}, err
}
return LoadSample{One: one, Five: five, Fifteen: fifteen}, nil
}
func readUptime(path string) (time.Duration, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
fields := strings.Fields(string(data))
if len(fields) < 1 {
return 0, fmt.Errorf("short uptime line: %q", data)
}
secs, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, err
}
return time.Duration(secs * float64(time.Second)), nil
}
func readNetDev(path string) ([]NetDev, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close() //nolint:errcheck
var out []NetDev
scanner := bufioNewScanner(f)
first := true
for scanner.Scan() {
line := scanner.Text()
if first {
first = false
if strings.Contains(line, "Inter-|") {
continue
}
}
fields := strings.Fields(line)
if len(fields) < 17 {
continue
}
name := strings.TrimSuffix(fields[0], ":")
out = append(out, NetDev{
Name: name,
RxBytes: parseU64(fields[1]),
TxBytes: parseU64(fields[9]),
RxPkt: parseU64(fields[2]),
TxPkt: parseU64(fields[10]),
RxErr: parseU64(fields[3]),
TxErr: parseU64(fields[11]),
RxDrop: parseU64(fields[4]),
TxDrop: parseU64(fields[12]),
})
}
return out, scanner.Err()
}
func parseU64(s string) uint64 {
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0
}
return v
}
func readMounts(path string) ([]DiskSample, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var out []DiskSample
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
// device mountpoint fstype options dump pass
out = append(out, DiskSample{
Device: fields[0],
Mount: fields[1],
FSType: fields[2],
})
}
return out, nil
}
// statDisk is implemented in metrics_linux.go (real statfs) and
// metrics_other.go (no-op stub for non-Linux dev builds). The
// function is called from CollectSnapshot below to fill disk usage
// data; tests that exercise the metric paths with fake /proc trees
// accept the zero-value stats as expected.

29
internal/webapp/metrics_linux.go Обычный файл
Просмотреть файл

@@ -0,0 +1,29 @@
//go:build linux
package webapp
import (
"syscall"
)
// statDisk fills Total/Free/Used for a DiskSample via statfs(2).
// Linux-only because syscall.Statfs_t is platform-specific. Tests
// that exercise the metric paths stub statDisk at the function
// boundary so the build is hermetic.
func statDisk(mount string, d *DiskSample) error {
var st syscall.Statfs_t
if err := syscall.Statfs(mount, &st); err != nil {
return err
}
bsize := uint64(st.Frsize)
total := st.Blocks * bsize
free := st.Bavail * bsize
used := total - free
d.Total = total
d.Free = free
d.Used = used
if total > 0 {
d.UsedPct = pct(used, total)
}
return nil
}

11
internal/webapp/metrics_other.go Обычный файл
Просмотреть файл

@@ -0,0 +1,11 @@
//go:build !linux
package webapp
// statDisk is a no-op on non-Linux platforms. The webapp MVP targets
// Linux workers (the proc/sysfs data sources only exist there); the
// build tag keeps the package compilable on a developer Mac for
// quick template/handler iteration.
func statDisk(_ string, _ *DiskSample) error {
return nil
}

140
internal/webapp/metrics_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,140 @@
package webapp
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// makeFakeMetricsProc writes a minimal /proc tree with stat, meminfo,
// loadavg, uptime, and net/dev files so CollectSnapshot can parse
// it without a real /proc.
func makeFakeMetricsProc(t *testing.T) string {
t.Helper()
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "stat"), []byte(statFixture), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "meminfo"), []byte(meminfoFixture), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "loadavg"), []byte(loadavgFixture), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "uptime"), []byte(uptimeFixture), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "net", "dev"), []byte(netdevFixture), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "mounts"), []byte(mountsFixture), 0o644))
return root
}
const statFixture = `cpu 100 0 50 800 20 0 0 0 0 0
cpu0 25 0 12 200 5 0 0 0 0 0
intr 12345 0 0 0
ctxt 78910
btime 1700000000
`
const meminfoFixture = `MemTotal: 16384000 kB
MemFree: 4096000 kB
MemAvailable: 8192000 kB
Buffers: 512000 kB
Cached: 2048000 kB
SwapCached: 0 kB
SwapTotal: 2048000 kB
SwapFree: 2048000 kB
`
const loadavgFixture = "0.42 0.85 1.23 1/123 4567\n"
const uptimeFixture = "12345.67\n"
const netdevFixture = `Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 1000000 1000 0 0 0 0 0 0 1000000 1000 0 0 0 0 0 0
eth0: 9999999 5000 1 2 0 0 0 0 8888888 4500 0 0 0 0 0 0
`
const mountsFixture = `/dev/sda1 / ext4 rw,relatime 0 0
tmpfs /run tmpfs rw,nosuid 0 0
`
func TestReadProcStatPercentages(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "stat"), []byte(statFixture), 0o644))
cpu, err := readProcStat(filepath.Join(root, "stat"))
require.NoError(t, err)
// Fixture: user=100, nice=0, system=50, idle=800, iowait=20, steal=0
// Total = 970, idle+iowait = 820, busy = 150.
assert.InDelta(t, 10.31, cpu.UserPct, 0.01)
assert.InDelta(t, 5.15, cpu.SystemPct, 0.01)
assert.InDelta(t, 82.47, cpu.IdlePct, 0.01)
assert.InDelta(t, 15.46, cpu.TotalPct, 0.01)
}
func TestReadMemInfoBytes(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "meminfo"), []byte(meminfoFixture), 0o644))
mem, err := readMemInfo(filepath.Join(root, "meminfo"))
require.NoError(t, err)
assert.Equal(t, uint64(16384000*1024), mem.Total)
assert.Equal(t, uint64(8192000*1024), mem.Available)
assert.Equal(t, uint64(2048000*1024), mem.SwapTotal)
// used = total - available = 8192 MiB
// pct = 8192 / 16384 * 100 = 50.00
assert.InDelta(t, 50.0, mem.UsedPct, 0.01)
}
func TestReadLoadAvg(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "loadavg"), []byte(loadavgFixture), 0o644))
load, err := readLoadAvg(filepath.Join(root, "loadavg"))
require.NoError(t, err)
assert.InDelta(t, 0.42, load.One, 0.001)
assert.InDelta(t, 0.85, load.Five, 0.001)
assert.InDelta(t, 1.23, load.Fifteen, 0.001)
}
func TestReadUptime(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "uptime"), []byte(uptimeFixture), 0o644))
d, err := readUptime(filepath.Join(root, "uptime"))
require.NoError(t, err)
assert.InDelta(t, 12345.67, d.Seconds(), 0.01)
}
func TestReadNetDev(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "net", "dev"), []byte(netdevFixture), 0o644))
ifaces, err := readNetDev(filepath.Join(root, "net", "dev"))
require.NoError(t, err)
require.Len(t, ifaces, 2)
eth := ifaces[1]
assert.Equal(t, "eth0", eth.Name)
assert.Equal(t, uint64(9999999), eth.RxBytes)
assert.Equal(t, uint64(8888888), eth.TxBytes)
assert.Equal(t, uint64(5000), eth.RxPkt)
assert.Equal(t, uint64(1), eth.RxErr)
}
func TestReadMounts(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "mounts"), []byte(mountsFixture), 0o644))
mounts, err := readMounts(filepath.Join(root, "mounts"))
require.NoError(t, err)
require.Len(t, mounts, 2)
assert.Equal(t, "/", mounts[0].Mount)
assert.Equal(t, "ext4", mounts[0].FSType)
}
func TestCollectSnapshotEndToEnd(t *testing.T) {
root := makeFakeMetricsProc(t)
snap, err := CollectSnapshot(root)
require.NoError(t, err)
assert.InDelta(t, 15.46, snap.CPU.TotalPct, 0.5,
"CPU busy should be in the expected range")
assert.InDelta(t, 50.0, snap.Memory.UsedPct, 0.5,
"memory used should be ~50% from the fixture")
assert.InDelta(t, 12345.67, snap.Uptime.Seconds(), 0.5)
require.Len(t, snap.Networks, 2)
require.Len(t, snap.Disks, 2)
assert.False(t, snap.BootAt.IsZero(), "boot time must be derivable")
}

173
internal/webapp/middleware.go Обычный файл
Просмотреть файл

@@ -0,0 +1,173 @@
package webapp
import (
"context"
"crypto/sha256"
"crypto/subtle"
"net/http"
"strings"
"time"
)
// requireSession is the middleware chain for authenticated pages.
// On success, the request context carries the *Session so handlers
// can look up the user without re-querying the store. On failure,
// the operator is redirected to /web/login.
//
// When basic auth is configured (WORKER_LOGIN/WORKER_PASSWORD), the
// middleware accepts HTTP Basic credentials on /web/api/* as an
// alternative to the session cookie. The synthetic session is created
// in-memory only (no row in webapp_sessions) so curl -u operators
// don't accumulate dead rows.
//
// First-run password-change enforcement is intentionally OFF: the
// requires_change flag is still recorded on webapp_users and the
// /web/change-password page is still reachable, but the operator is
// not redirected there on first login. The flag is reserved for a
// future "hardening" toggle that operators will opt into (e.g. via
// a config knob or a UI setting). For now the worker ships with
// frictionless first-login so basic auth + standalone bcrypt users
// can both reach the operator console immediately.
func (s *Server) requireSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Fast-path: HTTP Basic for API consumers when basic auth
// is configured. Only the /web/api/* prefix is exposed this
// way so the browser-driven login flow stays intact.
if s.basicAuthOK && strings.HasPrefix(r.URL.Path, "/web/api/") {
if s.checkBasicAuth(r) {
next(w, r)
return
}
w.Header().Set("WWW-Authenticate", basicAuthRealm)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sess, err := s.resolveSession(r)
if err != nil {
redirectTo(w, r, pathLogin)
return
}
ctx := context.WithValue(r.Context(), ctxKeySession, sess)
next(w, r.WithContext(ctx))
}
}
// checkBasicAuth validates the Authorization: Basic header against
// the configured WORKER_LOGIN / WORKER_PASSWORD pair. Login is
// compared in constant time; password is hashed at startup so the
// raw value never enters the request hot path.
func (s *Server) checkBasicAuth(r *http.Request) bool {
if !s.basicAuthOK {
return false
}
user, pass, ok := r.BasicAuth()
if !ok {
return false
}
userHash := sha256.Sum256([]byte(user))
wantUserHash := sha256.Sum256([]byte(s.cfg.BasicAuthLogin))
if subtle.ConstantTimeCompare(userHash[:], wantUserHash[:]) != 1 {
return false
}
passHash := sha256.Sum256([]byte(pass))
return subtle.ConstantTimeCompare(passHash[:], s.basicAuthHash[:]) == 1
}
// resolveSession reads the session cookie, validates the row, slides
// the absolute expiry forward on activity, and returns the Session.
// Returns errMissingSession if any step fails.
func (s *Server) resolveSession(r *http.Request) (*Session, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
return nil, errMissingSession
}
sess, err := s.store.GetSession(r.Context(), c.Value)
if err != nil {
return nil, errMissingSession
}
if err := s.store.TouchSession(r.Context(), sess.ID, s.cfg.SessionIdle, s.cfg.SessionAbs); err != nil {
return nil, errMissingSession
}
sess.LastSeenAt = time.Now().UTC()
return sess, nil
}
// requireCSRF verifies the double-submit token on state-changing
// requests. The middleware compares the form/header value against
// the session's stored CSRF token. The cookie value is not the
// authoritative source on its own (it is set without HttpOnly so
// that JS on the same origin could read it, but Phase 1 ships no
// JS, so a missing cookie is fine).
func (s *Server) requireCSRF(sess *Session, r *http.Request) bool {
if sess == nil {
return false
}
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
return true
}
want := sess.CSRFToken
if want == "" {
return false
}
// Form value is the primary check; header is a fallback for
// fetch-style callers.
if err := r.ParseForm(); err != nil {
return false
}
got := strings.TrimSpace(r.FormValue("csrf_token"))
if got == "" {
got = strings.TrimSpace(r.Header.Get("X-CSRF-Token"))
}
if got == "" || constantTimeEq(got, want) != 1 {
return false
}
return true
}
// writeSessionCookie sets the session and CSRF cookies. The Secure
// flag is set when the request did not arrive over loopback (i.e.,
// when the operator has gone out of their way to expose the
// webapp over a non-loopback interface).
func (s *Server) writeSessionCookie(w http.ResponseWriter, r *http.Request, sess *Session) {
if sess == nil {
return
}
secure := !isLoopbackRequest(r)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
Expires: sess.ExpiresAt,
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: sess.CSRFToken,
Path: "/",
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
Expires: sess.ExpiresAt,
})
}
// clearSessionCookie blanks out the session and CSRF cookies. Used
// on logout and on hard errors (DB failure, etc.).
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
secure := !isLoopbackRequest(r)
for _, name := range []string{sessionCookieName, csrfCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
}

40
internal/webapp/page_data.go Обычный файл
Просмотреть файл

@@ -0,0 +1,40 @@
package webapp
import (
"net/http"
"time"
)
// basePageData is the struct every page template receives (embedded
// into the per-page struct). The shared layout looks up Title,
// IsLogin, CSRFToken, Version, BuildDate, and StartedAt from here so
// handlers do not need to repeat those fields on every page.
type basePageData struct {
Title string
IsLogin bool
CSRFToken string
Version string
BuildDate string
StartedAt time.Time
}
// newBasePage builds the common fields. isLogin is true for the
// login page so the layout can hide the navigation; csrfToken is the
// empty string when isLogin is true (there is no session yet).
func (s *Server) newBasePage(_ *http.Request, title string, sess *Session) basePageData {
return basePageData{
Title: title,
IsLogin: sess == nil,
CSRFToken: tokenOrEmpty(sess),
Version: s.deps.Version,
BuildDate: s.deps.BuildDate,
StartedAt: s.deps.StartedAt,
}
}
func tokenOrEmpty(sess *Session) string {
if sess == nil {
return ""
}
return sess.CSRFToken
}

184
internal/webapp/routes.go Обычный файл
Просмотреть файл

@@ -0,0 +1,184 @@
package webapp
import (
"context"
"errors"
"net"
"net/http"
"path"
"strings"
)
// routes wires the HTTP mux for the Phase 1 webapp. The shape is
// deliberately stdlib-only (no gin) to keep the binary small and to
// follow section 12 of the plan doc (strict CSP, no inline scripts).
//
// Public routes (no auth): /web/login, /web/logout, /web/change-password.
// Authenticated routes: everything else. The middleware chain in
// middleware.go enforces this and adds CSRF / security headers.
//
// Handlers live in handlers_auth.go (login flow) and the per-page
// handlers_* files for the rest.
func (s *Server) routes() {
// Static assets are served via the embedded FS so no CDN is
// reachable from the worker webapp. They are public so the
// login page can render with the right CSS.
s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Auth flow (unauthenticated).
s.mux.HandleFunc("GET /web/login", s.handleLoginForm)
s.mux.HandleFunc("POST /web/login", s.handleLoginSubmit)
s.mux.HandleFunc("POST /web/logout", s.handleLogout)
// Change-password is reachable from /settings. The login flow
// does not force the operator through it any more (the
// requires_change flag is recorded on the user row but no
// longer enforced); the page is still here so an operator who
// wants to rotate the bcrypt password can do so explicitly.
s.mux.Handle("GET /web/change-password", s.requireSession(s.handleChangePasswordForm))
s.mux.Handle("POST /web/change-password", s.requireSession(s.handleChangePasswordSubmit))
// Authenticated pages.
s.mux.Handle("GET /{$}", s.requireSession(s.handleOverview)) // exact "/"
s.mux.Handle("GET /overview", s.requireSession(s.handleOverview))
s.mux.Handle("GET /apps", s.requireSession(s.handleApps))
s.mux.Handle("GET /apps/{id}", s.requireSession(s.handleAppDetail))
s.mux.Handle("GET /checks", s.requireSession(s.handleChecks))
s.mux.Handle("GET /notifications", s.requireSession(s.handleNotifications))
s.mux.Handle("GET /logs", s.requireSession(s.handleLogs))
s.mux.Handle("GET /status", s.requireSession(s.handleStatus))
s.mux.Handle("GET /settings", s.requireSession(s.handleSettings))
s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken))
s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates))
// Health endpoint for the cmd health subcommand and for the
// operator to confirm the listener is up without going through the
// login form. Returns 200 with a tiny body.
s.mux.HandleFunc("GET /healthz", s.handleHealth)
// Cluster admin endpoints. The routes are always registered so
// SetCluster can attach/detach a cluster at runtime; the handlers
// themselves return 503 when no cluster subsystem is attached.
// Both routes require a session (the worker webapp is single-tenant
// so every logged-in operator is effectively an admin).
s.mux.Handle("GET /web/api/cluster/status", s.requireSession(s.handleClusterStatus))
s.mux.Handle("POST /web/api/cluster/apply-test-config", s.requireSession(s.handleClusterApplyTestConfig))
// Cross-worker peer status. The path is intentionally under
// /api/ (not /web/api/) so the basic-auth middleware does not
// intercept it; peer workers are still expected to live in a
// trusted local network (slice 1 of
// docs/distributed/worker-to-worker-raft.md).
s.mux.HandleFunc("GET /api/peer/status", s.handlePeerStatus)
}
// securityHeaders wraps the mux with the response-header policy
// required by section 12 of the plan doc:
//
// - Cache-Control: no-store on every authenticated response
// (this is enforced inside handlers instead because the policy
// depends on whether the response is HTML or a 401 redirect;
// see writeNoStore).
// - CSP: default-src 'self'; no inline scripts, no eval. Phase 1
// serves no external assets, so 'self' is sufficient.
// - X-Content-Type-Options: nosniff.
// - Referrer-Policy: no-referrer (do not leak paths in referrers).
// - X-Frame-Options: DENY (the webapp is never meant to be
// framed, even on loopback).
//
// Secure / SameSite / HttpOnly on cookies is enforced inside the
// session helpers (see writeSessionCookie) because the values depend
// on whether the request arrived over a loopback connection.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; "+
"script-src 'self'; object-src 'none'; base-uri 'none'; "+
"frame-ancestors 'none'; form-action 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
// writeNoStore sets the response policy required for authenticated
// pages. Per section 12.4: every authenticated response returns
// Cache-Control: no-store so an operator on a shared kiosk browser
// cannot walk back to a logged-in view of the operator console.
func writeNoStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
}
// isLoopbackRequest reports whether the incoming TCP connection is
// from a loopback address. Used by the cookie helper to decide
// whether to set the Secure flag (a Secure cookie set over plain
// HTTP on loopback works fine, but the cookie helper only flips
// Secure when not on loopback for safety).
func isLoopbackRequest(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// RemoteAddr may already be just an IP if no port is
// present (some test servers).
host = r.RemoteAddr
}
if host == "" {
return false
}
if host == "::1" || strings.HasPrefix(host, "127.") {
return true
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return true
}
return false
}
// clientIP extracts the client IP from r.RemoteAddr. X-Forwarded-For
// is intentionally ignored: Phase 1 binds to loopback only, so any
// forwarded header would come from the operator's own browser and
// is not authoritative.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// Path returns the request's URL path with a leading slash so route
// lookups can compare with the registered pattern.
func Path(r *http.Request) string {
p := r.URL.Path
if p == "" {
p = "/"
}
return path.Clean(p)
}
// redirectTo sends a 302 to the given path. Used by the login and
// logout handlers.
func redirectTo(w http.ResponseWriter, r *http.Request, p string) {
http.Redirect(w, r, p, http.StatusFound)
}
// errMissingSession is the canonical "no session cookie" error for
// middleware. It is converted to a redirect to the login page by
// requireSession.
var errMissingSession = errors.New("webapp: no session")
// ctxWithSession attaches a session to the request context.
type ctxKey int
const (
ctxKeySession ctxKey = iota
)
// sessionFromContext returns the session attached by requireSession.
// The bool result is false if no session is attached.
func sessionFromContext(ctx context.Context) (*Session, bool) {
v, ok := ctx.Value(ctxKeySession).(*Session)
return v, ok
}

15
internal/webapp/scan.go Обычный файл
Просмотреть файл

@@ -0,0 +1,15 @@
package webapp
import "bufio"
// bufioNewScanner is a thin wrapper around bufio.NewScanner with a
// 1 MiB max line size. Most /proc files fit comfortably under that.
// Splitting it out lets tests override the buffer size if needed.
func bufioNewScanner(r interface {
Read(p []byte) (int, error)
},
) *bufio.Scanner {
s := bufio.NewScanner(r)
s.Buffer(make([]byte, 0, 64*1024), 1<<20)
return s
}

646
internal/webapp/server.go Обычный файл
Просмотреть файл

@@ -0,0 +1,646 @@
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")
}

600
internal/webapp/server_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,600 @@
package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateBasicAuth pins the XOR rejection invariant for
// WORKER_LOGIN / WORKER_PASSWORD. Both empty disables basic auth;
// both set enables it; mixed (XOR) is a config bug.
func TestValidateBasicAuth(t *testing.T) {
cases := []struct {
name string
login string
password string
wantErr bool
}{
{"both empty", "", "", false},
{"login only", "user", "", true},
{"password only", "", "secret", true},
{"both set", "user", "secret", false},
{"whitespace login ignored", " ", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateBasicAuth(c.login, c.password)
if c.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
// TestConfigFromEnvDefaults pins the env-less default bind: the
// webapp must listen on 0.0.0.0:27401 (not on the main RSMon port
// 7401) when no env overrides are set. The change from 127.0.0.1
// to 0.0.0.0 was made in tandem with the basic-auth rewrite so the
// webapp can be reached on a real interface by an operator who
// fronted the worker with Traefik/nginx.
func TestConfigFromEnvDefaults(t *testing.T) {
cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir())
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", cfg.Addr[:7], "default host must be 0.0.0.0")
assert.Equal(t, "27401", cfg.Addr[len(cfg.Addr)-5:], "default port must be 27401")
assert.False(t, cfg.BasicAuthLogin != "" || cfg.BasicAuthPassword != "",
"basic auth must be off by default")
}
// TestConfigFromEnvBasicAuthAcceptsBothEnvVars exercises the new
// happy path where WORKER_LOGIN and WORKER_PASSWORD are both set.
func TestConfigFromEnvBasicAuthAcceptsBothEnvVars(t *testing.T) {
env := map[string]string{
"WORKER_LOGIN": "alice",
"WORKER_PASSWORD": "s3cret",
"WORKER_HOST": "0.0.0.0",
"WORKER_PORT": "30000",
}
cfg, err := ConfigFromEnv(env, t.TempDir())
require.NoError(t, err)
assert.Equal(t, "alice", cfg.BasicAuthLogin)
assert.Equal(t, "s3cret", cfg.BasicAuthPassword)
assert.Equal(t, "0.0.0.0:30000", cfg.Addr)
}
// TestConfigFromEnvBasicAuthRejectsXOR pins the regression guard:
// setting only one of WORKER_LOGIN / WORKER_PASSWORD must fail fast
// so an operator notices the misconfiguration.
func TestConfigFromEnvBasicAuthRejectsXOR(t *testing.T) {
_, err := ConfigFromEnv(map[string]string{
"WORKER_LOGIN": "alice",
}, t.TempDir())
assert.Error(t, err, "XOR (login only) must be rejected")
_, err = ConfigFromEnv(map[string]string{
"WORKER_PASSWORD": "s3cret",
}, t.TempDir())
assert.Error(t, err, "XOR (password only) must be rejected")
}
// TestConfigFromEnvDebugClusterApply pins the default-off behavior
// of the cluster-apply debug gate and verifies the env flag flips
// it on. Production builds must not accidentally expose the
// endpoint, so the default is false.
func TestConfigFromEnvDebugClusterApply(t *testing.T) {
cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir())
require.NoError(t, err)
assert.False(t, cfg.DebugClusterApply, "default must leave the debug flag off")
cfg, err = ConfigFromEnv(map[string]string{
"WORKER_CLUSTER_DEBUG_APPLY": "true",
}, t.TempDir())
require.NoError(t, err)
assert.True(t, cfg.DebugClusterApply)
// Other truthy spellings accepted.
for _, v := range []string{"yes", "1", "TRUE", "YeS"} {
cfg, err = ConfigFromEnv(map[string]string{
"WORKER_CLUSTER_DEBUG_APPLY": v,
}, t.TempDir())
require.NoError(t, err)
assert.True(t, cfg.DebugClusterApply, "must accept truthy value %q", v)
}
// Empty / unknown values stay false.
for _, v := range []string{"", "false", "0", "no"} {
cfg, err = ConfigFromEnv(map[string]string{
"WORKER_CLUSTER_DEBUG_APPLY": v,
}, t.TempDir())
require.NoError(t, err)
assert.False(t, cfg.DebugClusterApply, "must reject non-truthy value %q", v)
}
}
// TestConfigFromEnvReleaseURL pins the env-driven WORKER_RELEASE_URL
// plumbing. The handler reads cfg.ReleaseURL when the page renders,
// so the value must survive ConfigFromEnv exactly.
func TestConfigFromEnvReleaseURL(t *testing.T) {
cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir())
require.NoError(t, err)
assert.Empty(t, cfg.ReleaseURL, "default ReleaseURL must be empty")
cfg, err = ConfigFromEnv(map[string]string{
"WORKER_RELEASE_URL": " https://example.com/releases ",
}, t.TempDir())
require.NoError(t, err)
assert.Equal(t, "https://example.com/releases", cfg.ReleaseURL,
"ReleaseURL must be trimmed before storage")
}
// TestLoginPageRendersAnonymous verifies the login page is
// reachable without a session and returns the right HTTP headers.
// In local-only mode the form has only a password field; the
// basic-auth variant adds a username field.
func TestLoginPageRendersAnonymous(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/web/login")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "default-src 'self'")
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "RSMon worker login")
assert.Contains(t, string(body), `name="password"`)
assert.NotContains(t, string(body), `name="login"`,
"local-only login form must not show a username field")
}
// TestLoginPageShowsUsernameFieldWhenBasicAuthConfigured confirms the
// login form renders a username input and the basic-auth explanatory
// copy when WORKER_LOGIN/WORKER_PASSWORD are set on the server.
func TestLoginPageShowsUsernameFieldWhenBasicAuthConfigured(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/web/login")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), `name="login"`,
"basic-auth login form must show a username field")
assert.Contains(t, string(body), "WORKER_LOGIN")
}
// TestAuthenticatedPagesRedirectToLogin asserts every authenticated
// route returns 302 to /web/login when no session cookie is sent.
func TestAuthenticatedPagesRedirectToLogin(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
pages := []string{
"/overview",
"/apps",
"/checks",
"/notifications",
"/logs",
"/status",
"/settings",
"/updates",
}
for _, p := range pages {
t.Run(p, func(t *testing.T) {
c := httpClient()
req, _ := http.NewRequest(http.MethodGet, ts.URL+p, nil)
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusFound, resp.StatusCode,
"%s must redirect to login when unauthenticated", p)
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
})
}
}
// TestFirstLoginReachesOverviewDirectly pins the frictionless
// first-login flow: the operator lands on /overview immediately and
// is NOT redirected to /web/change-password even though
// requires_change is set on the bcrypt user row. The flag is kept
// in the schema as a future hardening knob but the login flow no
// longer enforces it.
func TestFirstLoginReachesOverviewDirectly(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
// /overview must NOT redirect to change-password any more.
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/overview", nil)
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode,
"first-login must reach /overview without bouncing to change-password")
// The flag is still recorded on the user row so a future
// hardening toggle can re-enforce it without a schema change.
user, err := srv.store.GetUser(context.Background())
require.NoError(t, err)
assert.True(t, user.RequiresChange,
"requires_change flag must remain set on the user row for future use")
}
// TestRequiresChangeFlagDoesNotBlockAPIAccess complements the
// overview test: even on /web/api/* the flag must not bounce the
// operator. The path is the change-password page itself (which is
// under /web/, not /web/api/), so we exercise the overview path as
// a proxy for "any authenticated page".
func TestRequiresChangeFlagDoesNotBlockAPIAccess(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
for _, path := range []string{"/overview", "/apps", "/checks", "/status", "/settings"} {
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
resp, err := c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode,
"%s must not redirect away despite requires_change flag", path)
}
}
// TestChangePasswordClearsFlagAndAllowsOverview exercises the full
// change-password form. CSRF check is enforced, the requires_change
// flag clears. The change-password page is reachable without a
// forced redirect from /overview, so the test starts by walking
// the operator straight into the form.
func TestChangePasswordClearsFlagAndAllowsOverview(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, plain := loginAsFirstRun(t, ts.URL, srv)
// /overview is reachable immediately (no forced redirect any
// more); the operator clicks "change password" themselves.
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
// Now drive the change-password form directly.
resp, err = c.Get(ts.URL + "/web/change-password")
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(body))
form := url.Values{}
form.Set("current_password", plain)
form.Set("new_password", "new-stronger-password")
form.Set("new_password_confirm", "new-stronger-password")
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/change-password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusFound, resp.StatusCode)
assert.Equal(t, "/overview", resp.Header.Get("Location"))
// The flag must now be cleared on the user row.
user, err := srv.store.GetUser(context.Background())
require.NoError(t, err)
assert.False(t, user.RequiresChange,
"requires_change must clear after the operator updates the password")
}
// TestChangePasswordRequiresCSRF ensures state-changing endpoints
// refuse requests without a CSRF token (defense in depth on top of
// the double-submit comparison).
func TestChangePasswordRequiresCSRF(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
form := url.Values{}
form.Set("current_password", "irrelevant")
form.Set("new_password", "new-stronger-password")
form.Set("new_password_confirm", "new-stronger-password")
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/change-password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusForbidden, resp.StatusCode,
"change-password POST without CSRF must be 403")
}
// TestLogoutClearsSession verifies the session row is removed and
// subsequent authenticated pages redirect to login again.
func TestLogoutClearsSession(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
// Now the operator can reach /overview.
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
// Fetch CSRF, then POST /web/logout.
resp, err = c.Get(ts.URL + "/overview")
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(body))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/logout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusFound, resp.StatusCode)
// Authenticated pages must now redirect again.
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/overview", nil)
resp, err = c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusFound, resp.StatusCode)
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
}
// TestAuditRowsWrittenOnLoginAndLogout ensures the audit log
// captures the auth events with the right shape.
func TestAuditRowsWrittenOnLoginAndLogout(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
body2, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(body2))
// Logout.
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/logout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
entries, err := srv.store.RecentAudit(context.Background(), 10)
require.NoError(t, err)
var actions []string
for _, e := range entries {
actions = append(actions, e.Action)
}
assert.Contains(t, actions, "login")
assert.Contains(t, actions, "logout")
}
// TestOverviewRendersStubData wires the stubbed worker view and
// asserts /overview renders without panicking and includes the
// stubbed worker id.
func TestOverviewRendersStubData(t *testing.T) {
srv := newTestServer(t, &stubRunner{
id: "w-stub-1",
token: "abcdefghijklmnop",
lastAck: time.Now().Add(-time.Minute).UTC(),
})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body := mustBody(t, resp)
assert.Contains(t, body, "w-stub-1")
assert.Contains(t, body, "RSMon worker v")
}
// TestHealthzPublic verifies the health endpoint is reachable
// without a session and returns 200 + "ok".
func TestHealthzPublic(t *testing.T) {
srv := newTestServer(t, &stubRunner{})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/healthz")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "ok\n", string(body))
}
// TestStaticAssetsServedWithoutAuth confirms /static/ is served
// publicly so the login page can render its CSS without a session.
func TestStaticAssetsServedWithoutAuth(t *testing.T) {
srv := newTestServer(t, &stubRunner{})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/static/style.css")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), ".topbar")
}
// TestFirstRunPasswordStableAcrossRestart ensures the first-run
// password survives a process restart (the same bcrypt hash is
// re-loaded).
func TestFirstRunPasswordStableAcrossRestart(t *testing.T) {
dir := t.TempDir()
hash, err := HashPassword("seed-password-for-test")
require.NoError(t, err)
_, err = openWithSeed(dir, hash)
require.NoError(t, err)
_, err = openWithSeed(dir, hash)
require.NoError(t, err)
s3, err := openWithSeed(dir, hash)
require.NoError(t, err)
user, err := s3.GetUser(context.Background())
require.NoError(t, err)
assert.True(t, passwordMatches(user.BcryptHash, "seed-password-for-test"))
}
func openWithSeed(dir, hash string) (*Store, error) {
store, err := OpenStore(filepathJoin(dir, "webapp.db"))
if err != nil {
return nil, err
}
if _, err := store.GetUser(context.Background()); err != nil {
// No user yet, seed one.
_, err := store.CreateUser(context.Background(), hash, false)
if err != nil {
return nil, err
}
}
return store, nil
}
func passwordMatches(hash, plain string) bool {
return VerifyPassword(hash, plain) == nil
}
// helpers (kept local to avoid leaking test-only helpers into prod).
func filepathJoin(a, b string) string {
// tiny re-impl to avoid importing path/filepath at the top of
// every test case (the import is pulled in via test_helpers.go).
return a + "/" + b
}
func extractCSRFToken(t *testing.T, body string) string {
t.Helper()
const marker = `name="csrf_token" value="`
idx := strings.Index(body, marker)
require.GreaterOrEqual(t, idx, 0, "no csrf token found in body")
rest := body[idx+len(marker):]
end := strings.Index(rest, `"`)
require.GreaterOrEqual(t, end, 0, "csrf token not terminated")
return rest[:end]
}
func mustBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close() //nolint:errcheck
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return string(b)
}
// TestBasicAuthMiddlewareAcceptsCredentials exercises the HTTP Basic
// auth fast-path on /web/api/*: a correctly-configured client gets
// 200 from the cluster status endpoint without a session cookie.
func TestBasicAuthMiddlewareAcceptsCredentials(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
c := httpClient()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/cluster/status", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
// The stub server has no cluster attached, so the handler
// returns 503 "no cluster subsystem". What matters here is that
// the basic-auth check passed (no 401 with WWW-Authenticate).
assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
}
// TestBasicAuthMiddlewareRejectsBadPassword confirms an incorrect
// password returns 401 + WWW-Authenticate header so curl prompts the
// operator to retry.
func TestBasicAuthMiddlewareRejectsBadPassword(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/cluster/status", nil)
req.SetBasicAuth("alice", "wrong")
c := http.DefaultClient
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
assert.Contains(t, resp.Header.Get("WWW-Authenticate"), `Basic realm=`)
}
// TestBasicAuthLoginFormHappyPath drives the form-submission path:
// operator types username+password matching WORKER_LOGIN/WORKER_PASSWORD
// and lands on /overview.
func TestBasicAuthLoginFormHappyPath(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
c := httpClient()
form := url.Values{}
form.Set("login", "alice")
form.Set("password", "s3cret")
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusFound, resp.StatusCode,
"basic-auth login should redirect on success")
assert.Equal(t, pathOverview, resp.Header.Get("Location"))
}
// TestBasicAuthLoginFormRejectsWrongPassword ensures a wrong password
// keeps the operator on the login page (302 NOT issued) and writes an
// audit row tagged basic_auth.
func TestBasicAuthLoginFormRejectsWrongPassword(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
c := httpClient()
form := url.Values{}
form.Set("login", "alice")
form.Set("password", "wrong")
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode,
"wrong password re-renders the login page (no 302)")
entries, err := srv.store.RecentAudit(context.Background(), 5)
require.NoError(t, err)
var sawFail bool
for _, e := range entries {
if e.Action == "login_failed" && e.AuthMode == "basic_auth" {
sawFail = true
}
}
assert.True(t, sawFail, "audit log must capture basic-auth login_failed")
}
var (
_ = json.Marshal
_ = os.Getenv
)

63
internal/webapp/static/style.css Обычный файл
Просмотреть файл

@@ -0,0 +1,63 @@
:root {
--bg: #f6f7f9;
--card: #ffffff;
--fg: #1f2937;
--muted: #6b7280;
--accent: #2563eb;
--accent-fg: #ffffff;
--border: #e5e7eb;
--error: #b91c1c;
--ok: #047857;
--warn: #b45309;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.4; }
.topbar {
display: flex; align-items: center; gap: 24px;
padding: 12px 20px; background: var(--card); border-bottom: 1px solid var(--border);
}
.topbar .brand { font-weight: 700; }
.topbar nav { display: flex; gap: 12px; flex: 1; flex-wrap: wrap; }
.topbar nav a { color: var(--fg); text-decoration: none; padding: 4px 8px; border-radius: 4px; }
.topbar nav a:hover { background: var(--bg); }
.topbar .who { font-size: 0.9em; color: var(--muted); }
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.card h1 { margin-top: 0; }
.login { max-width: 480px; margin: 60px auto; }
.login form label { display: block; margin: 12px 0; }
.login form input { width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px; }
.login form button { background: var(--accent); color: var(--accent-fg); border: 0; padding: 8px 16px; border-radius: 4px; cursor: pointer; }
.login .muted { font-size: 0.85em; color: var(--muted); margin-top: 16px; }
form label { display: block; margin: 8px 0; }
form input, form select, form textarea { padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; }
form button { background: var(--accent); color: var(--accent-fg); border: 0; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
button.link { background: transparent; color: var(--accent); padding: 0; border: 0; cursor: pointer; }
button.danger { background: var(--error); }
.error { color: var(--error); }
.muted { color: var(--muted); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
table { border-collapse: collapse; width: 100%; }
table th, table td { padding: 6px 8px; text-align: left; border-bottom: 1px solid var(--border); }
table th { background: var(--bg); font-weight: 600; }
table tr:hover td { background: var(--bg); }
.logout-form { display: inline; }
.logout-form button { font-size: 0.9em; }
.footer { text-align: center; padding: 12px; color: var(--muted); font-size: 0.85em; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.metric { background: var(--bg); padding: 12px; border-radius: 6px; border: 1px solid var(--border); }
.metric .label { color: var(--muted); font-size: 0.85em; }
.metric .value { font-size: 1.4em; font-weight: 600; margin-top: 4px; }
pre.log { background: #111827; color: #e5e7eb; padding: 12px; border-radius: 6px; overflow: auto; max-height: 600px; font-size: 0.85em; white-space: pre; }

492
internal/webapp/store.go Обычный файл
Просмотреть файл

@@ -0,0 +1,492 @@
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()
}

233
internal/webapp/store_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,233 @@
package webapp
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
// Use a per-test in-memory DSN so the schema is fresh and there
// are no goroutine-leak concerns from shared cache.
store, err := OpenStore(":memory:")
require.NoError(t, err)
t.Cleanup(func() { _ = store.Close() })
return store
}
func TestStoreCreateAndGetUser(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
_, err := s.GetUser(ctx)
require.ErrorIs(t, err, sql.ErrNoRows, "fresh store must have no user")
hash, err := HashPassword("hello world")
require.NoError(t, err)
u, err := s.CreateUser(ctx, hash, false)
require.NoError(t, err)
require.NotNil(t, u)
assert.NotZero(t, u.ID)
assert.False(t, u.RequiresChange, "fresh user must not require change")
got, err := s.GetUser(ctx)
require.NoError(t, err)
assert.Equal(t, u.ID, got.ID)
assert.Equal(t, hash, got.BcryptHash)
}
func TestStoreUpdatePasswordMarksRequiresChange(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
hash, err := HashPassword("first")
require.NoError(t, err)
u, err := s.CreateUser(ctx, hash, false)
require.NoError(t, err)
newHash, err := HashPassword("second")
require.NoError(t, err)
require.NoError(t, s.UpdatePassword(ctx, u.ID, newHash))
got, err := s.GetUser(ctx)
require.NoError(t, err)
assert.Equal(t, newHash, got.BcryptHash)
assert.False(t, got.RequiresChange)
require.NoError(t, s.MarkRequiresChange(ctx, u.ID))
got, err = s.GetUser(ctx)
require.NoError(t, err)
assert.True(t, got.RequiresChange)
}
func TestStoreSessionLifecycle(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
hash, err := HashPassword("p")
require.NoError(t, err)
u, err := s.CreateUser(ctx, hash, false)
require.NoError(t, err)
sess := Session{
ID: "sess-1",
UserID: u.ID,
CSRFToken: "csrf-1",
IP: "127.0.0.1",
UA: "ua",
ExpiresAt: time.Now().Add(time.Minute).UTC(),
}
require.NoError(t, s.CreateSession(ctx, &sess))
got, err := s.GetSession(ctx, "sess-1")
require.NoError(t, err)
assert.Equal(t, sess.CSRFToken, got.CSRFToken)
assert.Equal(t, u.ID, got.UserID)
require.NoError(t, s.TouchSession(ctx, "sess-1", 5*time.Minute, 8*time.Hour))
require.NoError(t, s.DeleteSession(ctx, "sess-1"))
_, err = s.GetSession(ctx, "sess-1")
assert.ErrorIs(t, err, sql.ErrNoRows)
}
func TestStoreGetSessionExpired(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
hash, err := HashPassword("p")
require.NoError(t, err)
u, err := s.CreateUser(ctx, hash, false)
require.NoError(t, err)
sess := Session{
ID: "sess-2",
UserID: u.ID,
CSRFToken: "csrf-2",
ExpiresAt: time.Now().Add(-time.Second).UTC(),
}
require.NoError(t, s.CreateSession(ctx, &sess))
_, err = s.GetSession(ctx, "sess-2")
assert.ErrorIs(t, err, sql.ErrNoRows, "expired session must surface as no-rows")
}
func TestStoreCreateSessionValidation(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
cases := []struct {
name string
sess Session
}{
{
"empty id",
Session{CSRFToken: "c", UserID: 1, ExpiresAt: time.Now().Add(time.Minute)},
},
{
"empty csrf",
Session{ID: "x", UserID: 1, ExpiresAt: time.Now().Add(time.Minute)},
},
{
"empty expiry",
Session{ID: "x", UserID: 1, CSRFToken: "c"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := s.CreateSession(ctx, &tc.sess)
assert.Error(t, err)
})
}
}
func TestStoreAuditWriteAndRecent(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
for i := 0; i < 5; i++ {
require.NoError(t, s.WriteAudit(ctx, &AuditEntry{
Actor: "operator",
Role: "admin",
AuthMode: "local",
Action: "login",
Target: "self",
BeforeHash: "",
AfterHash: "",
}))
}
got, err := s.RecentAudit(ctx, 3)
require.NoError(t, err)
assert.Len(t, got, 3, "RecentAudit must respect limit")
// Empty action must be rejected.
err = s.WriteAudit(ctx, &AuditEntry{Action: ""})
assert.Error(t, err)
}
func TestStoreAuditPrune(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
// 10 rows older than 1h and 5 rows from "now".
for i := 0; i < 10; i++ {
require.NoError(t, s.WriteAudit(ctx, &AuditEntry{
Action: "old",
TS: now.Add(-2 * time.Hour),
}))
}
for i := 0; i < 5; i++ {
require.NoError(t, s.WriteAudit(ctx, &AuditEntry{
Action: "fresh",
TS: now,
}))
}
n, err := s.PruneAudit(ctx, time.Hour)
require.NoError(t, err)
assert.Equal(t, int64(10), n, "expected to prune exactly the 10 old rows")
got, err := s.RecentAudit(ctx, 100)
require.NoError(t, err)
require.Len(t, got, 5)
for _, e := range got {
assert.Equal(t, "fresh", e.Action, "only fresh rows must remain")
}
}
func TestStoreReplaceAndListApps(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
first := []App{
{Name: "rsmon-worker", Source: "process", PID: 100, Ports: "7401/tcp", StartTS: 1, LastSeen: time.Now().UTC(), JSONBlob: `{"foo":"bar"}`},
{Name: "postgres", Source: "process", PID: 200, Ports: "5432/tcp", StartTS: 2, LastSeen: time.Now().UTC(), JSONBlob: ""},
}
require.NoError(t, s.ReplaceApps(ctx, first))
list, err := s.ListApps(ctx)
require.NoError(t, err)
require.Len(t, list, 2)
assert.Equal(t, "rsmon-worker", list[0].Name)
assert.True(t, strings.HasPrefix(list[0].JSONBlob, "{"), "JSON blob must round-trip")
second := []App{
{Name: "redis", Source: "process", PID: 300, Ports: "6379/tcp", StartTS: 3, LastSeen: time.Now().UTC()},
}
require.NoError(t, s.ReplaceApps(ctx, second))
list, err = s.ListApps(ctx)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, "redis", list[0].Name)
}
func TestStoreOpenInvalidPath(t *testing.T) {
_, err := OpenStore("")
assert.True(t, errors.Is(err, err) || err != nil)
}

84
internal/webapp/template_helpers.go Обычный файл
Просмотреть файл

@@ -0,0 +1,84 @@
package webapp
import (
"fmt"
"strings"
"time"
)
// Template helpers. Kept in their own file so templates.go stays
// focused on parsing/wiring.
// fmtBytes renders a byte count as a human-readable string. The
// unit is picked automatically (B / KiB / MiB / GiB / TiB).
func fmtBytes(n uint64) string {
const k = 1024
if n < k {
return fmt.Sprintf("%d B", n)
}
div, exp := uint64(k), 1
for n2 := n / k; n2 >= k; n2 /= k {
div *= k
exp++
}
suffix := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}[exp-1]
return fmt.Sprintf("%.2f %s", float64(n)/float64(div), suffix)
}
// fmtPercent renders a 0..100 number as "N.NN%".
func fmtPercent(p float64) string {
return fmt.Sprintf("%.2f%%", p)
}
// fmtDuration renders a duration as "1d 2h 3m 4s". The format is
// stable across short and long uptimes.
func fmtDuration(d time.Duration) string {
if d < 0 {
d = 0
}
days := int(d / (24 * time.Hour))
d -= time.Duration(days) * 24 * time.Hour
hours := int(d / time.Hour)
d -= time.Duration(hours) * time.Hour
minutes := int(d / time.Minute)
d -= time.Duration(minutes) * time.Minute
seconds := int(d / time.Second)
switch {
case days > 0:
return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds)
case hours > 0:
return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
case minutes > 0:
return fmt.Sprintf("%dm %ds", minutes, seconds)
default:
return fmt.Sprintf("%ds", seconds)
}
}
// fmtTime renders a timestamp as "2006-01-02 15:04:05 UTC".
func fmtTime(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.UTC().Format("2006-01-02 15:04:05 UTC")
}
// fmtBool renders a bool as "yes" / "no".
func fmtBool(b bool) string {
if b {
return "yes"
}
return "no"
}
// sanitizeName strips characters that could break out of an HTML
// attribute when interpolated by a template. Template auto-escapes
// by default, but a paranoid layer here keeps audit targets safe
// when they are rendered into other contexts.
func sanitizeName(s string) string {
s = strings.ReplaceAll(s, "\x00", "")
if len(s) > 128 {
s = s[:128]
}
return s
}

98
internal/webapp/templates.go Обычный файл
Просмотреть файл

@@ -0,0 +1,98 @@
package webapp
import (
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"strings"
"sync"
)
//go:embed templates/*.html static/*
var contentFS embed.FS
// staticFS exposes the embedded static assets (CSS, JS, favicon) so
// they can be served by http.FileServer. There is no CDN dependency;
// everything ships inside the worker binary.
var staticFS = mustSubFS("static")
// mustSubFS returns the named sub-filesystem inside contentFS, or
// panics if it is missing. The embed directive above guarantees the
// dir exists at build time.
func mustSubFS(name string) fs.FS {
sub, err := fs.Sub(contentFS, name)
if err != nil {
panic(fmt.Sprintf("webapp: embedded sub %q: %v", name, err))
}
return sub
}
// Templates is a thin wrapper around html/template that lazily
// parses the embedded *.html files on first Execute. The FuncMap is
// intentionally minimal so adding global helpers does not blow up
// the per-page test surface.
type Templates struct {
mu sync.RWMutex
cache map[string]*template.Template
funcs template.FuncMap
}
// loadTemplates parses every embedded template into the cache. The
// shared layout (templates/layout.html) is parsed into every page
// so each {{template "content" .}} substitution resolves cleanly.
func loadTemplates() (*Templates, error) {
t := &Templates{
cache: map[string]*template.Template{},
funcs: template.FuncMap{
"fmtBytes": fmtBytes,
"fmtPercent": fmtPercent,
"fmtDuration": fmtDuration,
"fmtTime": fmtTime,
"fmtBool": fmtBool,
"hasPrefix": strings.HasPrefix,
"sanitizeName": sanitizeName,
},
}
pages, err := fs.ReadDir(contentFS, "templates")
if err != nil {
return nil, fmt.Errorf("webapp: read templates: %w", err)
}
for _, p := range pages {
if p.IsDir() || !strings.HasSuffix(p.Name(), ".html") {
continue
}
if _, err := t.parse(p.Name()); err != nil {
return nil, fmt.Errorf("webapp: parse %s: %w", p.Name(), err)
}
}
return t, nil
}
// parse loads the named template plus the shared layout. Page
// templates "{{define" body"}}" themselves.
func (t *Templates) parse(name string) (*template.Template, error) {
tmpl := template.New(name).Funcs(t.funcs)
if _, err := tmpl.ParseFS(contentFS, "templates/layout.html"); err != nil {
return nil, err
}
if _, err := tmpl.ParseFS(contentFS, "templates/"+name); err != nil {
return nil, err
}
t.cache[name] = tmpl
return tmpl, nil
}
// Execute renders the named page template into w. The page is
// wrapped by the shared layout block via the "layout" template
// defined in templates/layout.html.
func (t *Templates) Execute(w io.Writer, name string, data interface{}) error {
t.mu.RLock()
tmpl, ok := t.cache[name]
t.mu.RUnlock()
if !ok {
return fmt.Errorf("webapp: no such template %q", name)
}
return tmpl.ExecuteTemplate(w, "layout", data)
}

20
internal/webapp/templates/app_detail.html Обычный файл
Просмотреть файл

@@ -0,0 +1,20 @@
{{define "body"}}<section class="card">
<h1>{{.App.Name}}</h1>
<dl>
<dt>Source</dt><dd>{{.App.Source}}</dd>
<dt>PID</dt><dd>{{.App.PID}}</dd>
<dt>Open ports</dt>
<dd>
{{if .App.Ports}}
<ul>
{{range .App.Ports}}<li>{{.}}</li>{{end}}
</ul>
{{else}}<span class="muted">none</span>{{end}}
</dd>
<dt>Cmdline</dt><dd><code>{{.App.Cmdline}}</code></dd>
<dt>Cwd</dt><dd><code>{{.App.CWD}}</code></dd>
<dt>Last seen</dt><dd>{{fmtTime .App.LastSeen}}</dd>
<dt>Last check</dt><dd><span class="muted">(Phase 1: no per-app probes)</span></dd>
</dl>
<p><a href="/apps">← All apps</a></p>
</section>{{end}}

32
internal/webapp/templates/apps.html Обычный файл
Просмотреть файл

@@ -0,0 +1,32 @@
{{define "body"}}<section class="card">
<h1>Discovered apps</h1>
<p class="muted">Refreshed every 60s from <code>/proc</code>. Docker Compose / nginx / systemd discoveries land with the deploymentd integration (see docs/plans/inventory-management.md §7.2); once the host runs <code>deploymentd</code> and posts to RSMon, the matching Site + Deployment rows appear here side-by-side with the per-process list.</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Source</th>
<th>PID</th>
<th>Ports</th>
<th>Uptime</th>
<th>Last check</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Apps}}
<tr>
<td>{{.Name}}</td>
<td>{{.Source}}</td>
<td>{{.PID}}</td>
<td>{{range .Ports}}{{.}}<br>{{end}}</td>
<td>{{fmtTime .LastSeen}}</td>
<td></td>
<td><a href="/apps/{{.PID}}">details</a></td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No apps discovered yet — first refresh runs within 60s of start.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,21 @@
{{define "body"}}<section class="card">
<h1>Change password</h1>
<p class="muted">The operator must change the first-run password before they can use the worker web app.</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form action="/web/change-password" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
Current password
<input type="password" name="current_password" required>
</label>
<label>
New password (min {{.MinStrength}} chars)
<input type="password" name="new_password" required minlength="{{.MinStrength}}">
</label>
<label>
Confirm new password
<input type="password" name="new_password_confirm" required minlength="{{.MinStrength}}">
</label>
<button type="submit">Save</button>
</form>
</section>{{end}}

34
internal/webapp/templates/checks.html Обычный файл
Просмотреть файл

@@ -0,0 +1,34 @@
{{define "body"}}<section class="card">
<h1>Recent checks</h1>
<p class="muted">Last 50 results produced by this worker.</p>
<p><button type="button" disabled title="{{.RunNowTooltip}}">Run now</button>
<span class="muted">{{.RunNowTooltip}}</span></p>
<table>
<thead>
<tr>
<th>Monitor</th>
<th>Check</th>
<th>Kind</th>
<th>Host</th>
<th>State</th>
<th>Duration (ms)</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td>{{.MonitorID}}</td>
<td>{{.CheckID}}</td>
<td>{{.Kind}}</td>
<td>{{.Host}}</td>
<td>{{.State}}</td>
<td>{{.DurationMs}}</td>
<td>{{.Error}}</td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No checks yet. Once the main app assigns jobs to this worker, results will appear here.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

39
internal/webapp/templates/layout.html Обычный файл
Просмотреть файл

@@ -0,0 +1,39 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">RSMon worker</div>
{{if not .IsLogin}}
<nav>
<a href="/overview">Overview</a>
<a href="/apps">Apps</a>
<a href="/checks">Checks</a>
<a href="/notifications">Notifications</a>
<a href="/logs">Logs</a>
<a href="/status">Status</a>
<a href="/settings">Settings</a>
<a href="/updates">Updates</a>
</nav>
<div class="who">
<form action="/web/logout" method="post" class="logout-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="link">Logout</button>
</form>
</div>
{{end}}
</header>
<main>
{{template "body" .}}
</main>
<footer class="footer">
<span>RSMon worker v{{.Version}} · built {{.BuildDate}}</span>
</footer>
</body>
</html>{{end}}

33
internal/webapp/templates/login.html Обычный файл
Просмотреть файл

@@ -0,0 +1,33 @@
{{define "body"}}<section class="card login">
<h1>RSMon worker login</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form action="/web/login" method="post" autocomplete="off">
<input type="hidden" name="next" value="{{.NextURL}}">
{{if .BasicAuth}}
<label>
Username
<input type="text" name="login" autocomplete="username" autofocus required value="">
</label>
{{end}}
<label>
Password
<input type="password" name="password" autocomplete="current-password" {{if not .BasicAuth}}autofocus{{end}} required minlength="1">
</label>
<button type="submit">Sign in</button>
</form>
{{if .BasicAuth}}
<p class="muted">
This worker is configured with HTTP basic auth
(<code>WORKER_LOGIN</code>/<code>WORKER_PASSWORD</code>). Use the
credentials from your environment. You can also pass them as
<code>Authorization: Basic</code> on
<code>/web/api/*</code> for scripted access.
</p>
{{else}}
<p class="muted">
On first start the worker printed a one-time password to its log.
Read it via <code>journalctl -u rsmon-worker</code> or your
container runtime.
</p>
{{end}}
</section>{{end}}

17
internal/webapp/templates/logs.html Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
{{define "body"}}<section class="card">
<h1>Worker logs</h1>
<p class="muted">In-memory ring buffer (capacity {{.BufferCap}}, holding {{.BufferSize}} lines). Phase 1 shows worker-binary logs only; stack and host logs land in later phases.</p>
<form method="get" action="/logs" class="tail-form">
<label>Show last
<select name="tail" onchange="this.form.submit()">
<option value="200" {{if eq .Tail 200}}selected{{end}}>200</option>
<option value="500" {{if eq .Tail 500}}selected{{end}}>500</option>
<option value="1000" {{if eq .Tail 1000}}selected{{end}}>1000</option>
<option value="5000" {{if eq .Tail 5000}}selected{{end}}>5000</option>
</select>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<pre class="log">{{range .Lines}}{{.}}
{{end}}</pre>
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,32 @@
{{define "body"}}<section class="card">
<h1>Recent notifications</h1>
<p class="muted">Last 50 notifications emitted by this worker (Phase 1: selfcheck alerts only).</p>
<p><button type="button" disabled title="{{.ResendTooltip}}">Resend selected</button>
<span class="muted">{{.ResendTooltip}}</span></p>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Subject</th>
<th>Body</th>
<th>Status</th>
<th>Error</th>
<th>When</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td>{{.Channel}} ({{.Kind}})</td>
<td>{{.Subject}}</td>
<td><code>{{.Body}}</code></td>
<td>{{if .OK}}<span class="ok">delivered</span>{{else}}<span class="error">failed</span>{{end}}</td>
<td>{{.Error}}</td>
<td>{{fmtTime .At}}</td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">No notifications yet.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

56
internal/webapp/templates/overview.html Обычный файл
Просмотреть файл

@@ -0,0 +1,56 @@
{{define "body"}}<section class="card">
<h1>Overview</h1>
<div class="cards">
<div class="metric">
<div class="label">Worker ID</div>
<div class="value">{{.WorkerID}}</div>
</div>
<div class="metric">
<div class="label">Region</div>
<div class="value">{{.RegionCode}}</div>
</div>
<div class="metric">
<div class="label">State</div>
<div class="value">{{.WorkerState}}</div>
</div>
<div class="metric">
<div class="label">Last heartbeat ack</div>
<div class="value">{{fmtTime .LastAckAt}}</div>
</div>
<div class="metric">
<div class="label">Discovered apps</div>
<div class="value">{{.DiscoveredCount}}</div>
</div>
<div class="metric">
<div class="label">Checks (last 1000)</div>
<div class="value">{{.ResultCount}}</div>
</div>
<div class="metric">
<div class="label">Notifications (last 1000)</div>
<div class="value">{{.NotifCount}}</div>
</div>
<div class="metric">
<div class="label">CPU</div>
<div class="value">{{fmtPercent .Snapshot.CPU.TotalPct}}</div>
</div>
<div class="metric">
<div class="label">Memory used</div>
<div class="value">{{fmtPercent .Snapshot.Memory.UsedPct}}</div>
</div>
<div class="metric">
<div class="label">Uptime</div>
<div class="value">{{fmtDuration .Snapshot.Uptime}}</div>
</div>
<div class="metric">
<div class="label">Load (1/5/15)</div>
<div class="value">{{printf "%.2f / %.2f / %.2f" .Snapshot.Load.One .Snapshot.Load.Five .Snapshot.Load.Fifteen}}</div>
</div>
</div>
</section>
<section class="card">
<h2>Recent log lines</h2>
<pre class="log">{{range .LogTail}}{{.}}
{{end}}</pre>
<p><a href="/logs">View all logs</a></p>
</section>{{end}}

31
internal/webapp/templates/settings.html Обычный файл
Просмотреть файл

@@ -0,0 +1,31 @@
{{define "body"}}<section class="card">
<h1>Settings</h1>
<p class="muted">Phase 1 exposes only the worker fields; web-app settings (theme, session timeout, basic-auth password rotate) land in later phases.</p>
<h2>Worker</h2>
<dl>
<dt>Worker ID</dt><dd>{{.WorkerID}}</dd>
<dt>Region</dt><dd>{{.RegionCode}}</dd>
<dt>Version</dt><dd>{{.WorkerVersion}}</dd>
<dt>Capabilities</dt><dd>
{{if .Capabilities}}
<ul>{{range .Capabilities}}<li><code>{{.}}</code></li>{{end}}</ul>
{{else}}<span class="muted"></span>{{end}}
</dd>
<dt>Last heartbeat ack</dt><dd>{{fmtTime .LastAckAt}}</dd>
</dl>
<h2>Token</h2>
<p>
Current token: <code>{{.TokenMasked}}</code><br>
Last rotated: {{fmtTime .TokenRotatedAt}}
</p>
<form action="/settings/rotate-token" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="danger"
onclick="return confirm('Rotate the worker token? The old token will be invalidated immediately and the websocket will reconnect with the new one.')">
Rotate token
</button>
<span class="muted">Issues a fresh token via the main app API and updates the in-memory runner config. On failure the old token is kept.</span>
</form>
</section>{{end}}

82
internal/webapp/templates/status.html Обычный файл
Просмотреть файл

@@ -0,0 +1,82 @@
{{define "body"}}<section class="card">
<h1>Server status</h1>
<p class="muted">Sampled every 5s from <code>/proc</code> and statfs. CLI tools (lsblk, smartctl, sensors) are not bundled with the worker; install them on the host separately if you want richer hardware data. SMART self-tests are out of scope.</p>
<h2>CPU</h2>
<div class="cards">
<div class="metric"><div class="label">Total busy</div><div class="value">{{fmtPercent .Snapshot.CPU.TotalPct}}</div></div>
<div class="metric"><div class="label">User</div><div class="value">{{fmtPercent .Snapshot.CPU.UserPct}}</div></div>
<div class="metric"><div class="label">System</div><div class="value">{{fmtPercent .Snapshot.CPU.SystemPct}}</div></div>
<div class="metric"><div class="label">Idle</div><div class="value">{{fmtPercent .Snapshot.CPU.IdlePct}}</div></div>
<div class="metric"><div class="label">I/O wait</div><div class="value">{{fmtPercent .Snapshot.CPU.IOWaitPct}}</div></div>
</div>
<h2>Load</h2>
<div class="cards">
<div class="metric"><div class="label">1 min</div><div class="value">{{printf "%.2f" .Snapshot.Load.One}}</div></div>
<div class="metric"><div class="label">5 min</div><div class="value">{{printf "%.2f" .Snapshot.Load.Five}}</div></div>
<div class="metric"><div class="label">15 min</div><div class="value">{{printf "%.2f" .Snapshot.Load.Fifteen}}</div></div>
</div>
<h2>Memory</h2>
<table>
<tbody>
<tr><th>Total</th><td>{{fmtBytes .Snapshot.Memory.Total}}</td></tr>
<tr><th>Available</th><td>{{fmtBytes .Snapshot.Memory.Available}} ({{fmtPercent .Snapshot.Memory.AvailablePct}})</td></tr>
<tr><th>Free</th><td>{{fmtBytes .Snapshot.Memory.Free}}</td></tr>
<tr><th>Buffers</th><td>{{fmtBytes .Snapshot.Memory.Buffers}}</td></tr>
<tr><th>Cached</th><td>{{fmtBytes .Snapshot.Memory.Cached}}</td></tr>
<tr><th>Swap total</th><td>{{fmtBytes .Snapshot.Memory.SwapTotal}}</td></tr>
<tr><th>Swap free</th><td>{{fmtBytes .Snapshot.Memory.SwapFree}}</td></tr>
</tbody>
</table>
<h2>Uptime</h2>
<div class="cards">
<div class="metric"><div class="label">Uptime</div><div class="value">{{fmtDuration .Snapshot.Uptime}}</div></div>
<div class="metric"><div class="label">Boot at</div><div class="value">{{fmtTime .Snapshot.BootAt}}</div></div>
<div class="metric"><div class="label">Last sample</div><div class="value">{{fmtTime .SnapshotAt}}</div></div>
</div>
<h2>Network</h2>
<table>
<thead><tr><th>Iface</th><th>RX bytes</th><th>TX bytes</th><th>RX pkts</th><th>TX pkts</th><th>RX errs</th><th>TX errs</th><th>RX drops</th><th>TX drops</th></tr></thead>
<tbody>
{{range .Snapshot.Networks}}
<tr>
<td>{{.Name}}</td>
<td>{{fmtBytes .RxBytes}}</td>
<td>{{fmtBytes .TxBytes}}</td>
<td>{{.RxPkt}}</td>
<td>{{.TxPkt}}</td>
<td>{{.RxErr}}</td>
<td>{{.TxErr}}</td>
<td>{{.RxDrop}}</td>
<td>{{.TxDrop}}</td>
</tr>
{{else}}
<tr><td colspan="9" class="muted">No network interfaces detected.</td></tr>
{{end}}
</tbody>
</table>
<h2>Mount points</h2>
<table>
<thead><tr><th>Mount</th><th>Device</th><th>FS</th><th>Total</th><th>Free</th><th>Used</th><th>Use%</th></tr></thead>
<tbody>
{{range .Snapshot.Disks}}
<tr>
<td>{{.Mount}}</td>
<td>{{.Device}}</td>
<td>{{.FSType}}</td>
<td>{{fmtBytes .Total}}</td>
<td>{{fmtBytes .Free}}</td>
<td>{{fmtBytes .Used}}</td>
<td>{{fmtPercent .UsedPct}}</td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No mount points parsed from /proc/mounts.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

17
internal/webapp/templates/updates.html Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
{{define "body"}}<section class="card">
<h1>Updates</h1>
<p class="muted">Current binary version compared against the latest release. When <code>WORKER_RELEASE_URL</code> is unset the "latest known" cell shows the placeholder <code>v1 (dev)</code>. Pull and restart stays disabled until Docker management lands (requires sudo / docker socket access).</p>
<dl>
<dt>Current version</dt><dd><code>{{.CurrentVersion}}</code></dd>
<dt>Latest known</dt><dd><code>{{.LatestKnown}}</code></dd>
<dt>In sync?</dt><dd>
{{if eq .CurrentVersion .LatestKnown}}
<span class="ok">yes</span>
{{else}}
<span class="warn">update available</span>
{{end}}
</dd>
</dl>
<button type="button" disabled title="{{.PullTooltip}}">Pull and restart</button>
<p class="muted">{{.PullTooltip}}</p>
</section>{{end}}

189
internal/webapp/test_helpers.go Обычный файл
Просмотреть файл

@@ -0,0 +1,189 @@
package webapp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"rsgit.ru/rsmon/rsmon/internal/distworker"
)
// _ = assert is a guard so test_helpers.go stays import-clean when
// only the helpers below are needed.
var _ = assert.New
// distworkerHTTPConfig is a local alias for distworker.HTTPConfig so
// the test stub matches the WorkerView interface signature.
type _ = distworker.HTTPConfig
// stubRunner is a minimal WorkerView used by tests. RecentResults
// and RecentNotifications return the slices we configured; the rest
// are simple getters.
type stubRunner struct {
id string
region string
version string
caps []string
lastAck time.Time
token string
rotated time.Time
results []ResultRow
notifs []NotificationRow
// masterUp / masterAt let tests pin a specific
// /api/peer/status response. The default is the zero value
// ("no probe yet" / nil up), matching the runner's pre-Start
// behavior.
masterUp *bool
masterAt time.Time
}
// distworkerHTTPConfig is a local alias for distworker.HTTPConfig so
// the test stub does not pull in the worker package's full surface.
type distworkerHTTPConfig = distworker.HTTPConfig
func (s *stubRunner) HTTPConfig() distworker.HTTPConfig { return distworker.HTTPConfig{} }
func (s *stubRunner) Token() string { return s.token }
func (s *stubRunner) TokenRotatedAt() time.Time { return s.rotated }
func (s *stubRunner) WorkerID() string { return s.id }
func (s *stubRunner) RegionCode() string { return s.region }
func (s *stubRunner) WorkerVersion() string { return s.version }
func (s *stubRunner) WorkerCapabilities() []string { return s.caps }
func (s *stubRunner) LastHeartbeatAck() time.Time { return s.lastAck }
func (s *stubRunner) RecentResults(_ int) []ResultRow { return append([]ResultRow{}, s.results...) }
func (s *stubRunner) RecentNotifications(_ int) []NotificationRow {
return append([]NotificationRow{}, s.notifs...)
}
func (s *stubRunner) MasterStatus() (*bool, time.Time) { return s.masterUp, s.masterAt }
// newTestServer builds an in-memory Server with a stubbed worker
// view and no DB persistence (dataDir is a t.TempDir()). The
// inventory and metrics goroutines are NOT started so tests stay
// hermetic.
func newTestServer(t *testing.T, runner WorkerView) *Server {
t.Helper()
return newTestServerWithBasicAuth(t, runner, "", "")
}
// newTestServerWithBasicAuth is the basic-auth-aware variant of
// newTestServer. login == "" or password == "" disables basic auth.
func newTestServerWithBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server {
t.Helper()
dir := t.TempDir()
cfg := Config{
Addr: "127.0.0.1:0",
DataDir: dir,
BasicAuthLogin: login,
BasicAuthPassword: password,
}
srv, err := New(cfg, &Deps{
Runner: runner,
Version: "test",
BuildDate: "test-build",
StartedAt: time.Now().UTC(),
})
require.NoError(t, err)
t.Cleanup(func() {
_ = srv.Close(context.Background())
})
return srv
}
// newHTTPTestServer wraps srv.Handler in an httptest.Server so tests
// can dial a real loopback port and let cookie jar + redirects work
// naturally.
func newHTTPTestServer(t *testing.T, srv *Server) *httptest.Server {
t.Helper()
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts
}
// httpClient returns a cookie-aware *http.Client that does not
// follow redirects automatically (tests want to see the 302).
func httpClient() *http.Client {
jar, _ := cookiejar.New(nil)
return &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
}
// loginAsFirstRun provisions a fresh password via the same path the
// cmd binary uses (first-run hash written to the store, plaintext
// returned), then logs the operator in by POSTing the credentials.
// The cookie jar is returned so callers can re-use the session.
//
// The first-run flow is frictionless: the login lands on /overview
// without bouncing through /web/change-password. The flag is still
// recorded on the user row for a future hardening knob.
func loginAsFirstRun(t *testing.T, baseURL string, srv *Server) (*http.Client, string) {
t.Helper()
plain, err := GenerateFirstRunPassword()
require.NoError(t, err)
hash, err := HashPassword(plain)
require.NoError(t, err)
_, err = srv.store.CreateFirstRunUser(context.Background(), hash)
require.NoError(t, err)
c := httpClient()
form := url.Values{}
form.Set("password", plain)
req, _ := http.NewRequest(http.MethodPost, baseURL+"/web/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusFound, resp.StatusCode,
"login should redirect on success")
require.Equal(t, "/overview", resp.Header.Get("Location"))
return c, plain
}
// provisionFirstRunUser seeds the store with a user whose bcrypt hash
// matches the supplied plaintext and whose requires_change flag is
// set. loginAsFirstRun wraps the round trip that consumes the seed.
func provisionFirstRunUser(t *testing.T, srv *Server, plain string) {
t.Helper()
hash, err := HashPassword(plain)
require.NoError(t, err)
_, err = srv.store.CreateFirstRunUser(context.Background(), hash)
require.NoError(t, err)
}
// clearRequiresChange flips the requires_change flag off after the
// operator has changed their password (in tests we skip the change
// form to assert the regular page flow).
func clearRequiresChange(t *testing.T, srv *Server) {
t.Helper()
user, err := srv.store.GetUser(context.Background())
require.NoError(t, err)
require.NoError(t, srv.store.MarkRequiresChange(context.Background(), user.ID))
// MarkRequiresChange sets it to 1; we want it off. Use a direct
// helper.
_, err = srv.store.db.ExecContext(context.Background(),
`UPDATE webapp_users SET requires_change = 0 WHERE id = ?`, user.ID)
require.NoError(t, err)
}
var (
_ = os.Getenv
_ = filepath.Join
_ = json.Marshal
_ = bytes.NewReader
_ = io.Copy
_ = fmt.Sprintf
)