Remove operator auth — trust the LAN
CI / Lint + build + test (push) Failing after 5m15s

Can't log in from a fresh LXC deploy, and the service is LAN-only by
design. Rip out the whole bcrypt-password / signed-cookie session
layer: internal/auth, login templates, gen-admin-password binary +
Makefile targets, auth config block, login/logout routes and the
RequireSession middleware wrap. Agent bearer-token auth on
/api/v1/runs/{id}/* is untouched.

Operators who want a password can front the service with a reverse
proxy — noted in README and docs/operations.md.
This commit is contained in:
2026-04-17 22:31:49 -04:00
parent 273e7593bc
commit 42da48864f
19 changed files with 52 additions and 492 deletions
-34
View File
@@ -11,7 +11,6 @@ import (
"github.com/go-chi/chi/v5"
"gopkg.in/yaml.v3"
"vetting/internal/auth"
"vetting/internal/events"
"vetting/internal/model"
"vetting/internal/orchestrator"
@@ -23,7 +22,6 @@ type UI struct {
Hosts *store.Hosts
Runs *store.Runs
Artifacts *store.Artifacts
Auth *auth.Manager
EventHub *events.Hub
Runner *orchestrator.Runner
Tiles *TileEnricher
@@ -93,38 +91,6 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (u *UI) LoginForm(w http.ResponseWriter, r *http.Request) {
next := r.URL.Query().Get("next")
if next == "" {
next = "/"
}
_ = templates.Login("", next).Render(r.Context(), w)
}
func (u *UI) LoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
password := r.PostForm.Get("password")
next := r.PostForm.Get("next")
if next == "" || !strings.HasPrefix(next, "/") {
next = "/"
}
if !u.Auth.VerifyPassword(password) {
w.WriteHeader(http.StatusUnauthorized)
_ = templates.Login("Invalid password.", next).Render(r.Context(), w)
return
}
u.Auth.Issue(w, r)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (u *UI) Logout(w http.ResponseWriter, r *http.Request) {
u.Auth.Clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
_ = templates.Registration(templates.RegistrationForm{}).Render(r.Context(), w)
}
-64
View File
@@ -1,64 +0,0 @@
package auth
import (
"net/http"
)
// RequireSession redirects unauthenticated requests to /login.
func (m *Manager) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := m.Validate(r); err != nil {
if acceptsHTML(r) {
http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func acceptsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
if accept == "" {
return true
}
for _, part := range splitComma(accept) {
if part == "text/html" || part == "*/*" {
return true
}
}
return false
}
func splitComma(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
out = append(out, trimSpace(s[start:i]))
start = i + 1
} else if s[i] == ';' {
out = append(out, trimSpace(s[start:i]))
for i < len(s) && s[i] != ',' {
i++
}
start = i + 1
}
}
if start < len(s) {
out = append(out, trimSpace(s[start:]))
}
return out
}
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
-100
View File
@@ -1,100 +0,0 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const cookieName = "vetting_session"
type Manager struct {
PasswordHash string
Secret []byte
TTL time.Duration
}
func (m *Manager) VerifyPassword(password string) bool {
if m.PasswordHash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(m.PasswordHash), []byte(password)) == nil
}
// Issue writes a signed session cookie valid for m.TTL.
func (m *Manager) Issue(w http.ResponseWriter, r *http.Request) {
expiry := time.Now().Add(m.TTL).Unix()
payload := strconv.FormatInt(expiry, 10)
sig := m.sign(payload)
value := payload + "." + sig
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: value,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expiry, 0),
})
}
func (m *Manager) Clear(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
}
var errInvalidSession = errors.New("invalid session")
// Validate returns nil if the request's cookie is present, signed, and not expired.
func (m *Manager) Validate(r *http.Request) error {
c, err := r.Cookie(cookieName)
if err != nil {
return errInvalidSession
}
parts := strings.SplitN(c.Value, ".", 2)
if len(parts) != 2 {
return errInvalidSession
}
payload, sig := parts[0], parts[1]
expected := m.sign(payload)
if !hmac.Equal([]byte(sig), []byte(expected)) {
return errInvalidSession
}
expiry, err := strconv.ParseInt(payload, 10, 64)
if err != nil {
return errInvalidSession
}
if time.Now().Unix() >= expiry {
return errInvalidSession
}
return nil
}
func (m *Manager) sign(payload string) string {
mac := hmac.New(sha256.New, m.Secret)
_, _ = mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// BcryptHash is a helper used by the gen-admin-password tool.
func BcryptHash(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("bcrypt: %w", err)
}
return string(b), nil
}
-22
View File
@@ -1,7 +1,6 @@
package config
import (
"encoding/hex"
"fmt"
"os"
@@ -13,7 +12,6 @@ type Config struct {
Database Database `yaml:"database"`
Artifacts Artifacts `yaml:"artifacts"`
Logs Logs `yaml:"logs"`
Auth Auth `yaml:"auth"`
Dispatcher Dispatcher `yaml:"dispatcher"`
Janitor Janitor `yaml:"janitor"`
PXE PXE `yaml:"pxe"`
@@ -52,23 +50,6 @@ type Janitor struct {
IntervalMinutes int `yaml:"interval_minutes"` // 0 = 60
}
type Auth struct {
AdminPasswordBcrypt string `yaml:"admin_password_bcrypt"`
SessionSecretHex string `yaml:"session_secret_hex"`
SessionTTLHours int `yaml:"session_ttl_hours"`
}
func (a Auth) SessionSecret() ([]byte, error) {
b, err := hex.DecodeString(a.SessionSecretHex)
if err != nil {
return nil, fmt.Errorf("session_secret_hex: %w", err)
}
if len(b) < 32 {
return nil, fmt.Errorf("session_secret_hex must decode to at least 32 bytes, got %d", len(b))
}
return b, nil
}
type Dispatcher struct {
MaxConcurrentRuns int `yaml:"max_concurrent_runs"`
}
@@ -132,9 +113,6 @@ func Load(path string) (*Config, error) {
if c.Logs.Dir == "" {
c.Logs.Dir = "./var/logs"
}
if c.Auth.SessionTTLHours == 0 {
c.Auth.SessionTTLHours = 24
}
if c.Dispatcher.MaxConcurrentRuns == 0 {
c.Dispatcher.MaxConcurrentRuns = 3
}
+11 -22
View File
@@ -11,12 +11,10 @@ import (
"github.com/go-chi/chi/v5/middleware"
"vetting/internal/api"
"vetting/internal/auth"
"vetting/internal/web"
)
type Deps struct {
Auth *auth.Manager
UI *api.UI
Agent *api.Agent
LiveDir string // directory containing vmlinuz + initrd.img; "" disables /live
@@ -38,13 +36,8 @@ func NewRouter(d Deps) http.Handler {
r.Handle("/live/*", http.StripPrefix("/live/", http.FileServer(http.Dir(d.LiveDir))))
}
// Public (no session required) endpoints.
r.Get("/login", d.UI.LoginForm)
r.Post("/login", d.UI.LoginSubmit)
r.Post("/logout", d.UI.Logout)
// Agent / PXE endpoints — authenticated per-request by bearer token
// or by the unforgeable MAC path parameter, never by the UI session.
// or by the unforgeable MAC path parameter.
r.Get("/ipxe/{mac}", d.Agent.IPXEScript)
r.Route("/api/v1/runs/{id}", func(r chi.Router) {
r.Post("/hello", d.Agent.Hello)
@@ -56,20 +49,16 @@ func NewRouter(d Deps) http.Handler {
r.Post("/sensor", d.Agent.Sensor)
})
// Session-gated browser UI.
r.Group(func(r chi.Router) {
r.Use(d.Auth.RequireSession)
r.Get("/", d.UI.Dashboard)
r.Get("/hosts/new", d.UI.NewHostForm)
r.Post("/hosts", d.UI.CreateHost)
r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
r.Post("/hosts/{id}/start", d.UI.StartRun)
r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage)
r.Get("/reports/{runID}", d.UI.Report)
r.Get("/events", d.UI.SSE)
})
// Browser UI — no auth; bind to loopback or LAN only, or front
// with a reverse proxy if you need a password.
r.Get("/", d.UI.Dashboard)
r.Get("/hosts/new", d.UI.NewHostForm)
r.Post("/hosts", d.UI.CreateHost)
r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
r.Post("/hosts/{id}/start", d.UI.StartRun)
r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage)
r.Get("/reports/{runID}", d.UI.Report)
r.Get("/events", d.UI.SSE)
return r
}
-3
View File
@@ -20,9 +20,6 @@ templ Layout(title string) {
</nav>
<div class="session">
<span class="heartbeat" hx-ext="sse" sse-connect="/events" sse-swap="heartbeat">·</span>
<form method="post" action="/logout" class="logout-form">
<button type="submit">Log out</button>
</form>
</div>
</header>
<main>
+2 -2
View File
@@ -42,7 +42,7 @@ func Layout(title string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-Y4gc0CK6Kg4hmulDc1rNM+vbMvjbW/5rRCA6pC5gj5dLV1/4+OZGkQpJtHQvQTCr\" crossorigin=\"anonymous\"></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span><form method=\"post\" action=\"/logout\" class=\"logout-form\"><button type=\"submit\">Log out</button></form></div></header><main>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-Y4gc0CK6Kg4hmulDc1rNM+vbMvjbW/5rRCA6pC5gj5dLV1/4+OZGkQpJtHQvQTCr\" crossorigin=\"anonymous\"></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 41, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 38, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
-20
View File
@@ -1,20 +0,0 @@
package templates
templ Login(errMsg, next string) {
@BareLayout("Sign in") {
<div class="login-card">
<h1>Vetting</h1>
if errMsg != "" {
<div class="error">{ errMsg }</div>
}
<form method="post" action="/login">
<input type="hidden" name="next" value={ next }/>
<label>
Password
<input type="password" name="password" autofocus required/>
</label>
<button type="submit">Sign in</button>
</form>
</div>
}
}
-94
View File
@@ -1,94 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Login(errMsg, next string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"login-card\"><h1>Vetting</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/login.templ`, Line: 8, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(next)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/login.templ`, Line: 11, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"> <label>Password <input type=\"password\" name=\"password\" autofocus required></label> <button type=\"submit\">Sign in</button></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = BareLayout("Sign in").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate