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:
@@ -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)
|
||||
$(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
|
||||
tidy: ## go mod tidy
|
||||
go mod tidy
|
||||
@@ -68,7 +60,7 @@ endif
|
||||
$(MAKE) -C live-image 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
|
||||
run: orchestrator ## Build and run orchestrator with example config
|
||||
|
||||
@@ -22,20 +22,16 @@ notifications.
|
||||
## Quick start (local, against QEMU)
|
||||
|
||||
```bash
|
||||
# 1. Build
|
||||
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
|
||||
# → 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,
|
||||
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:
|
||||
|
||||
```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
|
||||
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
|
||||
docs/ operator + developer docs
|
||||
test/e2e/ build-tag-gated QEMU + PXE full-stack test
|
||||
tools/ small CLI helpers (e.g. gen-admin-password)
|
||||
tools/ small CLI helpers
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"vetting/internal/api"
|
||||
"vetting/internal/auth"
|
||||
"vetting/internal/config"
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/events"
|
||||
@@ -54,19 +53,6 @@ func main() {
|
||||
}
|
||||
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}
|
||||
runStore := &store.Runs{DB: conn}
|
||||
stageStore := &store.Stages{DB: conn}
|
||||
@@ -113,7 +99,6 @@ func main() {
|
||||
Hosts: hostStore,
|
||||
Runs: runStore,
|
||||
Artifacts: artifactStore,
|
||||
Auth: authMgr,
|
||||
EventHub: hub,
|
||||
Runner: runner,
|
||||
Tiles: tiles,
|
||||
@@ -163,7 +148,6 @@ func main() {
|
||||
}
|
||||
|
||||
router := httpserver.NewRouter(httpserver.Deps{
|
||||
Auth: authMgr,
|
||||
UI: ui,
|
||||
Agent: agentAPI,
|
||||
LiveDir: cfg.PXE.LiveDir,
|
||||
@@ -231,19 +215,3 @@ func main() {
|
||||
}
|
||||
_ = 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
@@ -7,9 +7,9 @@
|
||||
# 2. Creates the `vetting` system user with /var/lib/vetting homedir.
|
||||
# 3. Copies the pre-built `vetting` binary into /usr/local/bin.
|
||||
# 4. Drops the systemd unit and example config into /etc/vetting.
|
||||
# 5. Reminds the operator to edit the config and set a bcrypt
|
||||
# password before enabling the service — we don't auto-start
|
||||
# because a placeholder password would just refuse to boot.
|
||||
# 5. Reminds the operator to edit the config before enabling
|
||||
# the service — we don't auto-start because the default bind
|
||||
# is loopback-only and needs at least a tweak to be useful.
|
||||
#
|
||||
# What it deliberately does NOT do:
|
||||
# - Build the orchestrator (this script assumes you ran
|
||||
@@ -95,20 +95,6 @@ install -d -m 0755 "${CONFIG_DIR}"
|
||||
echo "==> installing binary"
|
||||
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"
|
||||
# vetting.production.yaml uses absolute /var/lib/vetting + /var/log/vetting
|
||||
# paths that match the systemd unit's ReadWritePaths. vetting.example.yaml
|
||||
@@ -140,8 +126,9 @@ vetting is installed but not yet enabled.
|
||||
|
||||
Next steps:
|
||||
1. Edit ${CONFIG_DIR}/vetting.yaml and set:
|
||||
- auth.admin_password_bcrypt (run: gen-admin-password 'YOURPW')
|
||||
- auth.session_secret_hex (run: openssl rand -hex 32)
|
||||
- server.bind (127.0.0.1:8080 by default; switch to
|
||||
0.0.0.0:8080 once you're ready to expose
|
||||
it on the LAN)
|
||||
- server.public_url (the URL you'll browse to)
|
||||
- pxe.* if you want PXE boot support
|
||||
- notifiers + routes (optional)
|
||||
@@ -150,4 +137,8 @@ Next steps:
|
||||
3. Watch the logs:
|
||||
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
|
||||
|
||||
@@ -65,9 +65,9 @@ fi
|
||||
echo "==> installing 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}"
|
||||
make orchestrator-linux gen-admin-password-linux
|
||||
make orchestrator-linux
|
||||
|
||||
echo "==> running deploy/install.sh"
|
||||
bash deploy/install.sh --binary "bin/vetting-linux-amd64"
|
||||
|
||||
@@ -28,15 +28,6 @@ janitor:
|
||||
# Interval between cleanup sweeps. 0 defaults to 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:
|
||||
max_concurrent_runs: 3
|
||||
|
||||
|
||||
@@ -28,15 +28,6 @@ janitor:
|
||||
# Interval between cleanup sweeps. 0 defaults to 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:
|
||||
max_concurrent_runs: 3
|
||||
|
||||
|
||||
@@ -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/httpserver` | chi router assembly — lives here to avoid `api ↔ orchestrator` cyclic imports. |
|
||||
| `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/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. |
|
||||
|
||||
+19
-18
@@ -37,25 +37,18 @@ repaired nodes so DHCP and WoL work.
|
||||
- disables the distro-default dnsmasq (the orchestrator supervises
|
||||
its own)
|
||||
|
||||
The installer does **not** enable the service, because the default
|
||||
config has a placeholder bcrypt password that the binary refuses to
|
||||
start with.
|
||||
The installer does **not** enable the service. You'll want to edit
|
||||
the config first.
|
||||
|
||||
3. Generate an admin password hash and a session secret, then edit
|
||||
`/etc/vetting/vetting.yaml`:
|
||||
3. Edit `/etc/vetting/vetting.yaml`:
|
||||
|
||||
```
|
||||
./bin/gen-admin-password 'your-password-here' # prints a bcrypt hash
|
||||
openssl rand -hex 32 # prints a 64-char hex string
|
||||
```
|
||||
|
||||
Required fields:
|
||||
- `auth.admin_password_bcrypt` — the bcrypt hash
|
||||
- `auth.session_secret_hex` — the 32-byte hex string
|
||||
- `server.bind` — defaults to `127.0.0.1:8080`. Switch to
|
||||
`0.0.0.0:8080` (or bind to a specific LAN IP) once you're ready
|
||||
to expose it. There is no built-in auth — see *Exposing outside
|
||||
the LAN* below.
|
||||
- `server.public_url` — the URL your browser hits the LXC on
|
||||
(e.g. `https://vetting.lan:8443`). This is used as the
|
||||
click-through link in notifications, so it must be the *external*
|
||||
URL, not the bind address.
|
||||
(e.g. `http://vetting.lan:8080`). Used as the click-through link
|
||||
in notifications.
|
||||
|
||||
4. (Optional) Configure notifiers in the same file — see the
|
||||
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
|
||||
```
|
||||
|
||||
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`
|
||||
- MAC: `52:54:00:12:34:56`
|
||||
- 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
|
||||
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
|
||||
|
||||
| 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;'`). |
|
||||
| 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. |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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,10 +49,8 @@ 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)
|
||||
|
||||
// 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)
|
||||
@@ -67,9 +58,7 @@ func NewRouter(d Deps) http.Handler {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user