811157b4ce
Self-hosted deployments already terminate TLS at Caddy/Traefik/nginx; making the server do TLS too means double cert config, dual ACME plumbing, and an untested code path. Drop RM_TLS_CERT/RM_TLS_KEY, remove TLSEnabled() and the ListenAndServeTLS branch. Replace the cookie's "Secure if TLS-in-process" check with a new RM_COOKIE_SECURE flag (default true). Local HTTP-only testing sets RM_COOKIE_SECURE=false; production is always behind a TLS proxy and the cookie stays Secure. Default port :8443 → :8080. docker-compose binds 127.0.0.1 only and populates RM_TRUSTED_PROXY. spec.md §4.1/§10.1 rewritten with a Caddyfile snippet and a hard "do not expose RM_LISTEN publicly" warning. enrollResponse keeps cert_pin_sha256 in the shape but the server can't introspect a cert it doesn't terminate — operator pastes the proxy's hash into -cert-pin at install time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
5.6 KiB
Go
171 lines
5.6 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
stdhttp "net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// enrollRequest is the body posted by the agent installer. The token
|
|
// was issued by the operator via the UI ("Add host" → P1-27); the
|
|
// host metadata comes from the agent's own sysinfo collection.
|
|
type enrollRequest struct {
|
|
Token string `json:"token"`
|
|
HostName string `json:"hostname"`
|
|
OS api.HostOS `json:"os"`
|
|
Arch api.HostArch `json:"arch"`
|
|
AgentVersion string `json:"agent_version"`
|
|
ResticVersion string `json:"restic_version"`
|
|
}
|
|
|
|
// enrollResponse hands the agent the credentials it'll use forever.
|
|
// AgentToken is shown exactly once; the server stores its hash.
|
|
//
|
|
// CertPinSHA256 is reserved for future use. The server is HTTP-only
|
|
// and sits behind a reverse proxy that owns the TLS cert; pinning is
|
|
// configured at the agent install step (`-cert-pin`) by the operator
|
|
// pasting in the proxy's cert hash. The field stays in the response
|
|
// shape so we can populate it later if the topology changes.
|
|
type enrollResponse struct {
|
|
HostID string `json:"host_id"`
|
|
AgentToken string `json:"agent_token"`
|
|
CertPinSHA256 string `json:"cert_pin_sha256,omitempty"`
|
|
}
|
|
|
|
// enrollOperatorRequest creates a one-time enrollment token for an
|
|
// operator who is about to install an agent. Authenticated UI route.
|
|
type enrollOperatorRequest struct {
|
|
HostName string `json:"hostname"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
type enrollOperatorResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// handleAgentEnroll consumes a one-time token, persists a Host row,
|
|
// and returns persistent agent credentials. Open endpoint (no
|
|
// session) — the token is the credential.
|
|
func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
var req enrollRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
if req.Token == "" || req.HostName == "" || req.OS == "" || req.Arch == "" {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field",
|
|
"token, hostname, os, arch all required")
|
|
return
|
|
}
|
|
|
|
hostID := ulid.Make().String()
|
|
|
|
// Atomically: validate + consume token, then create the host.
|
|
// We do these in two statements; if create-host fails, the token
|
|
// is already burned. That's acceptable — operator just regens.
|
|
tokHash := auth.HashToken(req.Token)
|
|
if err := s.deps.Store.ConsumeEnrollmentToken(r.Context(), tokHash, hostID); err != nil {
|
|
writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_token",
|
|
"token unknown, expired, or already used")
|
|
return
|
|
}
|
|
|
|
// Mint the persistent agent bearer.
|
|
agentToken, err := auth.NewToken()
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
|
|
host := store.Host{
|
|
ID: hostID,
|
|
Name: strings.TrimSpace(req.HostName),
|
|
OS: string(req.OS),
|
|
Arch: string(req.Arch),
|
|
AgentVersion: req.AgentVersion,
|
|
ResticVersion: req.ResticVersion,
|
|
EnrolledAt: time.Now().UTC(),
|
|
}
|
|
if err := s.deps.Store.CreateHost(r.Context(), host,
|
|
auth.HashToken(agentToken), ""); err != nil {
|
|
writeJSONError(w, stdhttp.StatusConflict, "host_exists", err.Error())
|
|
return
|
|
}
|
|
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
Actor: "system",
|
|
Action: "host.enrolled",
|
|
TargetKind: ptr("host"),
|
|
TargetID: &hostID,
|
|
TS: host.EnrolledAt,
|
|
})
|
|
|
|
writeJSON(w, stdhttp.StatusCreated, enrollResponse{
|
|
HostID: hostID,
|
|
AgentToken: agentToken,
|
|
// CertPinSHA256: the server is HTTP-only and sits behind a
|
|
// reverse proxy that owns the cert. The operator pastes the
|
|
// proxy's cert hash into the install command (`-cert-pin`)
|
|
// when they want pinning; the server cannot introspect a
|
|
// cert it doesn't terminate.
|
|
})
|
|
}
|
|
|
|
// handleCreateEnrollmentToken (operator-facing) — generates a
|
|
// short-lived token for a new host. Authenticated; admin/operator only.
|
|
//
|
|
// TODO: gate by authn middleware once login session lookup lands.
|
|
// For Phase 1's first slice, we accept the bootstrap-shipped admin
|
|
// session cookie and trust it, validating the cookie via store.
|
|
func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
if !s.authedUser(r) {
|
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
|
return
|
|
}
|
|
|
|
var req enrollOperatorRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
token, err := auth.NewToken()
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
const ttl = time.Hour
|
|
if err := s.deps.Store.CreateEnrollmentToken(r.Context(), auth.HashToken(token), ttl); err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{
|
|
Token: token,
|
|
ExpiresAt: time.Now().Add(ttl).UTC(),
|
|
})
|
|
}
|
|
|
|
// authedUser returns true iff the request carries a valid session
|
|
// cookie. Minimal stub for now; full RBAC middleware lands with
|
|
// P4-03.
|
|
func (s *Server) authedUser(r *stdhttp.Request) bool {
|
|
c, err := r.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, err = s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
|
|
return err == nil
|
|
}
|
|
|
|
func ptr(s string) *string { return &s }
|