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 serialises 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) } }