feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
45
internal/webapp/auth_password.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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}}
|
||||
21
internal/webapp/templates/change_password.html
Обычный файл
21
internal/webapp/templates/change_password.html
Обычный файл
@@ -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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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}}
|
||||
32
internal/webapp/templates/notifications.html
Обычный файл
32
internal/webapp/templates/notifications.html
Обычный файл
@@ -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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
)
|
||||
Ссылка в новой задаче
Block a user