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() } // 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) { 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) } }