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
+1 -9
View File
@@ -28,14 +28,6 @@ agent: ## Build agent for host OS (handy for unit testing only — real agent ru
agent-linux: ## Cross-build agent for linux-amd64 (consumed by live-image build) agent-linux: ## Cross-build agent for linux-amd64 (consumed by live-image build)
$(GOOS_LINUX) go build -ldflags="$(LDFLAGS)" -o bin/vetting-agent.linux-amd64 ./cmd/vetting-agent $(GOOS_LINUX) go build -ldflags="$(LDFLAGS)" -o bin/vetting-agent.linux-amd64 ./cmd/vetting-agent
.PHONY: gen-admin-password
gen-admin-password: ## Build the bcrypt password generator
go build -o bin/gen-admin-password$(if $(filter Windows%,$(UNAME_S)),.exe,) ./tools/gen-admin-password
.PHONY: gen-admin-password-linux
gen-admin-password-linux: ## Cross-build the bcrypt password generator for linux-amd64
$(GOOS_LINUX) go build -ldflags="$(LDFLAGS)" -o bin/gen-admin-password-linux-amd64 ./tools/gen-admin-password
.PHONY: tidy .PHONY: tidy
tidy: ## go mod tidy tidy: ## go mod tidy
go mod tidy go mod tidy
@@ -68,7 +60,7 @@ endif
$(MAKE) -C live-image all $(MAKE) -C live-image all
.PHONY: all .PHONY: all
all: orchestrator agent gen-admin-password ## Build everything buildable on host OS all: orchestrator agent ## Build everything buildable on host OS
.PHONY: run .PHONY: run
run: orchestrator ## Build and run orchestrator with example config run: orchestrator ## Build and run orchestrator with example config
+7 -11
View File
@@ -22,20 +22,16 @@ notifications.
## Quick start (local, against QEMU) ## Quick start (local, against QEMU)
```bash ```bash
# 1. Build
make all make all
# 2. Generate an admin password hash and paste it into the config.
./bin/gen-admin-password 'your-password'
# Edit deploy/vetting.example.yaml:
# auth.admin_password_bcrypt = <that hash>
# auth.session_secret_hex = $(openssl rand -hex 32)
# 3. Run
./bin/vetting --config deploy/vetting.example.yaml ./bin/vetting --config deploy/vetting.example.yaml
# → http://localhost:8080 # → http://localhost:8080
``` ```
The UI has no built-in auth — bind to loopback or LAN only, or front
the service with a reverse proxy (Caddy/nginx basic-auth) if you
want a password. The agent↔orchestrator channel keeps its own
bearer-token auth and is unaffected.
For a full end-to-end QEMU walk-through (bridge setup, host registration, For a full end-to-end QEMU walk-through (bridge setup, host registration,
PXE boot), see [docs/operations.md § First vetting run](docs/operations.md#first-vetting-run). PXE boot), see [docs/operations.md § First vetting run](docs/operations.md#first-vetting-run).
@@ -53,7 +49,7 @@ which lays down the binary, systemd unit, example config, and
`vetting` service user. Then: `vetting` service user. Then:
```bash ```bash
# Edit /etc/vetting/vetting.yaml (bcrypt password, session secret, public URL) # Edit /etc/vetting/vetting.yaml (server.bind + server.public_url)
sudo systemctl enable --now vetting sudo systemctl enable --now vetting
journalctl -fu vetting journalctl -fu vetting
``` ```
@@ -80,7 +76,7 @@ live-image/ mkosi config for the PXE-bootable Debian live image
deploy/ systemd unit + install.sh + example config deploy/ systemd unit + install.sh + example config
docs/ operator + developer docs docs/ operator + developer docs
test/e2e/ build-tag-gated QEMU + PXE full-stack test test/e2e/ build-tag-gated QEMU + PXE full-stack test
tools/ small CLI helpers (e.g. gen-admin-password) tools/ small CLI helpers
``` ```
## Development ## Development
-32
View File
@@ -14,7 +14,6 @@ import (
"time" "time"
"vetting/internal/api" "vetting/internal/api"
"vetting/internal/auth"
"vetting/internal/config" "vetting/internal/config"
"vetting/internal/db" "vetting/internal/db"
"vetting/internal/events" "vetting/internal/events"
@@ -54,19 +53,6 @@ func main() {
} }
defer func() { _ = conn.Close() }() defer func() { _ = conn.Close() }()
secret, err := cfg.Auth.SessionSecret()
if err != nil {
log.Fatalf("auth: %v", err)
}
authMgr := &auth.Manager{
PasswordHash: cfg.Auth.AdminPasswordBcrypt,
Secret: secret,
TTL: time.Duration(cfg.Auth.SessionTTLHours) * time.Hour,
}
if err := validateAuth(cfg, authMgr); err != nil {
log.Fatalf("auth: %v", err)
}
hostStore := &store.Hosts{DB: conn} hostStore := &store.Hosts{DB: conn}
runStore := &store.Runs{DB: conn} runStore := &store.Runs{DB: conn}
stageStore := &store.Stages{DB: conn} stageStore := &store.Stages{DB: conn}
@@ -113,7 +99,6 @@ func main() {
Hosts: hostStore, Hosts: hostStore,
Runs: runStore, Runs: runStore,
Artifacts: artifactStore, Artifacts: artifactStore,
Auth: authMgr,
EventHub: hub, EventHub: hub,
Runner: runner, Runner: runner,
Tiles: tiles, Tiles: tiles,
@@ -163,7 +148,6 @@ func main() {
} }
router := httpserver.NewRouter(httpserver.Deps{ router := httpserver.NewRouter(httpserver.Deps{
Auth: authMgr,
UI: ui, UI: ui,
Agent: agentAPI, Agent: agentAPI,
LiveDir: cfg.PXE.LiveDir, LiveDir: cfg.PXE.LiveDir,
@@ -231,19 +215,3 @@ func main() {
} }
_ = hub.Shutdown(ctx) _ = hub.Shutdown(ctx)
} }
func validateAuth(cfg *config.Config, _ *auth.Manager) error {
if cfg.Auth.AdminPasswordBcrypt == "" || cfg.Auth.AdminPasswordBcrypt == "$2a$10$REPLACE_ME_WITH_A_REAL_BCRYPT_HASH_0123456789abcdefABCDEFxx" {
return errPlaceholderPassword
}
if len(cfg.Auth.AdminPasswordBcrypt) < 4 || cfg.Auth.AdminPasswordBcrypt[0] != '$' {
return errPlaceholderPassword
}
return nil
}
var errPlaceholderPassword = plainErr("auth.admin_password_bcrypt is the placeholder; run bin/gen-admin-password and paste the hash into your config")
type plainErr string
func (e plainErr) Error() string { return string(e) }
+10 -19
View File
@@ -7,9 +7,9 @@
# 2. Creates the `vetting` system user with /var/lib/vetting homedir. # 2. Creates the `vetting` system user with /var/lib/vetting homedir.
# 3. Copies the pre-built `vetting` binary into /usr/local/bin. # 3. Copies the pre-built `vetting` binary into /usr/local/bin.
# 4. Drops the systemd unit and example config into /etc/vetting. # 4. Drops the systemd unit and example config into /etc/vetting.
# 5. Reminds the operator to edit the config and set a bcrypt # 5. Reminds the operator to edit the config before enabling
# password before enabling the service — we don't auto-start # the service — we don't auto-start because the default bind
# because a placeholder password would just refuse to boot. # is loopback-only and needs at least a tweak to be useful.
# #
# What it deliberately does NOT do: # What it deliberately does NOT do:
# - Build the orchestrator (this script assumes you ran # - Build the orchestrator (this script assumes you ran
@@ -95,20 +95,6 @@ install -d -m 0755 "${CONFIG_DIR}"
echo "==> installing binary" echo "==> installing binary"
install -m 0755 "${BINARY}" /usr/local/bin/vetting install -m 0755 "${BINARY}" /usr/local/bin/vetting
# Install the bcrypt password generator too if we can find it — the
# operator needs it to fill in auth.admin_password_bcrypt.
GEN_PW=""
for cand in \
"${REPO_ROOT}/bin/gen-admin-password-linux-amd64" \
"${REPO_ROOT}/bin/gen-admin-password" \
"${SCRIPT_DIR}/gen-admin-password"; do
if [[ -x "${cand}" ]]; then GEN_PW="${cand}"; break; fi
done
if [[ -n "${GEN_PW}" ]]; then
echo "==> installing gen-admin-password"
install -m 0755 "${GEN_PW}" /usr/local/bin/gen-admin-password
fi
echo "==> installing config and systemd unit" echo "==> installing config and systemd unit"
# vetting.production.yaml uses absolute /var/lib/vetting + /var/log/vetting # vetting.production.yaml uses absolute /var/lib/vetting + /var/log/vetting
# paths that match the systemd unit's ReadWritePaths. vetting.example.yaml # paths that match the systemd unit's ReadWritePaths. vetting.example.yaml
@@ -140,8 +126,9 @@ vetting is installed but not yet enabled.
Next steps: Next steps:
1. Edit ${CONFIG_DIR}/vetting.yaml and set: 1. Edit ${CONFIG_DIR}/vetting.yaml and set:
- auth.admin_password_bcrypt (run: gen-admin-password 'YOURPW') - server.bind (127.0.0.1:8080 by default; switch to
- auth.session_secret_hex (run: openssl rand -hex 32) 0.0.0.0:8080 once you're ready to expose
it on the LAN)
- server.public_url (the URL you'll browse to) - server.public_url (the URL you'll browse to)
- pxe.* if you want PXE boot support - pxe.* if you want PXE boot support
- notifiers + routes (optional) - notifiers + routes (optional)
@@ -150,4 +137,8 @@ Next steps:
3. Watch the logs: 3. Watch the logs:
journalctl -fu vetting journalctl -fu vetting
The UI has no built-in auth — it trusts the LAN. If you need a
password, front the service with a reverse proxy (Caddy/nginx
basic-auth) instead.
EOF EOF
+2 -2
View File
@@ -65,9 +65,9 @@ fi
echo "==> installing templ ${TEMPL_VERSION}" echo "==> installing templ ${TEMPL_VERSION}"
GOBIN=/usr/local/bin go install "github.com/a-h/templ/cmd/templ@${TEMPL_VERSION}" GOBIN=/usr/local/bin go install "github.com/a-h/templ/cmd/templ@${TEMPL_VERSION}"
echo "==> building orchestrator + gen-admin-password" echo "==> building orchestrator (make orchestrator-linux)"
cd "${SRC_DIR}" cd "${SRC_DIR}"
make orchestrator-linux gen-admin-password-linux make orchestrator-linux
echo "==> running deploy/install.sh" echo "==> running deploy/install.sh"
bash deploy/install.sh --binary "bin/vetting-linux-amd64" bash deploy/install.sh --binary "bin/vetting-linux-amd64"
-9
View File
@@ -28,15 +28,6 @@ janitor:
# Interval between cleanup sweeps. 0 defaults to 60. # Interval between cleanup sweeps. 0 defaults to 60.
interval_minutes: 60 interval_minutes: 60
auth:
# bcrypt hash of your admin password.
# Generate via: ./bin/gen-admin-password "your-password"
admin_password_bcrypt: "$2a$10$REPLACE_ME_WITH_A_REAL_BCRYPT_HASH_0123456789abcdefABCDEFxx"
# Random 32-byte hex string used to sign session cookies.
# Generate via: openssl rand -hex 32 (or use PowerShell equivalent)
session_secret_hex: "0000000000000000000000000000000000000000000000000000000000000000"
session_ttl_hours: 24
dispatcher: dispatcher:
max_concurrent_runs: 3 max_concurrent_runs: 3
-9
View File
@@ -28,15 +28,6 @@ janitor:
# Interval between cleanup sweeps. 0 defaults to 60. # Interval between cleanup sweeps. 0 defaults to 60.
interval_minutes: 60 interval_minutes: 60
auth:
# bcrypt hash of your admin password.
# Generate via: gen-admin-password 'your-password'
admin_password_bcrypt: "$2a$10$REPLACE_ME_WITH_A_REAL_BCRYPT_HASH_0123456789abcdefABCDEFxx"
# Random 32-byte hex string used to sign session cookies.
# Generate via: openssl rand -hex 32
session_secret_hex: "0000000000000000000000000000000000000000000000000000000000000000"
session_ttl_hours: 24
dispatcher: dispatcher:
max_concurrent_runs: 3 max_concurrent_runs: 3
-1
View File
@@ -45,7 +45,6 @@ Operator browser (HTMX + SSE, admin login)
| `internal/api` | HTTP handlers: `agent_handlers.go` (the agent-facing API) and `ui_handlers.go` (HTMX fragments + SSE). | | `internal/api` | HTTP handlers: `agent_handlers.go` (the agent-facing API) and `ui_handlers.go` (HTMX fragments + SSE). |
| `internal/httpserver` | chi router assembly — lives here to avoid `api ↔ orchestrator` cyclic imports. | | `internal/httpserver` | chi router assembly — lives here to avoid `api ↔ orchestrator` cyclic imports. |
| `internal/web` | Embedded static assets + compiled Templ templates. | | `internal/web` | Embedded static assets + compiled Templ templates. |
| `internal/auth` | Single-admin bcrypt + signed-cookie sessions. |
| `internal/pxe` | dnsmasq subprocess supervisor + per-MAC iPXE script generator. | | `internal/pxe` | dnsmasq subprocess supervisor + per-MAC iPXE script generator. |
| `internal/events` | In-process SSE hub (fan-out to live browser clients). | | `internal/events` | In-process SSE hub (fan-out to live browser clients). |
| `internal/logs` | Per-run flat-file writer + SSE fan-out of live log tail. | | `internal/logs` | Per-run flat-file writer + SSE fan-out of live log tail. |
+19 -18
View File
@@ -37,25 +37,18 @@ repaired nodes so DHCP and WoL work.
- disables the distro-default dnsmasq (the orchestrator supervises - disables the distro-default dnsmasq (the orchestrator supervises
its own) its own)
The installer does **not** enable the service, because the default The installer does **not** enable the service. You'll want to edit
config has a placeholder bcrypt password that the binary refuses to the config first.
start with.
3. Generate an admin password hash and a session secret, then edit 3. Edit `/etc/vetting/vetting.yaml`:
`/etc/vetting/vetting.yaml`:
``` - `server.bind` — defaults to `127.0.0.1:8080`. Switch to
./bin/gen-admin-password 'your-password-here' # prints a bcrypt hash `0.0.0.0:8080` (or bind to a specific LAN IP) once you're ready
openssl rand -hex 32 # prints a 64-char hex string to expose it. There is no built-in auth — see *Exposing outside
``` the LAN* below.
Required fields:
- `auth.admin_password_bcrypt` — the bcrypt hash
- `auth.session_secret_hex` — the 32-byte hex string
- `server.public_url` — the URL your browser hits the LXC on - `server.public_url` — the URL your browser hits the LXC on
(e.g. `https://vetting.lan:8443`). This is used as the (e.g. `http://vetting.lan:8080`). Used as the click-through link
click-through link in notifications, so it must be the *external* in notifications.
URL, not the bind address.
4. (Optional) Configure notifiers in the same file — see the 4. (Optional) Configure notifiers in the same file — see the
commented-out example block for ntfy / Discord / SMTP. commented-out example block for ntfy / Discord / SMTP.
@@ -79,7 +72,7 @@ Against a QEMU VM first, before you point it at real hardware:
sudo ip link set br-vetting up sudo ip link set br-vetting up
``` ```
2. In the UI at `https://<lxc>:8443`, log in and register a host: 2. In the UI at `http://<lxc>:8080`, register a host:
- Name: `qemu-test` - Name: `qemu-test`
- MAC: `52:54:00:12:34:56` - MAC: `52:54:00:12:34:56`
- WoL broadcast IP: `10.77.0.255` - WoL broadcast IP: `10.77.0.255`
@@ -145,11 +138,19 @@ Retention is governed by the `artifacts.retention_days` and
`logs.retention_days` settings. DB rows (run history) are preserved `logs.retention_days` settings. DB rows (run history) are preserved
indefinitely; only on-disk files get pruned. indefinitely; only on-disk files get pruned.
## Exposing outside the LAN
The orchestrator UI has no built-in auth. It's designed to live on a
trusted home LAN and trust whatever reaches it. If you want to reach
it from outside that LAN, don't expose the bind port directly — put
it behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS
and adds basic-auth or OIDC. The agent↔orchestrator bearer token
auth is independent and keeps working either way.
## Troubleshooting ## Troubleshooting
| Symptom | First check | | Symptom | First check |
|---|---| |---|---|
| Service refuses to start with `auth.admin_password_bcrypt is the placeholder` | You didn't replace the bcrypt hash in the config. Run `gen-admin-password`. |
| PXE client gets no DHCP offer | `journalctl -u vetting` for dnsmasq errors; confirm the LXC has `CAP_NET_ADMIN` (the shipped systemd unit does); confirm the host MAC is actually registered (`sqlite3 /var/lib/vetting/vetting.db 'SELECT name, mac FROM hosts;'`). | | PXE client gets no DHCP offer | `journalctl -u vetting` for dnsmasq errors; confirm the LXC has `CAP_NET_ADMIN` (the shipped systemd unit does); confirm the host MAC is actually registered (`sqlite3 /var/lib/vetting/vetting.db 'SELECT name, mac FROM hosts;'`). |
| Agent `/hello` never fires | Check the live image is actually loading the agent binary — SSH into the live env (use the hold key path), `systemctl status vetting-agent`. | | Agent `/hello` never fires | Check the live image is actually loading the agent binary — SSH into the live env (use the hold key path), `systemctl status vetting-agent`. |
| Tile stuck on `Booting` | Most likely the live image booted but the agent can't reach the orchestrator. Verify `vetting.orchestrator=` in the kernel cmdline resolves from the host's network. | | Tile stuck on `Booting` | Most likely the live image booted but the agent can't reach the orchestrator. Verify `vetting.orchestrator=` in the kernel cmdline resolves from the host's network. |
-34
View File
@@ -11,7 +11,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"vetting/internal/auth"
"vetting/internal/events" "vetting/internal/events"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/orchestrator" "vetting/internal/orchestrator"
@@ -23,7 +22,6 @@ type UI struct {
Hosts *store.Hosts Hosts *store.Hosts
Runs *store.Runs Runs *store.Runs
Artifacts *store.Artifacts Artifacts *store.Artifacts
Auth *auth.Manager
EventHub *events.Hub EventHub *events.Hub
Runner *orchestrator.Runner Runner *orchestrator.Runner
Tiles *TileEnricher Tiles *TileEnricher
@@ -93,38 +91,6 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) 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) { func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
_ = templates.Registration(templates.RegistrationForm{}).Render(r.Context(), w) _ = 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 package config
import ( import (
"encoding/hex"
"fmt" "fmt"
"os" "os"
@@ -13,7 +12,6 @@ type Config struct {
Database Database `yaml:"database"` Database Database `yaml:"database"`
Artifacts Artifacts `yaml:"artifacts"` Artifacts Artifacts `yaml:"artifacts"`
Logs Logs `yaml:"logs"` Logs Logs `yaml:"logs"`
Auth Auth `yaml:"auth"`
Dispatcher Dispatcher `yaml:"dispatcher"` Dispatcher Dispatcher `yaml:"dispatcher"`
Janitor Janitor `yaml:"janitor"` Janitor Janitor `yaml:"janitor"`
PXE PXE `yaml:"pxe"` PXE PXE `yaml:"pxe"`
@@ -52,23 +50,6 @@ type Janitor struct {
IntervalMinutes int `yaml:"interval_minutes"` // 0 = 60 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 { type Dispatcher struct {
MaxConcurrentRuns int `yaml:"max_concurrent_runs"` MaxConcurrentRuns int `yaml:"max_concurrent_runs"`
} }
@@ -132,9 +113,6 @@ func Load(path string) (*Config, error) {
if c.Logs.Dir == "" { if c.Logs.Dir == "" {
c.Logs.Dir = "./var/logs" c.Logs.Dir = "./var/logs"
} }
if c.Auth.SessionTTLHours == 0 {
c.Auth.SessionTTLHours = 24
}
if c.Dispatcher.MaxConcurrentRuns == 0 { if c.Dispatcher.MaxConcurrentRuns == 0 {
c.Dispatcher.MaxConcurrentRuns = 3 c.Dispatcher.MaxConcurrentRuns = 3
} }
+11 -22
View File
@@ -11,12 +11,10 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"vetting/internal/api" "vetting/internal/api"
"vetting/internal/auth"
"vetting/internal/web" "vetting/internal/web"
) )
type Deps struct { type Deps struct {
Auth *auth.Manager
UI *api.UI UI *api.UI
Agent *api.Agent Agent *api.Agent
LiveDir string // directory containing vmlinuz + initrd.img; "" disables /live 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)))) 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 // 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.Get("/ipxe/{mac}", d.Agent.IPXEScript)
r.Route("/api/v1/runs/{id}", func(r chi.Router) { r.Route("/api/v1/runs/{id}", func(r chi.Router) {
r.Post("/hello", d.Agent.Hello) r.Post("/hello", d.Agent.Hello)
@@ -56,20 +49,16 @@ func NewRouter(d Deps) http.Handler {
r.Post("/sensor", d.Agent.Sensor) r.Post("/sensor", d.Agent.Sensor)
}) })
// Session-gated browser UI. // Browser UI — no auth; bind to loopback or LAN only, or front
r.Group(func(r chi.Router) { // with a reverse proxy if you need a password.
r.Use(d.Auth.RequireSession) r.Get("/", d.UI.Dashboard)
r.Get("/hosts/new", d.UI.NewHostForm)
r.Get("/", d.UI.Dashboard) r.Post("/hosts", d.UI.CreateHost)
r.Get("/hosts/new", d.UI.NewHostForm) r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
r.Post("/hosts", d.UI.CreateHost) r.Post("/hosts/{id}/start", d.UI.StartRun)
r.Post("/hosts/{id}/delete", d.UI.DeleteHost) r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage)
r.Post("/hosts/{id}/start", d.UI.StartRun) r.Get("/reports/{runID}", d.UI.Report)
r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage) r.Get("/events", d.UI.SSE)
r.Get("/reports/{runID}", d.UI.Report)
r.Get("/events", d.UI.SSE)
})
return r return r
} }
-3
View File
@@ -20,9 +20,6 @@ templ Layout(title string) {
</nav> </nav>
<div class="session"> <div class="session">
<span class="heartbeat" hx-ext="sse" sse-connect="/events" sse-swap="heartbeat">·</span> <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> </div>
</header> </header>
<main> <main>
+2 -2
View File
@@ -42,7 +42,7 @@ func Layout(title string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { 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
-21
View File
@@ -1,21 +0,0 @@
package main
import (
"fmt"
"os"
"vetting/internal/auth"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: gen-admin-password <plaintext>")
os.Exit(2)
}
hash, err := auth.BcryptHash(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(hash)
}