Files
restic-manager/internal/server/http/host_credentials.go
T
steve e871b05b38
CI / Test (linux/amd64) (pull_request) Successful in 34s
CI / Lint (pull_request) Failing after 16s
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Build (linux/amd64) (pull_request) Successful in 20s
CI / Build (linux/arm64) (pull_request) Successful in 21s
lint: drive baseline to zero, drop only-new-issues gate
Cleanup pass over the repo so CI can enforce lint going forward
without the only-new-issues escape hatch:

* gofumpt -w across the tree (31 hits, all formatting)
* misspell --fix (25 hits, US-locale spelling) — but reverted on
  api.JobCancelled = "cancelled" since that literal is the wire +
  DB CHECK constraint value, plus matched the case in store/fleet.go
  back to "cancelled" and added //nolint:misspell on both for the
  next time someone reaches for the auto-fix
* Wrap every `defer rows.Close()` / `defer stmt.Close()` /
  `defer res.Body.Close()` in `defer func() { _ = .Close() }()`
  to satisfy errcheck without losing the close itself
* websocket.Dial callers (1 prod, 4 tests) now capture + close the
  upgrade response Body — coder/websocket can return res with a nil
  Body on success, so the test deferred-closes guard against that
* Annotate the two genuine-by-design nilerr cases with //nolint
  comments explaining why nil-on-error is the contract (cookie
  missing = no session; ctx cancelled mid-backoff = clean shutdown)
* Add brief godoc on the 10 exported const groups + types that
  revive flagged (api.HostOS/HostArch/JobKind/JobStatus/LogStream/
  ErrorCode, restic.EventKind, store.Role, web.FS)
* Drop the unused (*Server).userByID method
* Inline the unparam baseView(active) — every UI page is under
  the dashboard primary nav today

Result: `golangci-lint run ./...` reports 0 issues. CI lint job
no longer needs only-new-issues: true; X-06 follow-up entry in
tasks.md removed.
2026-05-03 16:15:17 +01:00

301 lines
10 KiB
Go

package http
import (
"context"
"encoding/json"
"errors"
"log/slog"
stdhttp "net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func nowUTC() time.Time { return time.Now().UTC() }
// hostRepoCredsView is the body of GET /api/hosts/{id}/repo-credentials.
// Password is always redacted; the UI uses this to pre-fill an edit
// form with the URL/username already populated.
type hostRepoCredsView struct {
RepoURL string `json:"repo_url"`
RepoUsername string `json:"repo_username,omitempty"`
HasPassword bool `json:"has_password"`
}
// handleGetHostCredentials returns a redacted view of the host's repo
// creds for UI display. 404 if no credential has ever been set.
func (s *Server) handleGetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
return
}
enc, err := s.deps.Store.GetHostCredentials(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "not_set", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
plain, err := s.deps.AEAD.Decrypt(enc, []byte("host:"+hostID))
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "decrypt_failed", "")
return
}
var blob repoCredsBlob
if err := json.Unmarshal(plain, &blob); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
writeJSON(w, stdhttp.StatusOK, hostRepoCredsView{
RepoURL: blob.RepoURL,
RepoUsername: blob.RepoUsername,
HasPassword: blob.RepoPassword != "",
})
}
// hostRepoCredsRequest is the body of PUT /api/hosts/{id}/repo-credentials.
// Operator can edit any subset; missing fields preserve the existing
// value (so changing only the password doesn't require resending the URL).
//
// We model this as plaintext on the wire because the wire is HTTPS to
// the proxy. The values are AEAD-encrypted before they touch SQLite,
// and only ever leave the server again inside the authenticated WS
// `config.update` push.
type hostRepoCredsRequest struct {
RepoURL *string `json:"repo_url,omitempty"`
RepoUsername *string `json:"repo_username,omitempty"`
RepoPassword *string `json:"repo_password,omitempty"`
}
// handleSetHostCredentials lets an operator/admin update a host's
// repo creds. Any fields the operator sends overwrite the
// corresponding fields in the existing blob; the others are
// preserved. Re-encrypts under host_id and pushes a config.update
// over the WS if the agent is connected.
func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
return
}
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
var req hostRepoCredsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
// Merge with the existing row, if any.
existing := repoCredsBlob{}
if cur, err := s.deps.Store.GetHostCredentials(r.Context(), hostID); err == nil {
plain, err := s.deps.AEAD.Decrypt(cur, []byte("host:"+hostID))
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "decrypt_failed", "")
return
}
_ = json.Unmarshal(plain, &existing)
} else if !errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
if req.RepoURL != nil {
existing.RepoURL = *req.RepoURL
}
if req.RepoUsername != nil {
existing.RepoUsername = *req.RepoUsername
}
if req.RepoPassword != nil {
existing.RepoPassword = *req.RepoPassword
}
if existing.RepoURL == "" || existing.RepoPassword == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field",
"repo_url and repo_password must end up non-empty")
return
}
enc, err := s.encryptRepoCreds(existing, []byte("host:"+hostID))
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
if err := s.deps.Store.SetHostCredentials(r.Context(), hostID, enc); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
Actor: "user",
Action: "host.repo_credentials_set",
TargetKind: ptr("host"),
TargetID: &hostID,
TS: nowUTC(),
})
// Push to the agent if it's connected. Errors here are non-fatal:
// the next reconnect will pick the row up via the hello handler.
if s.deps.Hub != nil && s.deps.Hub.Connected(hostID) {
_ = s.pushRepoCredsToAgent(r.Context(), hostID, existing)
}
w.WriteHeader(stdhttp.StatusNoContent)
}
// pushRepoCredsToAgent serializes blob into a config.update envelope
// and ships it down the agent's WS. Returns an error from the hub
// (no-op if not connected — caller is expected to check first when it
// matters).
func (s *Server) pushRepoCredsToAgent(ctx context.Context, hostID string, blob repoCredsBlob) error {
env, err := api.Marshal(api.MsgConfigUpdate, "", api.ConfigUpdatePayload{
RepoURL: blob.RepoURL,
RepoUsername: blob.RepoUsername,
RepoPassword: blob.RepoPassword,
})
if err != nil {
return err
}
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := s.deps.Hub.Send(sendCtx, hostID, env); err != nil {
slog.Warn("push repo creds: hub send failed", "host_id", hostID, "err", err)
return err
}
return nil
}
// onAgentHello runs synchronously inside the WS handler immediately
// after a successful hello. It loads the host's encrypted creds (if
// any), decrypts, and ships them down the conn as a config.update so
// the agent has them before any command.run lands.
//
// The conn argument is used directly (rather than via the hub) so we
// don't race a brand-new register against an old still-closing conn.
func (s *Server) onAgentHello(ctx context.Context, hostID string, conn *ws.Conn) {
s.pushRepoCredsOnHello(ctx, hostID, conn)
// Push the current schedule set in the same on-hello window so
// the agent's local cron is in sync before any command.run lands.
// An empty schedule list is a valid push: it tells the agent to
// drop any cron entries left over from a previous deployment.
// Always runs, even when the host has no repo credentials yet.
s.pushScheduleSetOnConn(ctx, hostID, conn)
// Auto-init the repo if we've never landed a successful init job
// against this host. Restic treats "config file already exists"
// as a soft success, so re-enrolment against a populated repo
// just no-ops. Skipped silently when the host has no creds yet —
// the next hello after the operator binds creds will dispatch.
s.maybeAutoInit(ctx, hostID, conn)
}
// maybeAutoInit dispatches a `restic init` job iff the host has no
// successful init in its history AND repo creds are bound (without
// them the runner can't talk to the repo). We rely on Restic's
// idempotent init for re-runs.
func (s *Server) maybeAutoInit(ctx context.Context, hostID string, conn *ws.Conn) {
if _, err := s.deps.Store.GetHostCredentials(ctx, hostID); err != nil {
// No creds bound yet — operator hasn't supplied them. The next
// hello after creds land will pick this up.
return
}
already, err := s.deps.Store.HasJobOfKind(ctx, hostID, string(api.JobInit))
if err != nil {
slog.Warn("auto-init: check job history", "host_id", hostID, "err", err)
return
}
if already {
return
}
jobID := ulid.Make().String()
now := time.Now().UTC()
if err := s.deps.Store.CreateJob(ctx, store.Job{
ID: jobID,
HostID: hostID,
Kind: string(api.JobInit),
ActorKind: "system",
CreatedAt: now,
}); err != nil {
slog.Warn("auto-init: persist job", "host_id", hostID, "err", err)
return
}
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
JobID: jobID,
Kind: api.JobInit,
})
if err != nil {
slog.Warn("auto-init: marshal command.run", "host_id", hostID, "err", err)
return
}
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := conn.Send(sendCtx, env); err != nil {
slog.Warn("auto-init: send command.run", "host_id", hostID, "err", err)
return
}
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
ID: ulid.Make().String(),
Actor: "system",
Action: "host.auto_init",
TargetKind: ptr("host"),
TargetID: &hostID,
TS: now,
})
slog.Info("auto-init: dispatched", "host_id", hostID, "job_id", jobID)
}
// pushRepoCredsOnHello loads + decrypts + sends the host's repo
// credentials. Silent no-op when the host has nothing on file
// (the operator hasn't bound creds to it yet).
func (s *Server) pushRepoCredsOnHello(ctx context.Context, hostID string, conn *ws.Conn) {
enc, err := s.deps.Store.GetHostCredentials(ctx, hostID)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
slog.Warn("on-hello: load host creds", "host_id", hostID, "err", err)
}
return
}
plain, err := s.deps.AEAD.Decrypt(enc, []byte("host:"+hostID))
if err != nil {
slog.Error("on-hello: decrypt host creds", "host_id", hostID, "err", err)
return
}
var blob repoCredsBlob
if err := json.Unmarshal(plain, &blob); err != nil {
slog.Error("on-hello: parse host creds", "host_id", hostID, "err", err)
return
}
env, err := api.Marshal(api.MsgConfigUpdate, "", api.ConfigUpdatePayload{
RepoURL: blob.RepoURL,
RepoUsername: blob.RepoUsername,
RepoPassword: blob.RepoPassword,
})
if err != nil {
slog.Error("on-hello: marshal config.update", "host_id", hostID, "err", err)
return
}
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := conn.Send(sendCtx, env); err != nil {
slog.Warn("on-hello: send config.update", "host_id", hostID, "err", err)
}
}