From c68d5dd3d8518917d40f5cd9c58ec570dd1d9de6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 21 Aug 2026 14:12:57 +0100 Subject: [PATCH] Extract configuration API runtime package --- docs/release-control/v6/internal/status.json | 22 +- .../v6/internal/subsystems/agent-lifecycle.md | 26 ++- .../v6/internal/subsystems/api-contracts.md | 67 ++++-- .../subsystems/deployment-installability.md | 2 +- .../v6/internal/subsystems/registry.json | 80 +++++-- .../internal/subsystems/security-privacy.md | 2 + .../internal/subsystems/storage-recovery.md | 16 +- internal/api/agent_exec_token_binding.go | 107 +-------- internal/api/agent_install_command_shared.go | 15 +- internal/api/agentbinding/policy.go | 117 ++++++++++ internal/api/agentbinding/policy_test.go | 39 ++++ internal/api/agenttokens/install.go | 133 +++++++++++ internal/api/agenttokens/install_test.go | 40 ++++ internal/api/config_handlers_compat.go | 110 +++++++++ .../api/config_handlers_test_support_test.go | 64 +++++ .../auto_register_test_helpers_test.go | 2 +- .../{ => configapi}/branchcov0723pm_test.go | 2 +- .../config_discovery_handlers.go | 2 +- .../config_export_import_compat_test.go | 2 +- .../config_export_import_handlers.go | 25 +- .../api/{ => configapi}/config_handlers.go | 102 +++++++- .../config_handlers_add_test.go | 2 +- .../config_handlers_admin_test.go | 2 +- .../config_handlers_auto_reg_test.go | 2 +- .../config_handlers_auto_register_test.go | 2 +- ...g_handlers_canonical_auto_register_test.go | 2 +- ...config_handlers_cluster_additional_test.go | 2 +- .../config_handlers_cluster_test.go | 2 +- .../config_handlers_connection_test.go | 2 +- .../config_handlers_delete_test.go | 2 +- .../config_handlers_discovery_test.go | 2 +- ...config_handlers_helpers_additional_test.go | 2 +- .../config_handlers_host_test.go | 2 +- .../config_handlers_pve_user_test.go | 2 +- .../config_handlers_sanitize_test.go | 2 +- .../config_handlers_setup_script_test.go | 2 +- .../config_handlers_setup_token_test.go | 2 +- .../config_handlers_setup_url_test.go | 2 +- .../config_handlers_temperature_ssh_test.go | 2 +- .../config_handlers_transport_guard_test.go | 2 +- .../config_handlers_update_test.go | 2 +- .../config_node_display_name_test.go | 2 +- .../{ => configapi}/config_node_handlers.go | 15 +- .../config_node_handlers_additional_test.go | 2 +- .../{ => configapi}/config_setup_handlers.go | 90 +++++-- .../config_setup_handlers_test.go | 2 +- .../{ => configapi}/config_system_handlers.go | 20 +- .../config_token_helpers_test.go | 2 +- internal/api/configapi/dependencies.go | 221 ++++++++++++++++++ internal/api/configapi/install_command.go | 62 +++++ ...sue1644_host_install_token_proxmox_test.go | 97 +------- .../issue1664_cluster_fingerprint_test.go | 2 +- .../proxmox_install_registration_test.go | 2 +- .../api/configapi/setup_script_artifact.go | 109 +++++++++ .../{ => configapi}/setup_script_render.go | 14 +- internal/api/configapi/test_support_test.go | 102 ++++++++ internal/api/contract_test.go | 46 ++-- internal/api/host_agent_install_token_test.go | 2 +- ...issue1644_exec_binding_integration_test.go | 85 +++++++ .../multi_tenant_setters_additional_test.go | 33 ++- internal/api/router.go | 32 +-- .../api/router_decomposition_contract_test.go | 6 +- internal/api/router_helpers_more_test.go | 4 +- internal/api/security_regression_test.go | 42 ++-- internal/api/update_readiness_test.go | 8 +- 65 files changed, 1549 insertions(+), 466 deletions(-) create mode 100644 internal/api/agentbinding/policy.go create mode 100644 internal/api/agentbinding/policy_test.go create mode 100644 internal/api/agenttokens/install.go create mode 100644 internal/api/agenttokens/install_test.go create mode 100644 internal/api/config_handlers_compat.go create mode 100644 internal/api/config_handlers_test_support_test.go rename internal/api/{ => configapi}/auto_register_test_helpers_test.go (97%) rename internal/api/{ => configapi}/branchcov0723pm_test.go (99%) rename internal/api/{ => configapi}/config_discovery_handlers.go (99%) rename internal/api/{ => configapi}/config_export_import_compat_test.go (99%) rename internal/api/{ => configapi}/config_export_import_handlers.go (84%) rename internal/api/{ => configapi}/config_handlers.go (94%) rename internal/api/{ => configapi}/config_handlers_add_test.go (99%) rename internal/api/{ => configapi}/config_handlers_admin_test.go (99%) rename internal/api/{ => configapi}/config_handlers_auto_reg_test.go (99%) rename internal/api/{ => configapi}/config_handlers_auto_register_test.go (99%) rename internal/api/{ => configapi}/config_handlers_canonical_auto_register_test.go (99%) rename internal/api/{ => configapi}/config_handlers_cluster_additional_test.go (99%) rename internal/api/{ => configapi}/config_handlers_cluster_test.go (99%) rename internal/api/{ => configapi}/config_handlers_connection_test.go (99%) rename internal/api/{ => configapi}/config_handlers_delete_test.go (99%) rename internal/api/{ => configapi}/config_handlers_discovery_test.go (99%) rename internal/api/{ => configapi}/config_handlers_helpers_additional_test.go (99%) rename internal/api/{ => configapi}/config_handlers_host_test.go (99%) rename internal/api/{ => configapi}/config_handlers_pve_user_test.go (99%) rename internal/api/{ => configapi}/config_handlers_sanitize_test.go (99%) rename internal/api/{ => configapi}/config_handlers_setup_script_test.go (99%) rename internal/api/{ => configapi}/config_handlers_setup_token_test.go (97%) rename internal/api/{ => configapi}/config_handlers_setup_url_test.go (99%) rename internal/api/{ => configapi}/config_handlers_temperature_ssh_test.go (99%) rename internal/api/{ => configapi}/config_handlers_transport_guard_test.go (99%) rename internal/api/{ => configapi}/config_handlers_update_test.go (99%) rename internal/api/{ => configapi}/config_node_display_name_test.go (99%) rename internal/api/{ => configapi}/config_node_handlers.go (98%) rename internal/api/{ => configapi}/config_node_handlers_additional_test.go (99%) rename internal/api/{ => configapi}/config_setup_handlers.go (96%) rename internal/api/{ => configapi}/config_setup_handlers_test.go (99%) rename internal/api/{ => configapi}/config_system_handlers.go (95%) rename internal/api/{ => configapi}/config_token_helpers_test.go (99%) create mode 100644 internal/api/configapi/dependencies.go create mode 100644 internal/api/configapi/install_command.go rename internal/api/{ => configapi}/issue1644_host_install_token_proxmox_test.go (89%) rename internal/api/{ => configapi}/issue1664_cluster_fingerprint_test.go (99%) rename internal/api/{ => configapi}/proxmox_install_registration_test.go (99%) create mode 100644 internal/api/configapi/setup_script_artifact.go rename internal/api/{ => configapi}/setup_script_render.go (99%) create mode 100644 internal/api/configapi/test_support_test.go create mode 100644 internal/api/issue1644_exec_binding_integration_test.go diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index c67562c4e..749711dcf 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -594,12 +594,12 @@ }, { "repo": "pulse", - "path": "internal/api/config_handlers_add_test.go", + "path": "internal/api/configapi/config_handlers_add_test.go", "kind": "file" }, { "repo": "pulse", - "path": "internal/api/config_handlers_auto_register_test.go", + "path": "internal/api/configapi/config_handlers_auto_register_test.go", "kind": "file" }, { @@ -2546,12 +2546,12 @@ }, { "repo": "pulse", - "path": "internal/api/config_handlers_canonical_auto_register_test.go", + "path": "internal/api/configapi/config_handlers_canonical_auto_register_test.go", "kind": "file" }, { "repo": "pulse", - "path": "internal/api/config_setup_handlers.go", + "path": "internal/api/configapi/config_setup_handlers.go", "kind": "file" }, { @@ -6315,12 +6315,17 @@ }, { "repo": "pulse", - "path": "internal/api/config_handlers_setup_script_test.go", + "path": "internal/api/configapi/config_handlers_setup_script_test.go", "kind": "file" }, { "repo": "pulse", - "path": "internal/api/config_setup_handlers.go", + "path": "internal/api/configapi/config_setup_handlers.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/configapi/setup_script_render.go", "kind": "file" }, { @@ -6328,11 +6333,6 @@ "path": "internal/api/contract_test.go", "kind": "file" }, - { - "repo": "pulse", - "path": "internal/api/setup_script_render.go", - "kind": "file" - }, { "repo": "pulse", "path": "internal/api/unified_agent.go", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c50c153d0..534fc5b15 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -200,8 +200,8 @@ installer download and the agent's subsequent Pulse TLS connection. ## Canonical Files 1. `internal/api/agent_install_command_shared.go` -2. `internal/api/config_setup_handlers.go` - 2a. `internal/api/setup_script_render.go` +2. `internal/api/configapi/config_setup_handlers.go` + 2a. `internal/api/configapi/setup_script_render.go` 3. `internal/api/unified_agent.go` 4. `internal/agentupdate/update.go` 5. `internal/hostagent/agent.go` @@ -548,7 +548,9 @@ update, profile rollout, command reachability, or fleet-control authority. with tenant-local tokens; branding is report rendering configuration inside that runtime, not a control-plane token, agent profile, or cross-client ingest path. -21. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +21. `internal/api/agentbinding/policy.go` shared with `api-contracts`, `security-privacy`: install-token command-channel binding is simultaneously an agent lifecycle admission policy, a canonical API identity contract, and a security boundary. +22. `internal/api/agenttokens/install.go` shared with `api-contracts`, `security-privacy`: agent install-token issuance and persistence are simultaneously an agent lifecycle authority, a canonical API token contract, and a security boundary. +21. `internal/api/configapi/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. Assisted-setup naming is lifecycle-owned bootstrap state: the connection name typed in the add dialog travels on the one-time setup token (`SetupTokenRecord.DesiredName`) rather than through the node-side script, @@ -557,7 +559,7 @@ update, profile rollout, command reachability, or fleet-control authority. self-reported hostname only when no name was typed. Dedup and cluster-member adoption identity remain hostname/candidate based so the carried name cannot fork or rotate an existing registration. -22. `internal/api/setup_script_render.go` shared with `api-contracts`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). +22. `internal/api/configapi/setup_script_render.go` shared with `api-contracts`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). PBS setup-script auto-registration remains lifecycle-owned bootstrap transport: rendered scripts must post registration payloads to the canonical Pulse base URL plus `/api/auto-register`, not to the script download @@ -2036,7 +2038,7 @@ the intentionally sparse public response. lifecycle-adjacent setup and install surfaces may rely on the authenticated user identity passed through that helper, but they must not treat a missing configured role header as administrator proof. -13. Preserve shipped security-doc guidance in shared lifecycle setup helpers so `internal/api/config_setup_handlers.go` and adjacent install/setup runtime paths point operators at the running build's local security documentation route rather than GitHub `main` links. +13. Preserve shipped security-doc guidance in shared lifecycle setup helpers so `internal/api/configapi/config_setup_handlers.go` and adjacent install/setup runtime paths point operators at the running build's local security documentation route rather than GitHub `main` links. 14. Keep shared `internal/api/router.go` workload-chart downsampling presentation-only: when that router caps mixed-cadence workload history into equal-time buckets for operator-facing cards, lifecycle-adjacent setup and fleet surfaces must not reuse the shaped chart samples as heartbeat, enrollment, or last-seen authority. That same presentation-only boundary must preserve canonical millisecond timestamps when it serializes chart points, so lifecycle-adjacent first-host and fleet surfaces do not misread rounded chart samples as duplicate or restarted heartbeat evidence. The same rule now applies to storage summary interaction. Shared sticky-card or row-hover focus behavior on infrastructure, workloads, and storage may reuse the canonical chart transport, but lifecycle-adjacent install, enrollment, and fleet surfaces must not treat highlighted summary series or sticky-shell state as agent freshness or setup progress. @@ -2678,7 +2680,7 @@ runtime, daemon host, and Swarm capability from the new connection. ### Shared system-settings boundary dropped dead auto-update schedule fields The shared `internal/api` system-settings surface this subsystem consumes -(`internal/api/system_settings.go`, `internal/api/config_system_handlers.go`) +(`internal/api/system_settings.go`, `internal/api/configapi/config_system_handlers.go`) removed the never-consumed `autoUpdateCheckInterval` / `autoUpdateTime` fields. No agent-lifecycle behavior keyed off them — agent update targeting and command admission are unaffected — and the extension-point expectations @@ -4569,7 +4571,7 @@ That same shared `internal/api/` dependency also assumes config import reloads fail closed without panicking when optional runtime managers are absent. Lifecycle-adjacent setup, install, and restore flows may invoke the shared config-import path before every notification or monitoring manager is wired, -but `internal/api/config_export_import_handlers.go` must still rebind the +but `internal/api/configapi/config_export_import_handlers.go` must still rebind the imported configuration without turning missing optional managers into a fatal reload path. The same proof boundary also owns deterministic first-run re-entry for the @@ -5429,13 +5431,13 @@ persist service arguments without `--token` on token-optional Pulse instances, instead of advertising a no-token flow in settings while the installer still fails validation at runtime. That same optional-auth install contract also applies to backend-generated -Proxmox install commands in `internal/api/config_setup_handlers.go` and +Proxmox install commands in `internal/api/configapi/config_setup_handlers.go` and `internal/api/agent_install_command_shared.go`: when Pulse auth is not configured, the canonical agent-install-command API must return tokenless install transport and must not persist a new API token record just because an operator opened a backend-driven install surface. That same backend-owned setup/install boundary also owns shipped security-doc -guidance in runtime responses and logs: `internal/api/config_setup_handlers.go` +guidance in runtime responses and logs: `internal/api/configapi/config_setup_handlers.go` and adjacent lifecycle setup helpers must not point operators at GitHub `main` for security instructions that the running build already serves locally, and should use the shipped `/docs/SECURITY.md` path instead. @@ -6053,9 +6055,9 @@ Token-optional installations retain the setup-token bootstrap path. Ordinary hosts never enter this Proxmox registration loop, while PVE, PBS, mixed-product hosts, restarts, and hosted-tenant install tokens retain the same authority split. `internal/hostagent/proxmox_setup_test.go`, -`internal/api/config_handlers_auto_register_test.go`, -`internal/api/proxmox_install_registration_test.go`, and -`internal/api/issue1644_host_install_token_proxmox_test.go` prove the recurring +`internal/api/configapi/config_handlers_auto_register_test.go`, +`internal/api/configapi/proxmox_install_registration_test.go`, and +`internal/api/configapi/issue1644_host_install_token_proxmox_test.go` prove the recurring health, first-install, type/host binding, one-time consumption, rejection, host-token bootstrap, and concurrent-completion contracts. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 36ecde42e..fb9251e53 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -28,6 +28,12 @@ and their monitor adapters live together in `internal/api/alerting/` with their unit and contract tests. The root `internal/api` package retains router wiring, cross-domain integration proof, and compatibility aliases for established extensions; domain behavior must not be copied back into that root facade. +Configuration, node lifecycle, discovery, export/import, setup-script, +auto-registration, and agent-install handlers live with their focused tests in +`internal/api/configapi/`. Install-token persistence and command-channel +binding policy are shared through `internal/api/agenttokens/` and +`internal/api/agentbinding/`, so config enrollment and root Router admission +consume one security contract without importing each other. Browser WebSocket shutdown distinguishes ordinary client lifecycle from transport failure. Normal closure, navigation/going-away, and abnormal closure @@ -217,9 +223,14 @@ continues to mint and quote the enrollment token. 58. `internal/cloudcp/portal/frontend_sync_test.go` 59. `internal/api/recovery_handlers.go` 51a. `internal/api/pbs_backups.go` -60. `internal/api/config_setup_handlers.go` - 52a. `internal/api/setup_script_render.go` +60. `internal/api/configapi/config_setup_handlers.go` + 52a. `internal/api/configapi/setup_script_render.go` 52b. `internal/api/cloud_agent_install_command.go` + 52c. `internal/api/configapi/config_handlers.go` + 52d. `internal/api/configapi/config_node_handlers.go` + 52e. `internal/api/configapi/config_system_handlers.go` + 52f. `internal/api/configapi/config_discovery_handlers.go` + 52g. `internal/api/configapi/config_export_import_handlers.go` 61. `internal/api/demo_mode_commercial.go` 62. `internal/api/demo_mode_operations.go` 63. `internal/api/security_status_capabilities.go` @@ -249,6 +260,8 @@ continues to mint and quote the enrollment token. 79a. `internal/api/patrol_objectives.go` 80. `frontend-modern/src/api/generated/aiChatEvents.ts` 81. `internal/api/agent_exec_token_binding.go` + 81a. `internal/api/agentbinding/policy.go` + 81b. `internal/api/agenttokens/install.go` 72a. `cmd/pulse-mcp/main.go` 72b. `cmd/pulse-mcp/README.md` 72c. `cmd/agent-probe/main.go` @@ -1477,6 +1490,8 @@ payload shape change when the portal presents compact client rows. page-local payload ownership. 63. `internal/api/agent_ingest.go` shared with `agent-lifecycle`: Unified Agent report admission, removal, remote-config identity binding, and re-enrollment responses are both an agent lifecycle authority and a canonical authenticated API contract boundary. 64. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary. +65. `internal/api/agentbinding/policy.go` shared with `agent-lifecycle`, `security-privacy`: install-token command-channel binding is simultaneously an agent lifecycle admission policy, a canonical API identity contract, and a security boundary. +66. `internal/api/agenttokens/install.go` shared with `agent-lifecycle`, `security-privacy`: agent install-token issuance and persistence are simultaneously an agent lifecycle authority, a canonical API token contract, and a security boundary. Frontend and backend Unix install command builders must stay on the same token-file and preflight transport contract: tokens are passed to the installer as ephemeral files, and host install snippets must verify the @@ -1769,7 +1784,8 @@ payload shape change when the portal presents compact client rows. 66. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary. 67. `internal/api/alerting/notification_queue.go` shared with `notifications`: the notification queue and DLQ handler is both a notification delivery consequence surface and a canonical API payload boundary for operational transition links. 68. `internal/api/alerting/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary. -69. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +69. `internal/api/configapi/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +70. `internal/api/configapi/setup_script_render.go` shared with `agent-lifecycle`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). That same shared boundary also owns reachable-host selection truth for canonical Proxmox registration: runtime callers may propose ordered `candidateHosts`, but the API contract must persist and echo the first candidate Pulse can actually reach instead of freezing the caller's rejected first preference into the stored node endpoint. That same canonical payload contract also owns strict-TLS truth for that selected host: `/api/auto-register` may only persist `VerifySSL=true` when Pulse actually captured a certificate fingerprint for the selected candidate, and it must not pretend public-CA verification is safe after every candidate fingerprint probe failed. For PVE cluster sources, that same contract must distinguish primary @@ -1923,7 +1939,7 @@ payload shape change when the portal presents compact client rows. before minting a `relay:mobile:access` credential. Community installs may receive the standard license-required response, but direct API calls must not bypass Relay entitlement by creating mobile runtime tokens. -81. `internal/api/setup_script_render.go` shared with `agent-lifecycle`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). + Setup-script rendering remains governed by the shared boundary above. PVE setup-script auto-registration is part of the rendered API contract: after creating the privilege-separated token and applying ACLs, the script must smoke-test the exact token id/value against @@ -3887,7 +3903,7 @@ the authoritative analysis outcome. role mappings, or stored secret markers unless the update explicitly replaces them. 33. Keep config-archive import reloads fail-closed on the shared API/runtime - boundary. `internal/api/config_export_import_handlers.go`, + boundary. `internal/api/configapi/config_export_import_handlers.go`, `internal/api/contract_test.go`, and adjacent config/runtime helpers must tolerate absent notification managers and other optional runtime managers after a successful import-triggered reload request, returning a controlled @@ -4091,11 +4107,18 @@ auto-register mutation boundary. ## Current State -The first production package boundary is active under -`internal/api/alerting/`, backed by shared tenant-context and scope-enforcement -packages. Alert and notification unit/contract proof executes independently; -the root router package retains cross-domain integration tests and stable -compatibility aliases instead of duplicating domain behavior. +Production package boundaries are active under `internal/api/alerting/` and +`internal/api/configapi/`, backed by shared tenant-context, scope-enforcement, +install-token, and agent-binding packages. Alert/notification and +configuration/enrollment unit and contract proof execute independently; the +root router package retains cross-domain integration tests and stable +compatibility aliases instead of duplicating domain behavior. On the reference +10-logical-CPU development host, an uncached `go test -json -count=1 +./internal/api/...` qualification run moved from 241.64 seconds at the +monolithic baseline to 169.12 seconds on the final passing decomposed +measurement (30.01% lower wall time), while aggregate CPU use increased from +1.241 to 1.544 cores. This development-host sample is not a substitute for the +PVE release-worker result. ### Host sensor payloads carry typed command and REST custom readings @@ -4115,7 +4138,7 @@ projection are pinned by `TestCustomSensorMetricJSONRoundTrip`, ### System settings shed the dead auto-update schedule fields The system settings payload (`internal/config.SystemSettings`, projected by -`internal/api/config_system_handlers.go` and mutated through +`internal/api/configapi/config_system_handlers.go` and mutated through `internal/api/system_settings.go`) no longer includes `autoUpdateCheckInterval` or `autoUpdateTime`. Nothing ever consumed the fields — the unattended update schedule is owned entirely by the @@ -6379,7 +6402,7 @@ The same shared-boundary rule now applies to `frontend-modern/src/api/agentProfi `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/utils/agentInstallCommand.ts`, `internal/api/agent_install_command_shared.go`, -`internal/api/config_setup_handlers.go`, and `internal/api/unified_agent.go`: +`internal/api/configapi/config_setup_handlers.go`, and `internal/api/unified_agent.go`: agent install/register/profile control changes must preserve canonical API payload behavior instead of drifting into subsystem-local transport rules. That same shared boundary now assumes `InfrastructureWorkspace.tsx` owns the @@ -6499,7 +6522,7 @@ carry a direct API-contract proof path instead of relying only on the generic frontend client or backend payload fallback coverage. That same rule now applies to the shared backend lifecycle install/register surface as well: `internal/api/agent_install_command_shared.go`, -`internal/api/config_setup_handlers.go`, and `internal/api/unified_agent.go` +`internal/api/configapi/config_setup_handlers.go`, and `internal/api/unified_agent.go` must carry a direct API-contract proof path instead of relying only on the generic `internal/api/` backend payload prefix. That same backend-owned `internal/api/` boundary also includes the generated @@ -7002,8 +7025,8 @@ That same request contract must also accept one-time setup-token auth through `setupCode` payload alias alongside the canonical field. That same shared discovery transport surface must also keep structured error ownership in the runtime model: `pkg/discovery` and `internal/discovery` own -`structured_errors`, while `internal/api/config_discovery_handlers.go`, -`internal/api/config_setup_handlers.go`, and `internal/api/config_node_handlers.go` +`structured_errors`, while `internal/api/configapi/config_discovery_handlers.go`, +`internal/api/configapi/config_setup_handlers.go`, and `internal/api/configapi/config_node_handlers.go` may derive the deprecated `errors` string list only as a compatibility field at the API and WebSocket boundary. That same WebSocket state boundary must also stay tenant-aware by construction: @@ -7757,7 +7780,7 @@ treat exclusions as client-only display state. The node update payload on `PUT /api/config/nodes/{id}` now also carries an optional write-only `clusterEndpointOverrides` collection of `{nodeName, ipOverride}` entries handled by -`internal/api/config_node_handlers.go`: only the members named in the request +`internal/api/configapi/config_node_handlers.go`: only the members named in the request are touched, an empty `ipOverride` clears the stored override, naming an unknown cluster member is a `400` rather than a silent no-op, and accepted values are normalized to a scheme-less IP or hostname with optional port @@ -7769,7 +7792,7 @@ optional `ipOverride` endpoint field consumed by the shared normalization in `frontend-modern/src/api/nodes.ts`. `ClusterEndpoints[n].IPOverride` has a second canonical writer: when a canonical `/api/auto-register` PVE registration in -`internal/api/config_setup_handlers.go` matches a non-primary cluster member +`internal/api/configapi/config_setup_handlers.go` matches a non-primary cluster member endpoint (address identity against the agent's candidate list first, then an unambiguous corosync node-name match), the handler adopts the Pulse-verified selected host as that member's override plus the fingerprint captured from @@ -8100,7 +8123,7 @@ customer-facing mutation and validation copy used by the governed runtime hooks stays explicit under the same API-backed settings proof instead of living as an unowned utility. That same backend-owned config/settings boundary also owns shipped security-doc -references in operator guidance. `internal/api/config_system_handlers.go` and +references in operator guidance. `internal/api/configapi/config_system_handlers.go` and shared setup helpers must not point API responses or runtime guidance at GitHub `main` for security instructions that the running build already serves locally; those references belong on the shipped `/docs/SECURITY.md` path. @@ -8337,7 +8360,7 @@ must then echo that resolved model back as the canonical default selection, so UI setup flows and provider test routes do not drift into frontend-baked model defaults or handler-local vendor fallbacks. That same shared config/runtime contract also owns import-triggered reload -safety. When `internal/api/config_export_import_handlers.go` imports a config +safety. When `internal/api/configapi/config_export_import_handlers.go` imports a config archive and rebinds shared runtime state, the reload path must tolerate absent notification or monitoring managers and degrade gracefully instead of panicking on optional side effects. `/api/config/import` may be exercised from @@ -9141,8 +9164,8 @@ Negative `checkRegistration` calls do not consume the install grant; a positive match consumes an otherwise-unneeded fresh-install grant so reinstalling an already-registered source cannot leave dormant creation authority. Successful steady-state authentication/processing emits only at debug severity. -`internal/api/config_handlers_auto_register_test.go`, -`internal/api/proxmox_install_registration_test.go`, and +`internal/api/configapi/config_handlers_auto_register_test.go`, +`internal/api/configapi/proxmox_install_registration_test.go`, and `internal/hostagent/proxmox_setup_test.go` prove the wire headers, omitted setup credential, one-time bootstrap, concurrency, and rejected-token diagnostics. @@ -9166,7 +9189,7 @@ The browser config payload remains additive and compatible: no endpoint field is removed or renamed. Monitoring owns the later repeated-authoritative- absence retirement rule; config handlers must not reinterpret a partial membership or telemetry read as deletion. -`internal/api/config_handlers_cluster_additional_test.go` and +`internal/api/configapi/config_handlers_cluster_additional_test.go` and `internal/config/pve_instances_test.go` prove unreachable-member persistence, evidence preservation, duplicate consolidation, and same-name-cluster isolation. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 06bca8d47..5c19c0300 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -3114,7 +3114,7 @@ secret deterministically: it must request the machine-readable `pveum ... --output-format json` form first and parse the `value` field, falling back to the legacy box-drawing table layout only when an older pveum rejects the JSON flag — matching the hardened web-setup render path -(`internal/api/setup_script_render.go`) so token capture does not silently fail +(`internal/api/configapi/setup_script_render.go`) so token capture does not silently fail or mis-parse when pveum's table formatting drifts across versions/locales. `scripts/installtests/root_install_sh_test.go` is the owned proof surface for that install-time extraction. A diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index a3621e90c..a20467a86 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -763,6 +763,24 @@ "api-contracts" ] }, + { + "path": "internal/api/agentbinding/policy.go", + "rationale": "install-token command-channel binding is simultaneously an agent lifecycle admission policy, a canonical API identity contract, and a security boundary", + "subsystems": [ + "agent-lifecycle", + "api-contracts", + "security-privacy" + ] + }, + { + "path": "internal/api/agenttokens/install.go", + "rationale": "agent install-token issuance and persistence are simultaneously an agent lifecycle authority, a canonical API token contract, and a security boundary", + "subsystems": [ + "agent-lifecycle", + "api-contracts", + "security-privacy" + ] + }, { "path": "internal/api/ai_handler.go", "rationale": "Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary", @@ -804,13 +822,22 @@ ] }, { - "path": "internal/api/config_setup_handlers.go", + "path": "internal/api/configapi/config_setup_handlers.go", "rationale": "auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary", "subsystems": [ "agent-lifecycle", "api-contracts" ] }, + { + "path": "internal/api/configapi/setup_script_render.go", + "rationale": "the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection)", + "subsystems": [ + "agent-lifecycle", + "api-contracts", + "storage-recovery" + ] + }, { "path": "internal/api/enterprise_extension_rbac_admin.go", "rationale": "RBAC admin extension endpoints are both an organization settings control surface and a canonical API payload contract boundary", @@ -915,15 +942,6 @@ "security-privacy" ] }, - { - "path": "internal/api/setup_script_render.go", - "rationale": "the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection)", - "subsystems": [ - "agent-lifecycle", - "api-contracts", - "storage-recovery" - ] - }, { "path": "internal/api/slo.go", "rationale": "the SLO endpoint is both an API contract surface and a protected performance hot-path boundary", @@ -1261,8 +1279,10 @@ "internal/agenttls/config.go", "internal/api/agent_ingest.go", "internal/api/agent_install_command_shared.go", - "internal/api/config_setup_handlers.go", - "internal/api/setup_script_render.go", + "internal/api/agentbinding/policy.go", + "internal/api/agenttokens/install.go", + "internal/api/configapi/config_setup_handlers.go", + "internal/api/configapi/setup_script_render.go", "internal/api/unified_agent.go", "internal/config/host_continuity.go", "internal/dockeragent/agent.go", @@ -1341,18 +1361,23 @@ "match_prefixes": [], "match_files": [ "internal/api/agent_install_command_shared.go", - "internal/api/config_setup_handlers.go", - "internal/api/setup_script_render.go", + "internal/api/agentbinding/policy.go", + "internal/api/agenttokens/install.go", + "internal/api/configapi/config_setup_handlers.go", + "internal/api/configapi/setup_script_render.go", "internal/api/unified_agent.go" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ "internal/api/agent_install_command_shared_test.go", - "internal/api/config_handlers_auto_register_test.go", - "internal/api/config_handlers_canonical_auto_register_test.go", + "internal/api/agentbinding/policy_test.go", + "internal/api/agenttokens/install_test.go", + "internal/api/configapi/config_handlers_auto_register_test.go", + "internal/api/configapi/config_handlers_canonical_auto_register_test.go", "internal/api/contract_test.go", "internal/api/hosted_agent_install_command_test.go", + "internal/api/issue1644_exec_binding_integration_test.go", "internal/api/unified_agent_more_test.go", "internal/api/unified_agent_test.go" ] @@ -2512,7 +2537,7 @@ "frontend-modern/src/utils/agentInstallCommand.ts", "frontend-modern/src/utils/apiTokenPresentation.ts", "frontend-modern/src/utils/infrastructureSettingsPresentation.ts", - "internal/api/setup_script_render.go", + "internal/api/configapi/setup_script_render.go", "internal/config/persistence_metadata_accessors.go", "internal/websocket/hub.go", "pkg/aicontracts/action_broker.go", @@ -2750,15 +2775,15 @@ "match_files": [ "internal/api/agent_install_command_shared.go", "internal/api/cloud_agent_install_command.go", - "internal/api/config_setup_handlers.go", + "internal/api/configapi/config_setup_handlers.go", "internal/api/unified_agent.go" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ "internal/api/agent_install_command_shared_test.go", - "internal/api/config_handlers_auto_register_test.go", - "internal/api/config_handlers_canonical_auto_register_test.go", + "internal/api/configapi/config_handlers_auto_register_test.go", + "internal/api/configapi/config_handlers_canonical_auto_register_test.go", "internal/api/contract_test.go", "internal/api/hosted_agent_install_command_test.go", "internal/api/unified_agent_more_test.go", @@ -2787,6 +2812,8 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/api/security.ts", + "internal/api/agentbinding/policy.go", + "internal/api/agenttokens/install.go", "internal/api/security.go", "internal/api/security_tokens.go", "internal/api/system_settings.go", @@ -2905,11 +2932,11 @@ "frontend-modern/src/types/api.ts", "internal/api/ai_handlers_more_test.go", "internal/api/ai_handlers_patrol_actions_additional_test.go", + "internal/api/alerting/external_probe_notifications_test.go", "internal/api/audit_handlers_test.go", "internal/api/availability_handlers_test.go", "internal/api/contract_test.go", "internal/api/docker_agents_report_size_test.go", - "internal/api/alerting/external_probe_notifications_test.go", "internal/api/host_agent_removal_lifecycle_integration_test.go", "internal/api/issue1640_readiness_gate_test.go", "internal/api/issue1640_readiness_transport_test.go", @@ -6839,6 +6866,8 @@ "frontend-modern/src/utils/auditWebhookPresentation.ts", "frontend-modern/src/utils/securityAuthPresentation.ts", "frontend-modern/src/utils/securityScorePresentation.ts", + "internal/api/agentbinding/policy.go", + "internal/api/agenttokens/install.go", "internal/api/security.go", "internal/api/security_tokens.go", "internal/api/system_settings.go", @@ -7057,6 +7086,8 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/api/security.ts", + "internal/api/agentbinding/policy.go", + "internal/api/agenttokens/install.go", "internal/api/security.go", "internal/api/security_tokens.go", "internal/api/system_settings.go" @@ -7065,6 +7096,9 @@ "test_prefixes": [], "exact_files": [ "frontend-modern/src/api/__tests__/security.test.ts", + "internal/api/agentbinding/policy_test.go", + "internal/api/agenttokens/install_test.go", + "internal/api/issue1644_exec_binding_integration_test.go", "internal/api/security_regression_test.go", "internal/api/security_status_additional_test.go", "internal/api/security_tokens_lifecycle_test.go", @@ -7130,7 +7164,7 @@ "frontend-modern/src/utils/recoveryOutcomePresentation.ts", "frontend-modern/src/utils/recoveryTimelineChartPresentation.ts", "frontend-modern/src/utils/recoveryTimelinePresentation.ts", - "internal/api/setup_script_render.go", + "internal/api/configapi/setup_script_render.go", "internal/proxmoxidentity/backup_identity.go" ], "verification": { @@ -7255,7 +7289,7 @@ "label": "shared Proxmox setup-script storage/recovery boundary proof", "match_prefixes": [], "match_files": [ - "internal/api/setup_script_render.go" + "internal/api/configapi/setup_script_render.go" ], "allow_same_subsystem_tests": false, "test_prefixes": [], diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 147d9bf9d..56818c722 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -191,6 +191,8 @@ with missing, unknown, or unrelated scopes fail closed. policy-allowed fixes, verification, and history. Security-facing token setup must not present it as generic operations workflow access. 12. `frontend-modern/src/utils/apiTokenPresentation.ts` shared with `api-contracts`: the API token presentation helper is both a security/privacy control surface and a canonical API token management boundary. +13. `internal/api/agentbinding/policy.go` shared with `agent-lifecycle`, `api-contracts`: install-token command-channel binding is simultaneously an agent lifecycle admission policy, a canonical API identity contract, and a security boundary. +14. `internal/api/agenttokens/install.go` shared with `agent-lifecycle`, `api-contracts`: agent install-token issuance and persistence are simultaneously an agent lifecycle authority, a canonical API token contract, and a security boundary. It owns Docker / Podman token copy for API Access, token presets, usage summaries, and revoke warnings so security-facing copy does not drift into page-local `container runtime` labels. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 40850f23c..f467a3df2 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -188,7 +188,7 @@ a recovery-provider read helper or compatibility alias. 1. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx` shared with `unified-resources`: Proxmox backup server table rows are both a storage/recovery backup-health surface and a unified-resource platform-table consumer boundary. 2. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx` shared with `unified-resources`: Proxmox workload coverage rows are both a storage/recovery protection-posture surface and a unified-resource identity consumer boundary. 3. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx` shared with `unified-resources`: Proxmox recoverable workload table rows are both a storage/recovery coverage surface and a unified-resource platform-table consumer boundary. -4. `internal/api/setup_script_render.go` shared with `agent-lifecycle`, `api-contracts`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). +4. `internal/api/configapi/setup_script_render.go` shared with `agent-lifecycle`, `api-contracts`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). The user-chosen connection name carried by assisted setup travels on the server-side setup token, not through the rendered script or its registration payload, so honoring it changes neither the backup @@ -297,10 +297,10 @@ remain independently observed inputs, and the live authenticated agent-server readiness check remains authoritative before any action admission or dispatch. Per-member cluster endpoint connection-address overrides accepted by -`internal/api/config_node_handlers.go` (`clusterEndpointOverrides` on the node +`internal/api/configapi/config_node_handlers.go` (`clusterEndpointOverrides` on the node update payload writing `ClusterEndpoints[n].IPOverride`), including the agent-driven writes of the same field by canonical auto-register member -matching in `internal/api/config_setup_handlers.go`, are monitoring +matching in `internal/api/configapi/config_setup_handlers.go`, are monitoring connectivity state only. Storage- and recovery-adjacent surfaces may observe the effective member address for support diagnostics, but they must not treat a present or absent `ipOverride` as backup coverage, restore readiness, or @@ -1713,7 +1713,7 @@ recovery scope, or a storage/recovery-owned secret source. recovery. Shared `internal/api/router.go` may mount the `/api/connections` and `/api/connections/probe` routes alongside the existing storage/recovery-adjacent API surfaces, and - `internal/api/config_handlers.go` and `internal/api/config_node_handlers.go` + `internal/api/configapi/config_handlers.go` and `internal/api/configapi/config_node_handlers.go` may carry the new per-instance `Enabled`/`Disabled` round-trip, but storage and recovery consumers must not reinterpret the derived connection `state` (active/paused/unauthorized/unreachable/stale/pending) @@ -2148,7 +2148,7 @@ must not treat starter storage-local timeout queue. 24. Keep storage/recovery-adjacent config-import reload safety on the shared `internal/api/` boundary. When storage or recovery setup flows depend on - `internal/api/config_export_import_handlers.go`, post-import reloads must + `internal/api/configapi/config_export_import_handlers.go`, post-import reloads must tolerate absent notification managers and other optional runtime managers so adjacent browser surfaces inherit a fail-closed API response instead of a panic after the archive import succeeds. @@ -2248,7 +2248,7 @@ pinned by `TestResourceFromHostPreservesCustomSensorMeta` and ### Shared system-settings boundary dropped dead auto-update schedule fields The shared `internal/api` system-settings surface this subsystem consumes -(`internal/api/system_settings.go`, `internal/api/config_system_handlers.go`) +(`internal/api/system_settings.go`, `internal/api/configapi/config_system_handlers.go`) removed the never-consumed `autoUpdateCheckInterval` / `autoUpdateTime` fields. Persisted `system.json` files that still carry the legacy keys load cleanly with the keys ignored, so tenant workspace preservation and recovery @@ -4357,7 +4357,7 @@ That same shared `internal/api/` dependency also assumes config import reloads degrade safely when optional runtime managers are missing. Storage- and recovery-adjacent restore or support flows may drive the shared `/api/config/import` boundary before every notification or monitoring manager -exists, but `internal/api/config_export_import_handlers.go` must still apply +exists, but `internal/api/configapi/config_export_import_handlers.go` must still apply the imported configuration without panicking on absent optional managers. The same boundary also owns first-session reset cleanup during managed-backend proof: the dev-only `/api/security/dev/reset-first-run` route must clear auth @@ -5043,7 +5043,7 @@ public-demo bootstrap signal instead of inferring demo posture from headers, ### Adjacent Proxmox registration authorization neutrality -The shared `internal/api/config_setup_handlers.go` Proxmox registration fix is +The shared `internal/api/configapi/config_setup_handlers.go` Proxmox registration fix is adjacent lifecycle/API authority only. Runtime agents now use `/api/auto-register` directly, ordinary agent tokens remain update-only, and a server-minted Proxmox install token may consume one host-bound initial source diff --git a/internal/api/agent_exec_token_binding.go b/internal/api/agent_exec_token_binding.go index 50ee7cd6f..9a5fec090 100644 --- a/internal/api/agent_exec_token_binding.go +++ b/internal/api/agent_exec_token_binding.go @@ -5,8 +5,8 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding" "github.com/rcourtman/pulse-go-rewrite/internal/config" - "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rs/zerolog/log" ) @@ -52,7 +52,7 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s // (unifiedresources.HostnamesEquivalent); the case-insensitive exact branch // keeps IP-literal hostnames comparable, which HostnamesEquivalent rejects. func agentExecHostnamesMatch(bound, requested string) bool { - return strings.EqualFold(bound, requested) || unifiedresources.HostnamesEquivalent(bound, requested) + return agentbinding.HostnamesMatch(bound, requested) } // agentExecBindingDecision is the single source of truth for whether an @@ -75,55 +75,14 @@ type agentExecBindingDecision struct { // while its command channel was rejected, leaving the host permanently on // "Remote control blocked" with reinstall as the only recourse. func evaluateAgentExecBinding(record *config.APITokenRecord, requestedID, requestedHost string) agentExecBindingDecision { - if record == nil { - return agentExecBindingDecision{} - } - requestedID = strings.TrimSpace(requestedID) - requestedHost = strings.TrimSpace(requestedHost) - boundID := strings.TrimSpace(record.Metadata["bound_agent_id"]) - boundHost := strings.TrimSpace(record.Metadata["bound_hostname"]) - - if boundID == "" && boundHost == "" { - if canBindAgentInstallExecToken(record, requestedID, requestedHost) { - return agentExecBindingDecision{admit: true, firstBind: true} - } - return agentExecBindingDecision{} - } - - // An install token that already auto-registered a Proxmox source carries a - // bound_hostname written by the registration bootstrap, with no - // bound_agent_id and no binding version (#1644). That is still a clean - // first use of the command channel, not a legacy pre-v6.1.1 record, so bind - // it here with the fresh runtime agent ID instead of letting it fall - // through to the legacy-migration branch. - if canBindAutoRegisteredAgentInstallExecToken(record, requestedID, requestedHost) { - return agentExecBindingDecision{admit: true, firstBind: true} - } - - // Pre-v6.1.1 deploy tokens could carry a server-synthesized agent ID even - // though the runtime derives its ID from machine-id. Migrate that - // hostname-bound legacy record exactly once, then enforce identity. - if strings.TrimSpace(record.Metadata[agentExecBindingVersionKey]) != agentExecBindingVersion && - boundHost != "" && agentExecHostnamesMatch(boundHost, requestedHost) { - return agentExecBindingDecision{admit: true, legacyMigrate: true} - } - - idMatches := boundID == "" || boundID == requestedID - hostMatches := boundHost == "" || agentExecHostnamesMatch(boundHost, requestedHost) - // The runtime agent ID is immutable machine identity while hostnames can - // be renamed after enrollment, so an exact ID match re-binds a drifted - // hostname rather than stranding the host: v6.1.1 admitted these agents - // under an ID-or-hostname rule, and rejecting them afterwards leaves no - // operator recourse short of reinstalling the agent. - rebindHostname := boundID != "" && boundID == requestedID && !hostMatches && requestedHost != "" - if !idMatches || (!hostMatches && !rebindHostname) { - return agentExecBindingDecision{} - } + decision := agentbinding.Evaluate(record, requestedID, requestedHost) return agentExecBindingDecision{ - admit: true, - rebindHostname: rebindHostname, - backfillID: boundID == "" && boundHost != "", - backfillHost: boundHost == "" && boundID != "", + admit: decision.Admit, + firstBind: decision.FirstBind, + legacyMigrate: decision.LegacyMigrate, + rebindHostname: decision.RebindHostname, + backfillID: decision.BackfillID, + backfillHost: decision.BackfillHost, } } @@ -389,26 +348,7 @@ func (r *Router) agentCommandSessionConnected(organizationID, tokenID, agentID, } func canBindAgentInstallExecToken(record *config.APITokenRecord, agentID string, hostname string) bool { - if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" { - return false - } - if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" || - strings.TrimSpace(record.Metadata["bound_hostname"]) != "" { - return false - } - - switch strings.TrimSpace(record.Metadata["install_type"]) { - case proxmoxInstallTypePVE, proxmoxInstallTypePBS, agentInstallTypeHost: - default: - return false - } - - switch strings.TrimSpace(record.Metadata["issued_via"]) { - case agentInstallIssuedViaConfig, agentInstallIssuedViaHosted: - return true - default: - return false - } + return agentbinding.CanBindInstallToken(record, agentID, hostname) } // canBindAutoRegisteredAgentInstallExecToken reports whether an install token @@ -420,30 +360,5 @@ func canBindAgentInstallExecToken(record *config.APITokenRecord, agentID string, // legacy-migration branch, which exists to repair old records and is not the // contract this flow should depend on. Hostname equivalence is still required. func canBindAutoRegisteredAgentInstallExecToken(record *config.APITokenRecord, agentID string, hostname string) bool { - if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" { - return false - } - if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" { - return false - } - if strings.TrimSpace(record.Metadata[agentExecBindingVersionKey]) != "" { - return false - } - boundHost := strings.TrimSpace(record.Metadata["bound_hostname"]) - if boundHost == "" || !agentExecHostnamesMatch(boundHost, strings.TrimSpace(hostname)) { - return false - } - - switch strings.TrimSpace(record.Metadata["install_type"]) { - case proxmoxInstallTypePVE, proxmoxInstallTypePBS, agentInstallTypeHost: - default: - return false - } - - switch strings.TrimSpace(record.Metadata["issued_via"]) { - case agentInstallIssuedViaConfig, agentInstallIssuedViaHosted: - return true - default: - return false - } + return agentbinding.CanBindAutoRegisteredInstallToken(record, agentID, hostname) } diff --git a/internal/api/agent_install_command_shared.go b/internal/api/agent_install_command_shared.go index a6464f52f..5448583ea 100644 --- a/internal/api/agent_install_command_shared.go +++ b/internal/api/agent_install_command_shared.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/api/configapi" "github.com/rcourtman/pulse-go-rewrite/internal/config" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) @@ -131,19 +132,7 @@ type agentInstallCommandOptions struct { Insecure bool } -type setupScriptInstallArtifact struct { - Type string `json:"type"` - Host string `json:"host"` - URL string `json:"url"` - DownloadURL string `json:"downloadURL"` - ScriptFileName string `json:"scriptFileName"` - Command string `json:"command"` - CommandWithEnv string `json:"commandWithEnv"` - CommandWithoutEnv string `json:"commandWithoutEnv"` - Expires int64 `json:"expires"` - SetupToken string `json:"setupToken"` - TokenHint string `json:"tokenHint"` -} +type setupScriptInstallArtifact = configapi.SetupScriptInstallArtifact func normalizeAgentInstallBaseURL(raw string) string { return strings.TrimRight(strings.TrimSpace(raw), "/") diff --git a/internal/api/agentbinding/policy.go b/internal/api/agentbinding/policy.go new file mode 100644 index 000000000..e686d5ec4 --- /dev/null +++ b/internal/api/agentbinding/policy.go @@ -0,0 +1,117 @@ +// Package agentbinding owns the immutable policy for install-token command-channel binding. +package agentbinding + +import ( + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +const ( + VersionKey = "agent_exec_binding_version" + Version = "2" + IssuedViaConfig = "config_agent_install_command" + IssuedViaHosted = "hosted_agent_install_command" +) + +type Decision struct { + Admit bool + FirstBind bool + LegacyMigrate bool + RebindHostname bool + BackfillID bool + BackfillHost bool +} + +func Evaluate(record *config.APITokenRecord, requestedID, requestedHost string) Decision { + if record == nil { + return Decision{} + } + requestedID = strings.TrimSpace(requestedID) + requestedHost = strings.TrimSpace(requestedHost) + boundID := strings.TrimSpace(record.Metadata["bound_agent_id"]) + boundHost := strings.TrimSpace(record.Metadata["bound_hostname"]) + + if boundID == "" && boundHost == "" { + if CanBindInstallToken(record, requestedID, requestedHost) { + return Decision{Admit: true, FirstBind: true} + } + return Decision{} + } + if canBindAutoRegisteredInstallToken(record, requestedID, requestedHost) { + return Decision{Admit: true, FirstBind: true} + } + if strings.TrimSpace(record.Metadata[VersionKey]) != Version && + boundHost != "" && hostnamesMatch(boundHost, requestedHost) { + return Decision{Admit: true, LegacyMigrate: true} + } + + idMatches := boundID == "" || boundID == requestedID + hostMatches := boundHost == "" || hostnamesMatch(boundHost, requestedHost) + rebindHostname := boundID != "" && boundID == requestedID && !hostMatches && requestedHost != "" + if !idMatches || (!hostMatches && !rebindHostname) { + return Decision{} + } + return Decision{ + Admit: true, + RebindHostname: rebindHostname, + BackfillID: boundID == "" && boundHost != "", + BackfillHost: boundHost == "" && boundID != "", + } +} + +func CanBindInstallToken(record *config.APITokenRecord, agentID, hostname string) bool { + if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" { + return false + } + if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" || strings.TrimSpace(record.Metadata["bound_hostname"]) != "" { + return false + } + if !supportedInstallType(record.Metadata["install_type"]) { + return false + } + return supportedIssuer(record.Metadata["issued_via"]) +} + +func CanBindAutoRegisteredInstallToken(record *config.APITokenRecord, agentID, hostname string) bool { + return canBindAutoRegisteredInstallToken(record, agentID, hostname) +} + +func HostnamesMatch(bound, requested string) bool { return hostnamesMatch(bound, requested) } + +func canBindAutoRegisteredInstallToken(record *config.APITokenRecord, agentID, hostname string) bool { + if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" { + return false + } + if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" || strings.TrimSpace(record.Metadata[VersionKey]) != "" { + return false + } + boundHost := strings.TrimSpace(record.Metadata["bound_hostname"]) + if boundHost == "" || !hostnamesMatch(boundHost, strings.TrimSpace(hostname)) { + return false + } + return supportedInstallType(record.Metadata["install_type"]) && supportedIssuer(record.Metadata["issued_via"]) +} + +func supportedInstallType(value string) bool { + switch strings.TrimSpace(value) { + case "pve", "pbs", "host": + return true + default: + return false + } +} + +func supportedIssuer(value string) bool { + switch strings.TrimSpace(value) { + case IssuedViaConfig, IssuedViaHosted: + return true + default: + return false + } +} + +func hostnamesMatch(bound, requested string) bool { + return strings.EqualFold(bound, requested) || unifiedresources.HostnamesEquivalent(bound, requested) +} diff --git a/internal/api/agentbinding/policy_test.go b/internal/api/agentbinding/policy_test.go new file mode 100644 index 000000000..c70dba2ff --- /dev/null +++ b/internal/api/agentbinding/policy_test.go @@ -0,0 +1,39 @@ +package agentbinding + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestEvaluateInstallTokenBinding(t *testing.T) { + record := &config.APITokenRecord{Metadata: map[string]string{ + "install_type": "host", + "issued_via": IssuedViaConfig, + }} + decision := Evaluate(record, "machine-id", "node.example") + if !decision.Admit || !decision.FirstBind || decision.LegacyMigrate { + t.Fatalf("fresh install-token decision = %+v", decision) + } + + record.Metadata["bound_hostname"] = "node" + decision = Evaluate(record, "machine-id", "node.example") + if !decision.Admit || !decision.FirstBind || decision.LegacyMigrate { + t.Fatalf("auto-registered install-token decision = %+v", decision) + } + + record.Metadata[VersionKey] = Version + if decision := Evaluate(record, "machine-id", "other.example"); decision.Admit { + t.Fatalf("versioned mismatched binding admitted: %+v", decision) + } +} + +func TestCanBindInstallTokenRejectsUnsupportedIssuer(t *testing.T) { + record := &config.APITokenRecord{Metadata: map[string]string{ + "install_type": "host", + "issued_via": "untrusted", + }} + if CanBindInstallToken(record, "machine-id", "node") { + t.Fatal("unsupported issuer admitted") + } +} diff --git a/internal/api/agenttokens/install.go b/internal/api/agenttokens/install.go new file mode 100644 index 000000000..4d58bb1ae --- /dev/null +++ b/internal/api/agenttokens/install.go @@ -0,0 +1,133 @@ +// Package agenttokens owns the security contract for install-token issuance. +package agenttokens + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +const ( + IssuedAtMetadataKey = "install_issued_at" + OwnerUserIDMetadataKey = "owner_user_id" +) + +var ( + ErrGeneration = errors.New("agent install token generation failed") + ErrRecord = errors.New("agent install token record failed") + ErrPersist = errors.New("agent install token persistence failed") +) + +type IssueOptions struct { + TokenName string + OrgID string + OwnerUserID string + Metadata map[string]string + Scopes []string +} + +func ProxmoxScopes() []string { + return []string{ + config.ScopeAgentReport, + config.ScopeAgentConfigRead, + config.ScopeAgentManage, + config.ScopeAgentExec, + } +} + +func HostScopes(enableCommands bool) []string { + scopes := []string{ + config.ScopeAgentReport, + config.ScopeAgentConfigRead, + config.ScopeAgentManage, + config.ScopeDockerReport, + config.ScopeKubernetesReport, + } + if enableCommands { + scopes = append(scopes, config.ScopeAgentExec) + } + return scopes +} + +func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, opts IssueOptions) (string, *config.APITokenRecord, error) { + if cfg == nil { + return "", nil, fmt.Errorf("config is required") + } + + rawToken, err := internalauth.GenerateAPIToken() + if err != nil { + return "", nil, fmt.Errorf("%w: %w", ErrGeneration, err) + } + + scopes := opts.Scopes + if len(scopes) == 0 { + scopes = ProxmoxScopes() + } + record, err := config.NewAPITokenRecord(rawToken, opts.TokenName, scopes) + if err != nil { + return "", nil, fmt.Errorf("%w: %w", ErrRecord, err) + } + + record.OrgID = strings.TrimSpace(opts.OrgID) + setOwnerUserID(record, opts.OwnerUserID) + if err := mergeMetadata(record, opts.Metadata); err != nil { + return "", nil, fmt.Errorf("%w: %w", ErrRecord, err) + } + if record.Metadata == nil { + record.Metadata = make(map[string]string) + } + record.Metadata[IssuedAtMetadataKey] = record.CreatedAt.UTC().Format(time.RFC3339) + + config.Mu.Lock() + defer config.Mu.Unlock() + + cfg.APITokens = append(cfg.APITokens, *record) + cfg.SortAPITokens() + if persistence != nil { + if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { + cfg.APITokens = cfg.APITokens[:len(cfg.APITokens)-1] + return "", nil, fmt.Errorf("%w: %w", ErrPersist, err) + } + } + + return rawToken, record, nil +} + +func OwnerUserID(record config.APITokenRecord) string { + return strings.TrimSpace(record.Metadata[OwnerUserIDMetadataKey]) +} + +func setOwnerUserID(record *config.APITokenRecord, ownerUserID string) { + ownerUserID = strings.TrimSpace(ownerUserID) + if record == nil || ownerUserID == "" || strings.HasPrefix(ownerUserID, "token:") { + return + } + if record.Metadata == nil { + record.Metadata = make(map[string]string) + } + record.Metadata[OwnerUserIDMetadataKey] = ownerUserID +} + +func mergeMetadata(record *config.APITokenRecord, metadata map[string]string) error { + if record == nil || len(metadata) == 0 { + return nil + } + if record.Metadata == nil { + record.Metadata = make(map[string]string) + } + for key, value := range metadata { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if key == OwnerUserIDMetadataKey { + return fmt.Errorf("reserved token metadata key %q cannot be supplied by caller metadata", OwnerUserIDMetadataKey) + } + record.Metadata[key] = value + } + return nil +} diff --git a/internal/api/agenttokens/install_test.go b/internal/api/agenttokens/install_test.go new file mode 100644 index 000000000..127cadb18 --- /dev/null +++ b/internal/api/agenttokens/install_test.go @@ -0,0 +1,40 @@ +package agenttokens + +import ( + "errors" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestIssueAndPersistInstallToken(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + raw, record, err := IssueAndPersist(cfg, nil, IssueOptions{ + TokenName: "host-agent", + OwnerUserID: "operator", + Scopes: HostScopes(true), + Metadata: map[string]string{"install_type": "host"}, + }) + if err != nil { + t.Fatalf("IssueAndPersist: %v", err) + } + if raw == "" || record == nil || len(cfg.APITokens) != 1 { + t.Fatalf("issued token = (%q, %#v), persisted=%d", raw, record, len(cfg.APITokens)) + } + if OwnerUserID(*record) != "operator" || record.Metadata[IssuedAtMetadataKey] == "" { + t.Fatalf("issued metadata = %#v", record.Metadata) + } + if !record.HasScope(config.ScopeAgentExec) { + t.Fatalf("commands-enabled host scopes = %v", record.Scopes) + } +} + +func TestIssueAndPersistRejectsReservedOwnerMetadata(t *testing.T) { + _, _, err := IssueAndPersist(&config.Config{}, nil, IssueOptions{ + TokenName: "invalid", + Metadata: map[string]string{OwnerUserIDMetadataKey: "forged"}, + }) + if !errors.Is(err, ErrRecord) { + t.Fatalf("reserved owner metadata error = %v, want ErrRecord", err) + } +} diff --git a/internal/api/config_handlers_compat.go b/internal/api/config_handlers_compat.go new file mode 100644 index 000000000..636ded00a --- /dev/null +++ b/internal/api/config_handlers_compat.go @@ -0,0 +1,110 @@ +package api + +import ( + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/api/configapi" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/websocket" +) + +const agentInstallTokenIssuedAtKey = "install_issued_at" + +const proxmoxInstallBootstrapGrantTTL = configapi.ProxmoxInstallBootstrapGrantTTL +const proxmoxInstallGrantExpiredMessage = configapi.ProxmoxInstallGrantExpiredMessage + +type ConfigHandlers = configapi.ConfigHandlers +type SetupTokenRecord = configapi.SetupTokenRecord +type RecentSetupTokenRecord = configapi.RecentSetupTokenRecord +type NodeConfigRequest = configapi.NodeConfigRequest +type ClusterEndpointOverrideRequest = configapi.ClusterEndpointOverrideRequest +type ClusterNodeDisplayNameOverrideRequest = configapi.ClusterNodeDisplayNameOverrideRequest +type NodeResponse = configapi.NodeResponse +type ClusterEndpointResponse = configapi.ClusterEndpointResponse +type AutoRegisterRequest = configapi.AutoRegisterRequest +type AutoRegisterResponse = configapi.AutoRegisterResponse +type AutoUnregisterRequest = configapi.AutoUnregisterRequest +type AutoUnregisterResponse = configapi.AutoUnregisterResponse +type SSHKeyPair = configapi.SSHKeyPair +type AgentInstallCommandRequest = configapi.AgentInstallCommandRequest +type AgentInstallCommandResponse = configapi.AgentInstallCommandResponse +type ExportConfigRequest = configapi.ExportConfigRequest +type ImportConfigRequest = configapi.ImportConfigRequest +type setupScriptRenderContext = configapi.SetupScriptRenderContext + +func NewConfigHandlers(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenantMonitor, reloadFunc func() error, wsHub *websocket.Hub, guestMetadataHandler *GuestMetadataHandler, reloadSystemSettingsFunc func(), hostedMode bool) *ConfigHandlers { + handler := configapi.NewConfigHandlers(mtp, mtm, reloadFunc, wsHub, guestMetadataHandler, reloadSystemSettingsFunc, hostedMode) + handler.SetRuntimeDependencies(configapi.RuntimeDependencies{ + AuditEvent: LogAuditEventForTenant, + ClientIP: GetClientIP, + AuthUsername: getAuthUsername, + TokenOwnerUserID: apiTokenOwnerUserIDForRequest, + AuthConfigured: authConfiguredForAgentLifecycle, + ResolvePublicURL: resolveConfiguredPublicBaseURL, + }) + return handler +} + +func pulseTokenHostCandidate(candidate string) string { + return configapi.PulseTokenHostCandidate(candidate) +} + +func buildPulseMonitorTokenName(candidates ...string) string { + return configapi.BuildPulseMonitorTokenName(candidates...) +} + +func hostsShareResolvedIdentity(existingHost, candidateHost string) bool { + return configapi.HostsShareResolvedIdentity(existingHost, candidateHost) +} + +func isCanonicalAutoRegisterType(nodeType string) bool { + return configapi.IsCanonicalAutoRegisterType(nodeType) +} + +func isCanonicalAutoRegisterTokenID(nodeType, tokenID string) bool { + return configapi.IsCanonicalAutoRegisterTokenID(nodeType, tokenID) +} + +func isCanonicalAutoRegisterSource(source string) bool { + return configapi.IsCanonicalAutoRegisterSource(source) +} + +func canonicalAutoRegisterMatchMessage(reason string) string { + return configapi.CanonicalAutoRegisterMatchMessage(reason) +} + +func canonicalAutoRegisterCompletionPayloadMessage() string { + return configapi.CanonicalAutoRegisterCompletionPayloadMessage() +} + +func canonicalAutoRegisterMissingFieldsMessage(typeValue, host string, hasTokenID bool, serverName string) string { + return configapi.CanonicalAutoRegisterMissingFieldsMessage(typeValue, host, hasTokenID, serverName) +} + +func canonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName string) string { + return configapi.CanonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName) +} + +func normalizePBSUser(user string) string { return configapi.NormalizePBSUser(user) } +func normalizePMGUser(user string) string { return configapi.NormalizePMGUser(user) } + +func canonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName string) string { + return configapi.CanonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName) +} + +func canBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req *AutoRegisterRequest, now time.Time) bool { + return configapi.CanBootstrapProxmoxInstallRegistrationAt(record, req, now) +} + +func proxmoxInstallGrantEligible(record *config.APITokenRecord, req *AutoRegisterRequest) bool { + return configapi.ProxmoxInstallGrantEligible(record, req) +} + +func deriveSetupScriptServerName(serverHost string) string { + return configapi.DeriveSetupScriptServerName(serverHost) +} + +func renderSetupScript(serverType string, ctx setupScriptRenderContext) string { + return configapi.RenderSetupScript(serverType, ctx) +} diff --git a/internal/api/config_handlers_test_support_test.go b/internal/api/config_handlers_test_support_test.go new file mode 100644 index 000000000..09042f646 --- /dev/null +++ b/internal/api/config_handlers_test_support_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/api/configapi" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" +) + +func newTestConfigHandlers(t *testing.T, cfg *config.Config) *ConfigHandlers { + t.Helper() + if cfg == nil { + cfg = &config.Config{} + } + if cfg.DataPath == "" { + cfg.DataPath = t.TempDir() + } + handler := NewConfigHandlers(nil, nil, func() error { return nil }, nil, nil, func() {}, false) + handler.SetPersistence(config.NewConfigPersistence(cfg.DataPath)) + monitor, _, _ := newTestMonitor(t) + manager := alerts.NewManager() + t.Cleanup(manager.Stop) + setUnexportedField(t, monitor, "alertManager", manager) + handler.SetMonitor(monitor) + handler.SetConfig(cfg) + return handler +} + +func stubAutoRegisterNetworkDeps(t *testing.T) { + t.Helper() + restore := configapi.ConfigureAutoRegisterNetworkDependencies( + func(proxmox.ClientConfig, string, []config.ClusterEndpoint) (bool, string, []config.ClusterEndpoint) { + return false, "", nil + }, + func(string) (string, error) { return "", nil }, + ) + t.Cleanup(restore) +} + +func runAgentAutoRegister(t *testing.T, handler *ConfigHandlers, rawToken string, payload AutoRegisterRequest) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal auto-register payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/auto-register", bytes.NewReader(body)) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + handler.HandleAutoRegister(rec, req) + return rec +} + +func truncate(value string, maxLen int) string { + if maxLen <= 0 || len(value) <= maxLen { + return value + } + return value[:maxLen] +} diff --git a/internal/api/auto_register_test_helpers_test.go b/internal/api/configapi/auto_register_test_helpers_test.go similarity index 97% rename from internal/api/auto_register_test_helpers_test.go rename to internal/api/configapi/auto_register_test_helpers_test.go index a09763e4d..daa5e4cca 100644 --- a/internal/api/auto_register_test_helpers_test.go +++ b/internal/api/configapi/auto_register_test_helpers_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "testing" diff --git a/internal/api/branchcov0723pm_test.go b/internal/api/configapi/branchcov0723pm_test.go similarity index 99% rename from internal/api/branchcov0723pm_test.go rename to internal/api/configapi/branchcov0723pm_test.go index f2ffdfcf9..296c3a121 100644 --- a/internal/api/branchcov0723pm_test.go +++ b/internal/api/configapi/branchcov0723pm_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" diff --git a/internal/api/config_discovery_handlers.go b/internal/api/configapi/config_discovery_handlers.go similarity index 99% rename from internal/api/config_discovery_handlers.go rename to internal/api/configapi/config_discovery_handlers.go index 9b84d900c..f2eab74c4 100644 --- a/internal/api/config_discovery_handlers.go +++ b/internal/api/configapi/config_discovery_handlers.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" diff --git a/internal/api/config_export_import_compat_test.go b/internal/api/configapi/config_export_import_compat_test.go similarity index 99% rename from internal/api/config_export_import_compat_test.go rename to internal/api/configapi/config_export_import_compat_test.go index b34c8a498..e5ed4eff7 100644 --- a/internal/api/config_export_import_compat_test.go +++ b/internal/api/configapi/config_export_import_compat_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_export_import_handlers.go b/internal/api/configapi/config_export_import_handlers.go similarity index 84% rename from internal/api/config_export_import_handlers.go rename to internal/api/configapi/config_export_import_handlers.go index 8dd43e51b..dcbc63257 100644 --- a/internal/api/config_export_import_handlers.go +++ b/internal/api/configapi/config_export_import_handlers.go @@ -1,9 +1,11 @@ -package api +package configapi import ( "encoding/json" "net/http" + "github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext" + "github.com/rcourtman/pulse-go-rewrite/internal/api/apihttp" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rs/zerolog/log" ) @@ -28,7 +30,7 @@ func (h *ConfigHandlers) handleExportConfig(w http.ResponseWriter, r *http.Reque r.Body = http.MaxBytesReader(w, r.Body, 8*1024) // SECURITY: Validating scope for config export - if !ensureScope(w, r, config.ScopeSettingsRead) { + if !apihttp.EnsureScope(w, r, config.ScopeSettingsRead) { return } @@ -55,14 +57,16 @@ func (h *ConfigHandlers) handleExportConfig(w http.ResponseWriter, r *http.Reque // Export configuration exportedData, err := h.getPersistence(r.Context()).ExportConfig(req.Passphrase) if err != nil { - LogAuditEventForTenant(GetOrgID(r.Context()), "config_exported", getAuthUsername(h.getConfig(r.Context()), r), GetClientIP(r), r.URL.Path, false, + deps := h.runtimeDependencies() + deps.AuditEvent(apicontext.OrgID(r.Context()), "config_exported", deps.AuthUsername(h.getConfig(r.Context()), r), deps.ClientIP(r), r.URL.Path, false, "Export failed") log.Error().Err(err).Msg("Failed to export configuration") http.Error(w, "Failed to export configuration", http.StatusInternalServerError) return } - LogAuditEventForTenant(GetOrgID(r.Context()), "config_exported", getAuthUsername(h.getConfig(r.Context()), r), GetClientIP(r), r.URL.Path, true, + deps := h.runtimeDependencies() + deps.AuditEvent(apicontext.OrgID(r.Context()), "config_exported", deps.AuthUsername(h.getConfig(r.Context()), r), deps.ClientIP(r), r.URL.Path, true, "Configuration exported") log.Info().Msg("Configuration exported successfully") @@ -79,7 +83,7 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque r.Body = http.MaxBytesReader(w, r.Body, 1024*1024) // SECURITY: Validating scope for config import - if !ensureScope(w, r, config.ScopeSettingsWrite) { + if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) { return } @@ -103,11 +107,12 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque } // Capture actor identity before config swap (config replacement changes auth resolution) - importUser := getAuthUsername(h.getConfig(r.Context()), r) + deps := h.runtimeDependencies() + importUser := deps.AuthUsername(h.getConfig(r.Context()), r) // Import configuration. if err := h.getPersistence(r.Context()).ImportConfig(req.Data, req.Passphrase); err != nil { - LogAuditEventForTenant(GetOrgID(r.Context()), "config_imported", importUser, GetClientIP(r), r.URL.Path, false, + deps.AuditEvent(apicontext.OrgID(r.Context()), "config_imported", importUser, deps.ClientIP(r), r.URL.Path, false, "Import failed") log.Error().Err(err).Msg("Failed to import configuration") http.Error(w, "Failed to import configuration. Verify the backup file and passphrase are correct.", http.StatusBadRequest) @@ -117,7 +122,7 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque // Reload configuration from disk. newConfig, err := config.Load() if err != nil { - LogAuditEventForTenant(GetOrgID(r.Context()), "config_imported", importUser, GetClientIP(r), r.URL.Path, false, + deps.AuditEvent(apicontext.OrgID(r.Context()), "config_imported", importUser, deps.ClientIP(r), r.URL.Path, false, "Import succeeded but config reload failed") log.Error().Err(err).Msg("Failed to reload configuration after import") http.Error(w, "Configuration imported but failed to reload", http.StatusInternalServerError) @@ -130,7 +135,7 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque // Reload monitor with new configuration. if h.reloadFunc != nil { if err := h.reloadFunc(); err != nil { - LogAuditEventForTenant(GetOrgID(r.Context()), "config_imported", importUser, GetClientIP(r), r.URL.Path, false, + deps.AuditEvent(apicontext.OrgID(r.Context()), "config_imported", importUser, deps.ClientIP(r), r.URL.Path, false, "Import succeeded but monitor reload failed") log.Error().Err(err).Msg("Failed to reload monitor after import") http.Error(w, "Configuration imported but failed to apply changes", http.StatusInternalServerError) @@ -193,7 +198,7 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque } } - LogAuditEventForTenant(GetOrgID(r.Context()), "config_imported", importUser, GetClientIP(r), r.URL.Path, true, + deps.AuditEvent(apicontext.OrgID(r.Context()), "config_imported", importUser, deps.ClientIP(r), r.URL.Path, true, "Configuration imported") log.Info().Msg("Configuration imported successfully") diff --git a/internal/api/config_handlers.go b/internal/api/configapi/config_handlers.go similarity index 94% rename from internal/api/config_handlers.go rename to internal/api/configapi/config_handlers.go index a418d78b7..b1771b242 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/configapi/config_handlers.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" @@ -14,6 +14,7 @@ import ( "sync" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/system" @@ -108,6 +109,10 @@ func buildPulseMonitorTokenName(candidates ...string) string { return "pulse-" + pulseTokenSuffix(candidates...) } +func BuildPulseMonitorTokenName(candidates ...string) string { + return buildPulseMonitorTokenName(candidates...) +} + func pulseTokenSuffix(candidates ...string) string { for _, candidate := range candidates { host := pulseTokenHostCandidate(candidate) @@ -145,6 +150,12 @@ func pulseTokenHostCandidate(candidate string) string { return strings.Trim(raw, "[]") } +// PulseTokenHostCandidate normalizes a URL or authority into the host identity +// used for deterministic Pulse-managed token naming. +func PulseTokenHostCandidate(candidate string) string { + return pulseTokenHostCandidate(candidate) +} + func pulseTokenSlug(raw string) string { trimmed := strings.Trim(strings.ToLower(strings.TrimSpace(raw)), ".") if trimmed == "" { @@ -210,7 +221,8 @@ type ConfigHandlers struct { reloadSystemSettingsFunc func() // Function to reload cached system settings mockModeChanged func(bool) wsHub *websocket.Hub - guestMetadataHandler *GuestMetadataHandler + guestMetadataHandler GuestMetadataReloader + runtime RuntimeDependencies setupTokens map[string]*SetupTokenRecord // Map of token hash -> setup token details recentSetupTokens map[string]RecentSetupTokenRecord // Temporary map for recently used setup tokens (grace period) codeMutex sync.RWMutex // Mutex for thread-safe code access @@ -222,7 +234,7 @@ type ConfigHandlers struct { } // NewConfigHandlers creates a new ConfigHandlers instance -func NewConfigHandlers(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenantMonitor, reloadFunc func() error, wsHub *websocket.Hub, guestMetadataHandler *GuestMetadataHandler, reloadSystemSettingsFunc func(), hostedMode bool) *ConfigHandlers { +func NewConfigHandlers(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenantMonitor, reloadFunc func() error, wsHub *websocket.Hub, guestMetadataHandler GuestMetadataReloader, reloadSystemSettingsFunc func(), hostedMode bool) *ConfigHandlers { // Initialize with default-org values from multi-tenant managers when available. var defaultConfig *config.Config var defaultMonitor *monitoring.Monitor @@ -257,6 +269,7 @@ func NewConfigHandlers(mtp *config.MultiTenantPersistence, mtm *monitoring.Multi recentSetupTokens: make(map[string]RecentSetupTokenRecord), lastClusterDetection: make(map[string]time.Time), recentAutoRegistered: make(map[string]time.Time), + runtime: defaultRuntimeDependencies(), } // Clean up expired setup tokens periodically. @@ -327,7 +340,7 @@ func (h *ConfigHandlers) getContextState(ctx context.Context) (*config.Config, * orgID := "default" if ctx != nil { - if requestOrgID := GetOrgID(ctx); requestOrgID != "" { + if requestOrgID := apicontext.OrgID(ctx); requestOrgID != "" { orgID = requestOrgID } } @@ -377,6 +390,10 @@ func (h *ConfigHandlers) getContextState(ctx context.Context) (*config.Config, * return nil, nil, nil } +func (h *ConfigHandlers) ContextState(ctx context.Context) (*config.Config, *config.ConfigPersistence, *monitoring.Monitor) { + return h.getContextState(ctx) +} + func bindMonitorMetadataStores( persistence *config.ConfigPersistence, monitor *monitoring.Monitor, @@ -396,16 +413,51 @@ func (h *ConfigHandlers) getConfig(ctx context.Context) *config.Config { return c } +func (h *ConfigHandlers) Config(ctx context.Context) *config.Config { return h.getConfig(ctx) } + func (h *ConfigHandlers) getPersistence(ctx context.Context) *config.ConfigPersistence { _, p, _ := h.getContextState(ctx) return p } +func (h *ConfigHandlers) Persistence(ctx context.Context) *config.ConfigPersistence { + return h.getPersistence(ctx) +} + func (h *ConfigHandlers) getMonitor(ctx context.Context) *monitoring.Monitor { _, _, m := h.getContextState(ctx) return m } +func (h *ConfigHandlers) Monitor(ctx context.Context) *monitoring.Monitor { return h.getMonitor(ctx) } + +func (h *ConfigHandlers) SetPersistence(persistence *config.ConfigPersistence) { + h.stateMu.Lock() + h.defaultPersistence = persistence + h.stateMu.Unlock() +} + +func (h *ConfigHandlers) HostedMode() bool { + h.stateMu.RLock() + defer h.stateMu.RUnlock() + return h.hostedMode +} + +func (h *ConfigHandlers) StoreSetupToken(hash string, record *SetupTokenRecord) { + h.codeMutex.Lock() + defer h.codeMutex.Unlock() + if h.setupTokens == nil { + h.setupTokens = make(map[string]*SetupTokenRecord) + } + h.setupTokens[hash] = record +} + +func (h *ConfigHandlers) SetupTokenCount() int { + h.codeMutex.RLock() + defer h.codeMutex.RUnlock() + return len(h.setupTokens) +} + func (h *ConfigHandlers) normalizePVEConfigState(ctx context.Context) { cfg := h.getConfig(ctx) if cfg == nil { @@ -795,14 +847,20 @@ func (r *NodeConfigRequest) normalizeTokenAliases() { } } +func (r *NodeConfigRequest) NormalizeTokenAliases() { r.normalizeTokenAliases() } + func (r NodeConfigRequest) hasGuestURLField() bool { return r.guestURLSet } +func (r NodeConfigRequest) HasGuestURLField() bool { return r.hasGuestURLField() } + func (r NodeConfigRequest) hasFingerprintField() bool { return r.fingerprintSet } +func (r NodeConfigRequest) HasFingerprintField() bool { return r.hasFingerprintField() } + func isRedactedCredentialPlaceholder(value string) bool { trimmed := strings.TrimSpace(value) switch strings.ToLower(trimmed) { @@ -1217,6 +1275,10 @@ func findExistingClusterEndpoint(nodeName string, existingEndpoints []config.Clu return config.ClusterEndpoint{}, false } +func FindExistingClusterEndpoint(nodeName string, existingEndpoints []config.ClusterEndpoint) (config.ClusterEndpoint, bool) { + return findExistingClusterEndpoint(nodeName, existingEndpoints) +} + func retainUnreconciledClusterEndpoints(discovered, existing []config.ClusterEndpoint) []config.ClusterEndpoint { seen := make(map[string]struct{}, len(discovered)) for _, endpoint := range discovered { @@ -1334,9 +1396,35 @@ var ( fetchTLSFingerprint = tlsutil.FetchFingerprint ) +func ConfigureAutoRegisterNetworkDependencies( + detect func(proxmox.ClientConfig, string, []config.ClusterEndpoint) (bool, string, []config.ClusterEndpoint), + fingerprint func(string) (string, error), +) func() { + previousDetect := detectPVECluster + previousFingerprint := fetchTLSFingerprint + if detect != nil { + detectPVECluster = detect + } + if fingerprint != nil { + fetchTLSFingerprint = fingerprint + } + return func() { + detectPVECluster = previousDetect + fetchTLSFingerprint = previousFingerprint + } +} + // detectPVECluster checks if a PVE node is part of a cluster and returns cluster information // If existingEndpoints is provided, GuestURL values will be preserved for matching nodes func defaultDetectPVECluster(clientConfig proxmox.ClientConfig, nodeName string, existingEndpoints []config.ClusterEndpoint) (isCluster bool, clusterName string, clusterEndpoints []config.ClusterEndpoint) { + return detectPVEClusterWithValidator(clientConfig, nodeName, existingEndpoints, validatePVEClusterNode) +} + +func DetectPVEClusterWithValidator(clientConfig proxmox.ClientConfig, nodeName string, existingEndpoints []config.ClusterEndpoint, validator func(proxmox.ClusterStatus, proxmox.ClientConfig) (bool, string, string)) (isCluster bool, clusterName string, clusterEndpoints []config.ClusterEndpoint) { + return detectPVEClusterWithValidator(clientConfig, nodeName, existingEndpoints, validator) +} + +func detectPVEClusterWithValidator(clientConfig proxmox.ClientConfig, nodeName string, existingEndpoints []config.ClusterEndpoint, validator func(proxmox.ClusterStatus, proxmox.ClientConfig) (bool, string, string)) (isCluster bool, clusterName string, clusterEndpoints []config.ClusterEndpoint) { tempClient, err := proxmox.NewClient(clientConfig) if err != nil { log.Warn().Err(err).Msg("Failed to create client for cluster detection") @@ -1420,7 +1508,7 @@ func defaultDetectPVECluster(clientConfig proxmox.ClientConfig, nodeName string, // Reachability enriches a member endpoint but never defines cluster // membership. Powered-off nodes remain canonical members even // though their individual API cannot answer during discovery. - isValid, nodeFingerprint, failureReason := validatePVEClusterNode(clusterNode, clientConfig) + isValid, nodeFingerprint, failureReason := validator(clusterNode, clientConfig) existingEndpoint, hadExistingEndpoint := findExistingClusterEndpoint(clusterNode.Name, existingEndpoints) if !isValid { log.Info(). @@ -1829,6 +1917,10 @@ func hostsShareResolvedIdentity(existingHost, candidateHost string) bool { candidateParsed.IsLoopback() } +func HostsShareResolvedIdentity(existingHost, candidateHost string) bool { + return hostsShareResolvedIdentity(existingHost, candidateHost) +} + // disambiguateNodeName ensures a node name is unique by appending the host IP if needed. // This handles cases where multiple Proxmox hosts have the same hostname (e.g., "px1" on different networks). // Returns the original name if unique, or "name (ip)" if duplicates exist. diff --git a/internal/api/config_handlers_add_test.go b/internal/api/configapi/config_handlers_add_test.go similarity index 99% rename from internal/api/config_handlers_add_test.go rename to internal/api/configapi/config_handlers_add_test.go index 3231acbe1..c24882603 100644 --- a/internal/api/config_handlers_add_test.go +++ b/internal/api/configapi/config_handlers_add_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_admin_test.go b/internal/api/configapi/config_handlers_admin_test.go similarity index 99% rename from internal/api/config_handlers_admin_test.go rename to internal/api/configapi/config_handlers_admin_test.go index bd86baaec..eb4e285ff 100644 --- a/internal/api/config_handlers_admin_test.go +++ b/internal/api/configapi/config_handlers_admin_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_auto_reg_test.go b/internal/api/configapi/config_handlers_auto_reg_test.go similarity index 99% rename from internal/api/config_handlers_auto_reg_test.go rename to internal/api/configapi/config_handlers_auto_reg_test.go index 532b2ed08..91fa52247 100644 --- a/internal/api/config_handlers_auto_reg_test.go +++ b/internal/api/configapi/config_handlers_auto_reg_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "testing" diff --git a/internal/api/config_handlers_auto_register_test.go b/internal/api/configapi/config_handlers_auto_register_test.go similarity index 99% rename from internal/api/config_handlers_auto_register_test.go rename to internal/api/configapi/config_handlers_auto_register_test.go index ead59c0a8..ca7880ea0 100644 --- a/internal/api/config_handlers_auto_register_test.go +++ b/internal/api/configapi/config_handlers_auto_register_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_canonical_auto_register_test.go b/internal/api/configapi/config_handlers_canonical_auto_register_test.go similarity index 99% rename from internal/api/config_handlers_canonical_auto_register_test.go rename to internal/api/configapi/config_handlers_canonical_auto_register_test.go index f5b2e8b24..5bad3bc29 100644 --- a/internal/api/config_handlers_canonical_auto_register_test.go +++ b/internal/api/configapi/config_handlers_canonical_auto_register_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "encoding/json" diff --git a/internal/api/config_handlers_cluster_additional_test.go b/internal/api/configapi/config_handlers_cluster_additional_test.go similarity index 99% rename from internal/api/config_handlers_cluster_additional_test.go rename to internal/api/configapi/config_handlers_cluster_additional_test.go index 376bd4b09..94b9e8ae7 100644 --- a/internal/api/config_handlers_cluster_additional_test.go +++ b/internal/api/configapi/config_handlers_cluster_additional_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_cluster_test.go b/internal/api/configapi/config_handlers_cluster_test.go similarity index 99% rename from internal/api/config_handlers_cluster_test.go rename to internal/api/configapi/config_handlers_cluster_test.go index 61a2b1fab..01330acfe 100644 --- a/internal/api/config_handlers_cluster_test.go +++ b/internal/api/configapi/config_handlers_cluster_test.go @@ -1,4 +1,4 @@ -package api +package configapi import "testing" diff --git a/internal/api/config_handlers_connection_test.go b/internal/api/configapi/config_handlers_connection_test.go similarity index 99% rename from internal/api/config_handlers_connection_test.go rename to internal/api/configapi/config_handlers_connection_test.go index 5c7e3b549..93b7967d4 100644 --- a/internal/api/config_handlers_connection_test.go +++ b/internal/api/configapi/config_handlers_connection_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_delete_test.go b/internal/api/configapi/config_handlers_delete_test.go similarity index 99% rename from internal/api/config_handlers_delete_test.go rename to internal/api/configapi/config_handlers_delete_test.go index 73e4c8c92..a27c359fc 100644 --- a/internal/api/config_handlers_delete_test.go +++ b/internal/api/configapi/config_handlers_delete_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "encoding/json" diff --git a/internal/api/config_handlers_discovery_test.go b/internal/api/configapi/config_handlers_discovery_test.go similarity index 99% rename from internal/api/config_handlers_discovery_test.go rename to internal/api/configapi/config_handlers_discovery_test.go index 17b9fd593..cb5d06a30 100644 --- a/internal/api/config_handlers_discovery_test.go +++ b/internal/api/configapi/config_handlers_discovery_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_helpers_additional_test.go b/internal/api/configapi/config_handlers_helpers_additional_test.go similarity index 99% rename from internal/api/config_handlers_helpers_additional_test.go rename to internal/api/configapi/config_handlers_helpers_additional_test.go index 4e130ff59..06c2bd6ff 100644 --- a/internal/api/config_handlers_helpers_additional_test.go +++ b/internal/api/configapi/config_handlers_helpers_additional_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "net" diff --git a/internal/api/config_handlers_host_test.go b/internal/api/configapi/config_handlers_host_test.go similarity index 99% rename from internal/api/config_handlers_host_test.go rename to internal/api/configapi/config_handlers_host_test.go index 9d39025f4..d0fcbe89e 100644 --- a/internal/api/config_handlers_host_test.go +++ b/internal/api/configapi/config_handlers_host_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "testing" diff --git a/internal/api/config_handlers_pve_user_test.go b/internal/api/configapi/config_handlers_pve_user_test.go similarity index 99% rename from internal/api/config_handlers_pve_user_test.go rename to internal/api/configapi/config_handlers_pve_user_test.go index c2613242c..de0737f2f 100644 --- a/internal/api/config_handlers_pve_user_test.go +++ b/internal/api/configapi/config_handlers_pve_user_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" diff --git a/internal/api/config_handlers_sanitize_test.go b/internal/api/configapi/config_handlers_sanitize_test.go similarity index 99% rename from internal/api/config_handlers_sanitize_test.go rename to internal/api/configapi/config_handlers_sanitize_test.go index a287c018d..a7023929a 100644 --- a/internal/api/config_handlers_sanitize_test.go +++ b/internal/api/configapi/config_handlers_sanitize_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "errors" diff --git a/internal/api/config_handlers_setup_script_test.go b/internal/api/configapi/config_handlers_setup_script_test.go similarity index 99% rename from internal/api/config_handlers_setup_script_test.go rename to internal/api/configapi/config_handlers_setup_script_test.go index 3ba563a27..16a06dd08 100644 --- a/internal/api/config_handlers_setup_script_test.go +++ b/internal/api/configapi/config_handlers_setup_script_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "net/http" diff --git a/internal/api/config_handlers_setup_token_test.go b/internal/api/configapi/config_handlers_setup_token_test.go similarity index 97% rename from internal/api/config_handlers_setup_token_test.go rename to internal/api/configapi/config_handlers_setup_token_test.go index c324ca8c6..10e65e8bd 100644 --- a/internal/api/config_handlers_setup_token_test.go +++ b/internal/api/configapi/config_handlers_setup_token_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "testing" diff --git a/internal/api/config_handlers_setup_url_test.go b/internal/api/configapi/config_handlers_setup_url_test.go similarity index 99% rename from internal/api/config_handlers_setup_url_test.go rename to internal/api/configapi/config_handlers_setup_url_test.go index 50f831a80..285a021f2 100644 --- a/internal/api/config_handlers_setup_url_test.go +++ b/internal/api/configapi/config_handlers_setup_url_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_temperature_ssh_test.go b/internal/api/configapi/config_handlers_temperature_ssh_test.go similarity index 99% rename from internal/api/config_handlers_temperature_ssh_test.go rename to internal/api/configapi/config_handlers_temperature_ssh_test.go index 6d685a336..265d3ec67 100644 --- a/internal/api/config_handlers_temperature_ssh_test.go +++ b/internal/api/configapi/config_handlers_temperature_ssh_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_transport_guard_test.go b/internal/api/configapi/config_handlers_transport_guard_test.go similarity index 99% rename from internal/api/config_handlers_transport_guard_test.go rename to internal/api/configapi/config_handlers_transport_guard_test.go index f18a15761..1f04dc8e7 100644 --- a/internal/api/config_handlers_transport_guard_test.go +++ b/internal/api/configapi/config_handlers_transport_guard_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_handlers_update_test.go b/internal/api/configapi/config_handlers_update_test.go similarity index 99% rename from internal/api/config_handlers_update_test.go rename to internal/api/configapi/config_handlers_update_test.go index 60ba25d2e..b0050c46e 100644 --- a/internal/api/config_handlers_update_test.go +++ b/internal/api/configapi/config_handlers_update_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_node_display_name_test.go b/internal/api/configapi/config_node_display_name_test.go similarity index 99% rename from internal/api/config_node_display_name_test.go rename to internal/api/configapi/config_node_display_name_test.go index 98124b54b..acdba0f28 100644 --- a/internal/api/config_node_display_name_test.go +++ b/internal/api/configapi/config_node_display_name_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/config_node_handlers.go b/internal/api/configapi/config_node_handlers.go similarity index 98% rename from internal/api/config_node_handlers.go rename to internal/api/configapi/config_node_handlers.go index 9b33e1526..19be2748f 100644 --- a/internal/api/config_node_handlers.go +++ b/internal/api/configapi/config_node_handlers.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" @@ -558,7 +559,7 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) { } else if req.Password != "" { // Using password authentication - try to create a token via API // This enables turnkey setup for Docker/containerized PBS - pulseURL := resolveConfigAgentInstallBaseURL(r, h.getConfig(r.Context()), h.hostedMode) + pulseURL := h.resolveConfigAgentInstallBaseURL(r, h.getConfig(r.Context())) if pulseURL == "" { writeConfigAgentInstallBaseURLUnavailable(w) return @@ -760,7 +761,7 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) { } } - LogAuditEventForTenant(GetOrgID(r.Context()), "node_added", getAuthUsername(h.getConfig(r.Context()), r), GetClientIP(r), r.URL.Path, true, + h.runtimeDependencies().AuditEvent(apicontext.OrgID(r.Context()), "node_added", h.runtimeDependencies().AuthUsername(h.getConfig(r.Context()), r), h.runtimeDependencies().ClientIP(r), r.URL.Path, true, fmt.Sprintf("Added %s node %q", req.Type, req.Name)) w.WriteHeader(http.StatusCreated) @@ -1213,6 +1214,8 @@ func normalizePBSUser(user string) string { return user + "@pbs" } +func NormalizePBSUser(user string) string { return normalizePBSUser(user) } + func normalizePMGUser(user string) string { user = strings.TrimSpace(user) if user == "" || strings.Contains(user, "@") { @@ -1221,6 +1224,8 @@ func normalizePMGUser(user string) string { return user + "@pmg" } +func NormalizePMGUser(user string) string { return normalizePMGUser(user) } + // HandleUpdateNode updates an existing node // normalizeClusterEndpointIPOverride validates a user-supplied per-member // connection address and returns the canonical stored form. Empty clears the @@ -1670,7 +1675,7 @@ func (h *ConfigHandlers) handleUpdateNode(w http.ResponseWriter, r *http.Request } } - LogAuditEventForTenant(GetOrgID(r.Context()), "node_updated", getAuthUsername(h.getConfig(r.Context()), r), GetClientIP(r), r.URL.Path, true, + h.runtimeDependencies().AuditEvent(apicontext.OrgID(r.Context()), "node_updated", h.runtimeDependencies().AuthUsername(h.getConfig(r.Context()), r), h.runtimeDependencies().ClientIP(r), r.URL.Path, true, fmt.Sprintf("Updated node %s", nodeID)) w.WriteHeader(http.StatusOK) @@ -1852,7 +1857,7 @@ func (h *ConfigHandlers) handleDeleteNode(w http.ResponseWriter, r *http.Request if deletedNodeType == "pve" && deletedNodeHost != "" { } - LogAuditEventForTenant(GetOrgID(r.Context()), "node_deleted", getAuthUsername(h.getConfig(r.Context()), r), GetClientIP(r), r.URL.Path, true, + h.runtimeDependencies().AuditEvent(apicontext.OrgID(r.Context()), "node_deleted", h.runtimeDependencies().AuthUsername(h.getConfig(r.Context()), r), h.runtimeDependencies().ClientIP(r), r.URL.Path, true, fmt.Sprintf("Deleted %s node %s", nodeType, nodeID)) w.WriteHeader(http.StatusOK) diff --git a/internal/api/config_node_handlers_additional_test.go b/internal/api/configapi/config_node_handlers_additional_test.go similarity index 99% rename from internal/api/config_node_handlers_additional_test.go rename to internal/api/configapi/config_node_handlers_additional_test.go index 604c25650..763b4b8d4 100644 --- a/internal/api/config_node_handlers_additional_test.go +++ b/internal/api/configapi/config_node_handlers_additional_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" diff --git a/internal/api/config_setup_handlers.go b/internal/api/configapi/config_setup_handlers.go similarity index 96% rename from internal/api/config_setup_handlers.go rename to internal/api/configapi/config_setup_handlers.go index b6338087f..5e63b8727 100644 --- a/internal/api/config_setup_handlers.go +++ b/internal/api/configapi/config_setup_handlers.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" @@ -21,6 +21,7 @@ import ( "golang.org/x/crypto/ssh" + "github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/system" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" @@ -149,11 +150,15 @@ func proxmoxInstallRegistrationConsumedTypesValue(record *config.APITokenRecord, // only has to cover copy-paste-and-run of the installer command. const proxmoxInstallBootstrapGrantTTL = 24 * time.Hour +const ProxmoxInstallBootstrapGrantTTL = proxmoxInstallBootstrapGrantTTL + // proxmoxInstallGrantExpiredMessage is the distinct denial log for a grant // that was otherwise valid but is past proxmoxInstallBootstrapGrantTTL. The // request still takes the ordinary update-only path and its 403. const proxmoxInstallGrantExpiredMessage = "Proxmox install bootstrap grant expired; install token stays update-only" +const ProxmoxInstallGrantExpiredMessage = proxmoxInstallGrantExpiredMessage + // proxmoxInstallGrantIssuedAt resolves the grant clock for a token record. // Tokens minted after #1644 carry an explicit install_issued_at; older records // fall back to the token's own creation timestamp. A record with neither is @@ -242,6 +247,14 @@ func canBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req return proxmoxInstallGrantEligible(record, req) && !proxmoxInstallGrantExpiredAt(record, now) } +func CanBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req *AutoRegisterRequest, now time.Time) bool { + return canBootstrapProxmoxInstallRegistrationAt(record, req, now) +} + +func ProxmoxInstallGrantEligible(record *config.APITokenRecord, req *AutoRegisterRequest) bool { + return proxmoxInstallGrantEligible(record, req) +} + // completedProxmoxInstallRegistrationMatches reports that this exact // type-and-hostname registration is the one that already spent its grant, which // makes a repeat completion a no-op rather than a denial. It is deliberately @@ -583,7 +596,7 @@ func (h *ConfigHandlers) handleSetupScript(w http.ResponseWriter, r *http.Reques // The setup script is now public; authentication happens via setup token. // No need to check auth here since the script will prompt for a code - serverName := deriveSetupScriptServerName(serverHost) + serverName := DeriveSetupScriptServerName(serverHost) pulseTokenScope := pulseTokenSuffix(pulseURL) tokenName := buildPulseMonitorTokenName(pulseURL) @@ -624,7 +637,7 @@ fi` fi` } - script := renderSetupScript(serverType, setupScriptRenderContext{ + script := RenderSetupScript(serverType, SetupScriptRenderContext{ ServerName: serverName, PulseURL: pulseURL, ServerHost: serverHost, @@ -725,7 +738,7 @@ func (h *ConfigHandlers) handleSetupScriptURL(w http.ResponseWriter, r *http.Req return } req.Host = normalizedHost - pulseURL := resolveConfigAgentInstallBaseURL(r, h.getConfig(r.Context()), h.hostedMode) + pulseURL := h.resolveConfigAgentInstallBaseURL(r, h.getConfig(r.Context())) if pulseURL == "" { writeConfigAgentInstallBaseURLUnavailable(w) return @@ -743,7 +756,7 @@ func (h *ConfigHandlers) handleSetupScriptURL(w http.ResponseWriter, r *http.Req Used: false, NodeType: req.Type, Host: req.Host, - OrgID: GetOrgID(r.Context()), + OrgID: apicontext.OrgID(r.Context()), DesiredName: req.Name, } h.codeMutex.Unlock() @@ -829,6 +842,8 @@ func isCanonicalAutoRegisterType(nodeType string) bool { } } +func IsCanonicalAutoRegisterType(nodeType string) bool { return isCanonicalAutoRegisterType(nodeType) } + func isCanonicalAutoRegisterTokenID(nodeType string, tokenID string) bool { trimmedType := strings.TrimSpace(nodeType) trimmedTokenID := strings.TrimSpace(tokenID) @@ -843,6 +858,10 @@ func isCanonicalAutoRegisterTokenID(nodeType string, tokenID string) bool { return strings.HasPrefix(suffix, "pulse-") && suffix != "pulse-" } +func IsCanonicalAutoRegisterTokenID(nodeType string, tokenID string) bool { + return isCanonicalAutoRegisterTokenID(nodeType, tokenID) +} + func isCanonicalAutoRegisterSource(source string) bool { switch strings.TrimSpace(source) { case "agent", "script": @@ -852,6 +871,8 @@ func isCanonicalAutoRegisterSource(source string) bool { } } +func IsCanonicalAutoRegisterSource(source string) bool { return isCanonicalAutoRegisterSource(source) } + func normalizeAutoRegisterHostCandidates(nodeType, primary string, alternates []string) ([]string, error) { candidates := make([]string, 0, len(alternates)+1) seen := make(map[string]struct{}, len(alternates)+1) @@ -923,6 +944,10 @@ func canonicalAutoRegisterMatchMessage(reason string) string { return "Canonical auto-register matched existing node by " + reason } +func CanonicalAutoRegisterMatchMessage(reason string) string { + return canonicalAutoRegisterMatchMessage(reason) +} + // shouldPreserveExistingAutoRegisterHost reports whether an identity-matched // node keeps its stored host on re-registration. The agent orders // candidateHosts by preference, so an existing host that appears anywhere in @@ -1214,6 +1239,10 @@ func canonicalAutoRegisterCompletionPayloadMessage() string { return "Incomplete canonical auto-register token completion payload" } +func CanonicalAutoRegisterCompletionPayloadMessage() string { + return canonicalAutoRegisterCompletionPayloadMessage() +} + func canonicalAutoRegisterCheckMissingFieldsMessage(typeValue string, host string, serverName string) string { missing := make([]string, 0, 3) if strings.TrimSpace(typeValue) == "" { @@ -1231,6 +1260,10 @@ func canonicalAutoRegisterCheckMissingFieldsMessage(typeValue string, host strin return "Missing required canonical auto-register check fields: " + strings.Join(missing, ", ") } +func CanonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName string) string { + return canonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName) +} + func canonicalAutoRegisterMissingFieldsMessage(typeValue string, host string, hasTokenID bool, serverName string) string { missing := make([]string, 0, 4) if strings.TrimSpace(typeValue) == "" { @@ -1251,6 +1284,10 @@ func canonicalAutoRegisterMissingFieldsMessage(typeValue string, host string, ha return "Missing required canonical auto-register fields: " + strings.Join(missing, ", ") } +func CanonicalAutoRegisterMissingFieldsMessage(typeValue, host string, hasTokenID bool, serverName string) string { + return canonicalAutoRegisterMissingFieldsMessage(typeValue, host, hasTokenID, serverName) +} + func canonicalAutoRegisterNodeIdentity(req *AutoRegisterRequest, actualName string, host string) string { eventName := strings.TrimSpace(actualName) if eventName == "" { @@ -1291,6 +1328,10 @@ func canonicalAutoUnregisterMissingFieldsMessage(typeValue string, host string, return "Missing required canonical auto-unregister fields: " + strings.Join(missing, ", ") } +func CanonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName string) string { + return canonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName) +} + func canonicalAutoUnregisterSuccessMessage(nodeName string, host string, removed bool) string { if !removed { return fmt.Sprintf("No matching node is currently configured for %s", strings.TrimSpace(host)) @@ -1330,7 +1371,7 @@ func (h *ConfigHandlers) authenticateSetupScriptTeardownRequest(r *http.Request, return r, false } - requestOrgID := GetOrgID(r.Context()) + requestOrgID := apicontext.OrgID(r.Context()) if !h.ValidateSetupTokenForOrg(setupToken, requestOrgID) { return r, false } @@ -1352,7 +1393,7 @@ func (h *ConfigHandlers) authenticateSetupScriptTeardownRequest(r *http.Request, h.recentSetupTokens[tokenHash] = buildRecentSetupTokenRecord(record, graceExpiry) } if record.OrgID != "" { - r = r.WithContext(context.WithValue(r.Context(), OrgIDContextKey, record.OrgID)) + r = r.WithContext(context.WithValue(r.Context(), apicontext.OrgIDContextKey, record.OrgID)) } return r, true } @@ -1365,7 +1406,7 @@ func (h *ConfigHandlers) authenticateSetupScriptTeardownRequest(r *http.Request, return r, false } if recentRecord.OrgID != "" { - r = r.WithContext(context.WithValue(r.Context(), OrgIDContextKey, recentRecord.OrgID)) + r = r.WithContext(context.WithValue(r.Context(), apicontext.OrgIDContextKey, recentRecord.OrgID)) } return r, true @@ -1885,7 +1926,7 @@ func (h *ConfigHandlers) handleAutoRegister(w http.ResponseWriter, r *http.Reque // Inject OrgID from the setup token into context for subsequent processing. if setupTokenRecord.OrgID != "" { - ctx := context.WithValue(r.Context(), OrgIDContextKey, setupTokenRecord.OrgID) + ctx := context.WithValue(r.Context(), apicontext.OrgIDContextKey, setupTokenRecord.OrgID) r = r.WithContext(ctx) } // Allow a short grace period for follow-up actions without keeping tokens alive too long. @@ -1931,7 +1972,7 @@ func (h *ConfigHandlers) handleAutoRegister(w http.ResponseWriter, r *http.Reque if ok && record != nil && record.HasScope(config.ScopeAgentReport) { // Reject cross-org tokens: if the request context has an explicit // non-default org, the token must belong to that same org. - requestOrgID := GetOrgID(r.Context()) + requestOrgID := apicontext.OrgID(r.Context()) orgMismatch := requestOrgID != "" && requestOrgID != "default" && record.OrgID != "" && record.OrgID != requestOrgID if !orgMismatch { @@ -1985,15 +2026,8 @@ func (h *ConfigHandlers) handleAutoRegister(w http.ResponseWriter, r *http.Reque return } - // Log source IP for security auditing - clientIP := r.RemoteAddr - // Only trust X-Forwarded-For if request comes from a trusted proxy - peerIP := extractRemoteIP(clientIP) - if isTrustedProxyIP(peerIP) { - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - clientIP = forwarded - } - } + // Resolve the source through the root security policy supplied at wiring time. + clientIP := h.runtimeDependencies().ClientIP(r) requestEvent := log.Info() if req.CheckRegistration { requestEvent = log.Debug() @@ -2364,6 +2398,10 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h } +func (h *ConfigHandlers) HandleCanonicalAutoRegister(w http.ResponseWriter, r *http.Request, req *AutoRegisterRequest, clientIP string) { + h.handleCanonicalAutoRegister(w, r, req, clientIP) +} + // SSHKeyPair holds the sensors SSH public key for temperature monitoring. type SSHKeyPair struct { SensorsPublicKey string @@ -2381,7 +2419,7 @@ func (h *ConfigHandlers) getOrGenerateSSHKeys() SSHKeyPair { if isContainer && !devModeAllowSSH { log.Error().Msg("SECURITY BLOCK: SSH key generation disabled in containerized deployments") log.Error().Msg("Temperature monitoring via SSH is disabled in containerized deployments") - log.Error().Msg("See: " + shippedSecurityContainerNoticeDocAnchor) + log.Error().Msg("See: " + "/docs/SECURITY.md#critical-security-notice-for-container-deployments") log.Error().Msg("To test SSH keys in dev/lab only: PULSE_DEV_ALLOW_CONTAINER_SSH=true (NEVER in production!)") return SSHKeyPair{} } @@ -2409,6 +2447,8 @@ func (h *ConfigHandlers) getOrGenerateSSHKeys() SSHKeyPair { } } +func (h *ConfigHandlers) GetOrGenerateSSHKeys() SSHKeyPair { return h.getOrGenerateSSHKeys() } + // generateOrLoadSSHKey generates or loads a single SSH keypair func (h *ConfigHandlers) generateOrLoadSSHKey(sshDir, privateKeyPath, publicKeyPath, keyType string) string { // Check if public key already exists @@ -2518,18 +2558,18 @@ func (h *ConfigHandlers) handleAgentInstallCommand(w http.ResponseWriter, r *htt cfg := h.getConfig(r.Context()) persistence := h.getPersistence(r.Context()) - baseURL := resolveConfigAgentInstallBaseURL(r, cfg, h.hostedMode) + baseURL := h.resolveConfigAgentInstallBaseURL(r, cfg) if baseURL == "" { writeConfigAgentInstallBaseURLUnavailable(w) return } rawToken := "" - if authConfiguredForAgentLifecycle(cfg) { + if h.authConfiguredForAgentLifecycle(cfg) { tokenName := fmt.Sprintf("proxmox-agent-%s-%d", installType, time.Now().Unix()) rawToken, _, err = issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{ TokenName: tokenName, - OwnerUserID: apiTokenOwnerUserIDForRequest(cfg, r), + OwnerUserID: h.apiTokenOwnerUserIDForRequest(cfg, r), Metadata: map[string]string{ "install_type": installType, "issued_via": "config_agent_install_command", @@ -2588,7 +2628,7 @@ func (h *ConfigHandlers) handleHostAgentInstallToken(w http.ResponseWriter, r *h cfg := h.getConfig(r.Context()) persistence := h.getPersistence(r.Context()) - if !authConfiguredForAgentLifecycle(cfg) { + if !h.authConfiguredForAgentLifecycle(cfg) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AgentInstallCommandResponse{}) return @@ -2600,7 +2640,7 @@ func (h *ConfigHandlers) handleHostAgentInstallToken(w http.ResponseWriter, r *h } rawToken, record, err := issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{ TokenName: tokenName, - OwnerUserID: apiTokenOwnerUserIDForRequest(cfg, r), + OwnerUserID: h.apiTokenOwnerUserIDForRequest(cfg, r), Scopes: hostAgentInstallScopes(req.EnableCommands), Metadata: map[string]string{ "install_type": agentInstallTypeHost, diff --git a/internal/api/config_setup_handlers_test.go b/internal/api/configapi/config_setup_handlers_test.go similarity index 99% rename from internal/api/config_setup_handlers_test.go rename to internal/api/configapi/config_setup_handlers_test.go index 6cfb4f235..163887f1b 100644 --- a/internal/api/config_setup_handlers_test.go +++ b/internal/api/configapi/config_setup_handlers_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "testing" diff --git a/internal/api/config_system_handlers.go b/internal/api/configapi/config_system_handlers.go similarity index 95% rename from internal/api/config_system_handlers.go rename to internal/api/configapi/config_system_handlers.go index 355ef3bab..abf1ca62b 100644 --- a/internal/api/config_system_handlers.go +++ b/internal/api/configapi/config_system_handlers.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "context" @@ -17,6 +17,24 @@ import ( "github.com/rs/zerolog/log" ) +const shippedSecurityDocPath = "/docs/SECURITY.md" + +type SystemSettingsResponse struct { + config.SystemSettings + EnvOverrides map[string]bool `json:"envOverrides"` +} + +func EmptySystemSettingsResponse() SystemSettingsResponse { + return SystemSettingsResponse{}.NormalizeCollections() +} + +func (r SystemSettingsResponse) NormalizeCollections() SystemSettingsResponse { + if r.EnvOverrides == nil { + r.EnvOverrides = map[string]bool{} + } + return r +} + func (h *ConfigHandlers) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) { // Load settings from persistence to get all fields including theme persistedSettings := config.DefaultSystemSettings() diff --git a/internal/api/config_token_helpers_test.go b/internal/api/configapi/config_token_helpers_test.go similarity index 99% rename from internal/api/config_token_helpers_test.go rename to internal/api/configapi/config_token_helpers_test.go index 60a0f69f0..f1a66a905 100644 --- a/internal/api/config_token_helpers_test.go +++ b/internal/api/configapi/config_token_helpers_test.go @@ -1,4 +1,4 @@ -package api +package configapi import "testing" diff --git a/internal/api/configapi/dependencies.go b/internal/api/configapi/dependencies.go new file mode 100644 index 000000000..81bb1a95a --- /dev/null +++ b/internal/api/configapi/dependencies.go @@ -0,0 +1,221 @@ +package configapi + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" + "github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +const ( + OrgIDContextKey = apicontext.OrgIDContextKey + agentInstallIssuedViaConfig = "config_agent_install_command" + agentInstallIssuedViaHosted = "hosted_agent_install_command" + agentInstallTypeHost = "host" +) + +var ( + errAgentInstallTokenGeneration = agenttokens.ErrGeneration + errAgentInstallTokenRecord = agenttokens.ErrRecord + errAgentInstallTokenPersist = agenttokens.ErrPersist +) + +type GuestMetadataReloader interface { + Reload(context.Context) error +} + +type RuntimeDependencies struct { + AuditEvent func(orgID, event, user, ip, path string, success bool, details string) + ClientIP func(*http.Request) string + AuthUsername func(*config.Config, *http.Request) string + TokenOwnerUserID func(*config.Config, *http.Request) string + AuthConfigured func(*config.Config) bool + ResolvePublicURL func(*http.Request, *config.Config, bool) string +} + +func defaultRuntimeDependencies() RuntimeDependencies { + return RuntimeDependencies{ + AuditEvent: func(string, string, string, string, string, bool, string) {}, + ClientIP: func(r *http.Request) string { + if r == nil { + return "" + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return strings.Trim(r.RemoteAddr, "[]") + }, + AuthUsername: func(_ *config.Config, r *http.Request) string { + if r == nil { + return "" + } + return internalauth.GetUser(r.Context()) + }, + TokenOwnerUserID: func(_ *config.Config, r *http.Request) string { + if r == nil { + return "" + } + return internalauth.GetUser(r.Context()) + }, + AuthConfigured: func(cfg *config.Config) bool { + return cfg != nil && ((strings.TrimSpace(cfg.AuthUser) != "" && strings.TrimSpace(cfg.AuthPass) != "") || + cfg.HasAPITokens() || strings.TrimSpace(cfg.ProxyAuthSecret) != "") + }, + ResolvePublicURL: defaultPublicURL, + } +} + +func (h *ConfigHandlers) SetRuntimeDependencies(deps RuntimeDependencies) { + defaults := defaultRuntimeDependencies() + if deps.AuditEvent == nil { + deps.AuditEvent = defaults.AuditEvent + } + if deps.ClientIP == nil { + deps.ClientIP = defaults.ClientIP + } + if deps.AuthUsername == nil { + deps.AuthUsername = defaults.AuthUsername + } + if deps.TokenOwnerUserID == nil { + deps.TokenOwnerUserID = defaults.TokenOwnerUserID + } + if deps.AuthConfigured == nil { + deps.AuthConfigured = defaults.AuthConfigured + } + if deps.ResolvePublicURL == nil { + deps.ResolvePublicURL = defaults.ResolvePublicURL + } + h.stateMu.Lock() + h.runtime = deps + h.stateMu.Unlock() +} + +type apiTokenDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Scopes []string `json:"scopes"` + OwnerUserID string `json:"ownerUserId,omitempty"` +} + +type issueAgentInstallTokenOptions = agenttokens.IssueOptions + +func issueAndPersistAgentInstallToken(cfg *config.Config, persistence *config.ConfigPersistence, opts issueAgentInstallTokenOptions) (string, *config.APITokenRecord, error) { + return agenttokens.IssueAndPersist(cfg, persistence, opts) +} + +func hostAgentInstallScopes(enableCommands bool) []string { + return agenttokens.HostScopes(enableCommands) +} + +func (h *ConfigHandlers) authConfiguredForAgentLifecycle(cfg *config.Config) bool { + return h.runtimeDependencies().AuthConfigured(cfg) +} + +func (h *ConfigHandlers) apiTokenOwnerUserIDForRequest(cfg *config.Config, r *http.Request) string { + return h.runtimeDependencies().TokenOwnerUserID(cfg, r) +} + +func toAPITokenDTO(record config.APITokenRecord) apiTokenDTO { + return apiTokenDTO{ + ID: record.ID, + Name: record.Name, + Prefix: record.Prefix, + Suffix: record.Suffix, + CreatedAt: record.CreatedAt, + LastUsedAt: record.LastUsedAt, + ExpiresAt: record.ExpiresAt, + Scopes: append([]string{}, record.Scopes...), + OwnerUserID: agenttokens.OwnerUserID(record), + } +} + +func (h *ConfigHandlers) resolveConfigAgentInstallBaseURL(req *http.Request, cfg *config.Config) string { + return h.runtimeDependencies().ResolvePublicURL(req, cfg, h.hostedMode) +} + +func writeConfigAgentInstallBaseURLUnavailable(w http.ResponseWriter) { + http.Error(w, "A valid external Pulse URL is required", http.StatusServiceUnavailable) +} + +func defaultPublicURL(req *http.Request, cfg *config.Config, hostedMode bool) string { + if cfg == nil { + return "" + } + if agentURL := strings.TrimSpace(cfg.AgentConnectURL); agentURL != "" { + return strings.TrimRight(agentURL, "/") + } + publicURL := strings.TrimSpace(cfg.PublicURL) + if publicURL != "" && (!cfg.PublicURLAutoDetected || hostedMode) { + return strings.TrimRight(publicURL, "/") + } + if hostedMode { + return "" + } + if req != nil && strings.TrimSpace(req.Host) != "" { + scheme := "http" + if req.TLS != nil { + scheme = "https" + } + return scheme + "://" + strings.TrimSpace(req.Host) + } + if publicURL != "" { + return strings.TrimRight(publicURL, "/") + } + if cfg.FrontendPort > 0 { + return "http://localhost:" + fmt.Sprint(cfg.FrontendPort) + } + return "http://localhost:7655" +} + +func (h *ConfigHandlers) runtimeDependencies() RuntimeDependencies { + h.stateMu.RLock() + deps := h.runtime + h.stateMu.RUnlock() + return deps +} + +func safePrefixForLog(value string, n int) string { + if n <= 0 || len(value) <= n { + return value + } + return value[:n] +} + +func explicitAPITokenFromRequest(r *http.Request) (string, bool) { + if r == nil { + return "", false + } + if values := r.Header.Values("X-API-Token"); len(values) > 0 { + return strings.TrimSpace(values[0]), true + } + if header := r.Header.Get("Authorization"); strings.HasPrefix(strings.ToLower(header), "bearer ") { + return strings.TrimSpace(header[7:]), true + } + if strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") { + if values, ok := r.URL.Query()["token"]; ok && len(values) > 0 { + return strings.TrimSpace(values[0]), true + } + } + return "", false +} + +func normalizeProxmoxInstallType(raw string) (string, error) { + installType := strings.ToLower(strings.TrimSpace(raw)) + if installType != "pve" && installType != "pbs" { + return "", fmt.Errorf("Type must be 'pve' or 'pbs'") + } + return installType, nil +} diff --git a/internal/api/configapi/install_command.go b/internal/api/configapi/install_command.go new file mode 100644 index 000000000..882e8bc4b --- /dev/null +++ b/internal/api/configapi/install_command.go @@ -0,0 +1,62 @@ +package configapi + +import ( + "fmt" + "strings" +) + +type AgentInstallCommandOptions struct { + BaseURL string + Token string + InstallType string + IncludeInstallType bool + EnableCommands bool + Insecure bool +} + +type agentInstallCommandOptions = AgentInstallCommandOptions + +func BuildProxmoxAgentInstallCommand(opts AgentInstallCommandOptions) string { + baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") + installScriptURL := baseURL + "/install.sh" + curlFlags := "-fsSL" + if opts.Insecure { + curlFlags = "-kfsSL" + } + token := strings.TrimSpace(opts.Token) + tokenSetup, tokenArg, tokenCleanup := "", "", "" + if token != "" { + tokenSetup = fmt.Sprintf(`token_file=$(mktemp) && chmod 600 "$token_file" && printf %%s %s > "$token_file" && `, posixShellQuote(token)) + tokenArg = " \\\n --token-file \"$token_file\"" + tokenCleanup = `; rc=$?; rm -f "$token_file"; exit $rc` + } + command := fmt.Sprintf("%scurl %s %s | bash -s -- \\\n --url %s \\\n --enable-proxmox", tokenSetup, curlFlags, posixShellQuote(installScriptURL), posixShellQuote(baseURL)) + command += tokenArg + if opts.Insecure || strings.HasPrefix(strings.ToLower(baseURL), "http://") { + command += " \\\n --insecure" + } + if opts.IncludeInstallType { + command += fmt.Sprintf(" \\\n --proxmox-type %s", posixShellQuote(opts.InstallType)) + } + if opts.EnableCommands { + command += " \\\n --enable-commands" + } + return withPrivilegeEscalation(command) + tokenCleanup +} + +func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string { + return BuildProxmoxAgentInstallCommand(opts) +} + +func withPrivilegeEscalation(command string) string { + const installPipe = "| bash -s --" + idx := strings.Index(command, installPipe) + if idx == -1 { + return command + } + args := command[idx+len(installPipe):] + return command[:idx] + + `| { if [ "$(id -u)" -eq 0 ]; then bash -s --` + args + + `; elif command -v sudo >/dev/null 2>&1; then sudo bash -s --` + args + + `; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }` +} diff --git a/internal/api/issue1644_host_install_token_proxmox_test.go b/internal/api/configapi/issue1644_host_install_token_proxmox_test.go similarity index 89% rename from internal/api/issue1644_host_install_token_proxmox_test.go rename to internal/api/configapi/issue1644_host_install_token_proxmox_test.go index 2fb5ae8d7..b787d0383 100644 --- a/internal/api/issue1644_host_install_token_proxmox_test.go +++ b/internal/api/configapi/issue1644_host_install_token_proxmox_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" @@ -793,101 +793,6 @@ func TestIssue1644FailedGrantConsumptionLeavesNoPersistedSource(t *testing.T) { } } -// Issue #1644 follow-up: the auto-register path writes bound_hostname for host -// install tokens without bound_agent_id or a binding version, which is exactly -// the shape canBindAgentInstallExecToken refuses. The first command-channel -// enrollment of a freshly auto-registered Proxmox host must take the clean -// first-use bind rather than the legacy pre-v6.1.1 migration branch. -func TestIssue1644AutoRegisteredHostTokenBindsExecOnFirstUse(t *testing.T) { - stubAutoRegisterNetworkDeps(t) - - dataPath := t.TempDir() - cfg := &config.Config{ - DataPath: dataPath, - AuthUser: "admin", - AuthPass: "hashed-password", - } - handler := newTestConfigHandlers(t, cfg) - - installReq := httptest.NewRequest( - http.MethodPost, - "/api/agent-install-command", - strings.NewReader(`{"type":"host","name":"issue-1644-exec","enableCommands":true}`), - ) - installReq.Host = "pulse.example:7655" - installRec := httptest.NewRecorder() - handler.HandleAgentInstallCommand(installRec, installReq) - if installRec.Code != http.StatusOK { - t.Fatalf("host install token mint status = %d, body=%s", installRec.Code, installRec.Body.String()) - } - var install AgentInstallCommandResponse - if err := json.Unmarshal(installRec.Body.Bytes(), &install); err != nil { - t.Fatalf("decode host install token response: %v", err) - } - rawToken := strings.TrimSpace(install.Token) - if rawToken == "" { - t.Fatal("host install token mint omitted runtime token") - } - if !cfg.APITokens[0].HasScope(config.ScopeAgentExec) { - t.Fatalf("commands-enabled host install token is missing %s: %v", config.ScopeAgentExec, cfg.APITokens[0].Scopes) - } - - registerRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{ - Type: "pve", - Host: "https://pve-exec.local:8006", - TokenID: "pulse-monitor@pve!pulse-pve-exec", - TokenValue: "proxmox-secret", - ServerName: "pve-exec", - Source: "agent", - }) - if registerRec.Code != http.StatusOK { - t.Fatalf("host-token pve registration status = %d, body=%s", registerRec.Code, registerRec.Body.String()) - } - if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "pve-exec" { - t.Fatalf("auto-register bound hostname = %q, want %q", got, "pve-exec") - } - if got := strings.TrimSpace(cfg.APITokens[0].Metadata["bound_agent_id"]); got != "" { - t.Fatalf("auto-register wrote bound_agent_id = %q; the exec first-use path assumes it is empty", got) - } - - // The agent enrols on the command channel with its machine-id derived - // runtime ID and the same hostname, spelled as the reporting FQDN. - decision := evaluateAgentExecBinding(&cfg.APITokens[0], "agent-machine-id", "pve-exec.lan") - if !decision.admit || !decision.firstBind { - t.Fatalf("auto-registered install token exec decision = %+v, want a clean first bind", decision) - } - if decision.legacyMigrate { - t.Fatal("auto-registered install token was admitted through the legacy migration branch") - } - - router := &Router{ - config: cfg, - persistence: config.NewConfigPersistence(dataPath), - } - admission, ok := router.admitAgentExecToken(rawToken, "agent-machine-id", "pve-exec.lan") - if !ok { - t.Fatal("auto-registered host install token was rejected on the command channel") - } - if admission.AgentID != "agent-machine-id" { - t.Fatalf("admitted agent id = %q, want %q", admission.AgentID, "agent-machine-id") - } - - config.Mu.RLock() - defer config.Mu.RUnlock() - bound := cfg.APITokens[0].Metadata - if got := bound["bound_agent_id"]; got != "agent-machine-id" { - t.Fatalf("bound_agent_id = %q, want %q", got, "agent-machine-id") - } - if got := bound[agentExecBindingVersionKey]; got != agentExecBindingVersion { - t.Fatalf("%s = %q, want %q", agentExecBindingVersionKey, got, agentExecBindingVersion) - } - // The install grant compares req.ServerName against bound_hostname, so an - // equivalent FQDN spelling from the agent must not rewrite it. - if got := bound["bound_hostname"]; got != "pve-exec" { - t.Fatalf("bound_hostname after exec bind = %q, want the auto-registered %q", got, "pve-exec") - } -} - func TestIssue1644HostInstallTokenRejectsNonCanonicalType(t *testing.T) { rawToken := "issue-1644-host-bad-type.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, map[string]string{ diff --git a/internal/api/issue1664_cluster_fingerprint_test.go b/internal/api/configapi/issue1664_cluster_fingerprint_test.go similarity index 99% rename from internal/api/issue1664_cluster_fingerprint_test.go rename to internal/api/configapi/issue1664_cluster_fingerprint_test.go index e9d6c4753..bb96dca1f 100644 --- a/internal/api/issue1664_cluster_fingerprint_test.go +++ b/internal/api/configapi/issue1664_cluster_fingerprint_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "errors" diff --git a/internal/api/proxmox_install_registration_test.go b/internal/api/configapi/proxmox_install_registration_test.go similarity index 99% rename from internal/api/proxmox_install_registration_test.go rename to internal/api/configapi/proxmox_install_registration_test.go index 8197371ee..91a8d3a41 100644 --- a/internal/api/proxmox_install_registration_test.go +++ b/internal/api/configapi/proxmox_install_registration_test.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "bytes" diff --git a/internal/api/configapi/setup_script_artifact.go b/internal/api/configapi/setup_script_artifact.go new file mode 100644 index 000000000..47b8c4d87 --- /dev/null +++ b/internal/api/configapi/setup_script_artifact.go @@ -0,0 +1,109 @@ +package configapi + +import ( + "fmt" + "net/url" + "strings" +) + +type SetupScriptInstallArtifact struct { + Type string `json:"type"` + Host string `json:"host"` + URL string `json:"url"` + DownloadURL string `json:"downloadURL"` + ScriptFileName string `json:"scriptFileName"` + Command string `json:"command"` + CommandWithEnv string `json:"commandWithEnv"` + CommandWithoutEnv string `json:"commandWithoutEnv"` + Expires int64 `json:"expires"` + SetupToken string `json:"setupToken"` + TokenHint string `json:"tokenHint"` +} + +type setupScriptInstallArtifact = SetupScriptInstallArtifact + +func BuildSetupScriptCommand(scriptURL string, token string) string { + curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | " + bashCommand := "bash" + sudoCommand := "sudo bash" + if trimmedToken := strings.TrimSpace(token); trimmedToken != "" { + envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " " + bashCommand = envPrefix + bashCommand + sudoCommand = "sudo env " + envPrefix + "bash" + } + + return curlCommand + + `{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand + + `; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand + + `; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }` +} + +func BuildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string { + query := url.Values{} + query.Set("type", strings.TrimSpace(installType)) + if trimmedHost := strings.TrimSpace(host); trimmedHost != "" { + query.Set("host", trimmedHost) + } + if trimmedPulseURL := strings.TrimSpace(pulseURL); trimmedPulseURL != "" { + query.Set("pulse_url", trimmedPulseURL) + } + if backupPerms && strings.TrimSpace(installType) == "pve" { + query.Set("backup_perms", "true") + } + return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/api/setup-script?" + query.Encode() +} + +func BuildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string { + downloadURL := BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms) + trimmedToken := strings.TrimSpace(setupToken) + if trimmedToken == "" { + return downloadURL + } + parsed, err := url.Parse(downloadURL) + if err != nil { + return downloadURL + } + query := parsed.Query() + query.Set("setup_token", trimmedToken) + parsed.RawQuery = query.Encode() + return parsed.String() +} + +func BuildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) SetupScriptInstallArtifact { + scriptURL := BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms) + commandWithEnv := BuildSetupScriptCommand(scriptURL, setupToken) + return SetupScriptInstallArtifact{ + Type: strings.TrimSpace(installType), + Host: strings.TrimSpace(host), + URL: scriptURL, + DownloadURL: BuildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken), + ScriptFileName: fmt.Sprintf("pulse-setup-%s.sh", strings.TrimSpace(installType)), + Command: commandWithEnv, + CommandWithEnv: commandWithEnv, + CommandWithoutEnv: BuildSetupScriptCommand(scriptURL, ""), + Expires: expiresAt, + SetupToken: strings.TrimSpace(setupToken), + TokenHint: setupScriptTokenHint(setupToken), + } +} + +func buildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) setupScriptInstallArtifact { + return BuildSetupScriptInstallArtifact(baseURL, installType, host, pulseURL, backupPerms, setupToken, expiresAt) +} + +func buildSetupScriptFileName(installType string) string { + return fmt.Sprintf("pulse-setup-%s.sh", strings.TrimSpace(installType)) +} + +func setupScriptTokenHint(token string) string { + trimmed := strings.TrimSpace(token) + if len(trimmed) <= 6 { + return trimmed + } + return fmt.Sprintf("%s…%s", trimmed[:3], trimmed[len(trimmed)-3:]) +} + +func posixShellQuote(value string) string { + escaped := strings.ReplaceAll(value, "'", `'"'"'`) + return "'" + escaped + "'" +} diff --git a/internal/api/setup_script_render.go b/internal/api/configapi/setup_script_render.go similarity index 99% rename from internal/api/setup_script_render.go rename to internal/api/configapi/setup_script_render.go index 7f6e0acd6..d604f165d 100644 --- a/internal/api/setup_script_render.go +++ b/internal/api/configapi/setup_script_render.go @@ -1,4 +1,4 @@ -package api +package configapi import ( "fmt" @@ -6,7 +6,7 @@ import ( "time" ) -type setupScriptRenderContext struct { +type SetupScriptRenderContext struct { ServerName string PulseURL string ServerHost string @@ -16,10 +16,10 @@ type setupScriptRenderContext struct { StoragePerms string StorageRepairPerms string SensorsPublicKey string - Artifact setupScriptInstallArtifact + Artifact SetupScriptInstallArtifact } -func deriveSetupScriptServerName(serverHost string) string { +func DeriveSetupScriptServerName(serverHost string) string { trimmedHost := strings.TrimSpace(serverHost) if trimmedHost == "" { return "your-server" @@ -33,14 +33,14 @@ func deriveSetupScriptServerName(serverHost string) string { return strings.Split(trimmedHost, ":")[0] } -func renderSetupScript(serverType string, ctx setupScriptRenderContext) string { +func RenderSetupScript(serverType string, ctx SetupScriptRenderContext) string { if strings.TrimSpace(serverType) == "pve" { return renderPVESetupScript(ctx) } return renderPBSSetupScript(ctx) } -func renderPVESetupScript(ctx setupScriptRenderContext) string { +func renderPVESetupScript(ctx SetupScriptRenderContext) string { return fmt.Sprintf(`#!/bin/bash # Pulse Monitoring Setup Script for %s # Generated: %s @@ -1526,7 +1526,7 @@ fi ctx.SensorsPublicKey) } -func renderPBSSetupScript(ctx setupScriptRenderContext) string { +func renderPBSSetupScript(ctx SetupScriptRenderContext) string { return fmt.Sprintf(`#!/bin/bash # Pulse Monitoring Setup Script for PBS %s # Generated: %s diff --git a/internal/api/configapi/test_support_test.go b/internal/api/configapi/test_support_test.go new file mode 100644 index 000000000..d0df8bf0a --- /dev/null +++ b/internal/api/configapi/test_support_test.go @@ -0,0 +1,102 @@ +package configapi + +import ( + "crypto/tls" + "net" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + "unsafe" + + "github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/testutil" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +const ( + agentExecBindingVersionKey = agentbinding.VersionKey + agentExecBindingVersion = agentbinding.Version +) + +type agentExecBindingDecision struct { + admit bool + firstBind bool + legacyMigrate bool +} + +func evaluateAgentExecBinding(record *config.APITokenRecord, agentID, hostname string) agentExecBindingDecision { + decision := agentbinding.Evaluate(record, agentID, hostname) + return agentExecBindingDecision{admit: decision.Admit, firstBind: decision.FirstBind, legacyMigrate: decision.LegacyMigrate} +} + +func canBindAgentInstallExecToken(record *config.APITokenRecord, agentID, hostname string) bool { + return agentbinding.CanBindInstallToken(record, agentID, hostname) +} + +func newIPv4TLSServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Skipf("cannot listen on tcp4 loopback (tests require local sockets): %v", err) + } + server := &httptest.Server{ + Listener: listener, + Config: &http.Server{Handler: handler}, + TLS: &tls.Config{}, + } + server.StartTLS() + return server +} + +func setUnexportedField(t *testing.T, target any, fieldName string, value any) { + t.Helper() + field := reflect.ValueOf(target).Elem().FieldByName(fieldName) + if !field.IsValid() { + t.Fatalf("field %q not found", fieldName) + } + reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value)) +} + +func newTestMonitor(t *testing.T) (*monitoring.Monitor, *models.State, *monitoring.MetricsHistory) { + t.Helper() + monitor := &monitoring.Monitor{} + state := models.NewState() + history := monitoring.NewMetricsHistory(10, time.Hour) + setUnexportedField(t, monitor, "state", state) + setUnexportedField(t, monitor, "metricsHistory", history) + return monitor, state, history +} + +func syncTestResourceStore(t *testing.T, monitor *monitoring.Monitor, state *models.State) { + t.Helper() + adapter := unifiedresources.NewMonitorAdapter(nil) + adapter.PopulateFromSnapshot(state.GetSnapshot()) + setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter)) +} + +func newTokenRecord(t *testing.T, raw string, scopes []string, metadata map[string]string) config.APITokenRecord { + t.Helper() + record, err := config.NewAPITokenRecord(raw, "test-token", scopes) + if err != nil { + t.Fatalf("NewAPITokenRecord: %v", err) + } + if metadata != nil { + record.Metadata = metadata + } + return *record +} + +func setMaxMonitoredSystemsLicenseForTests(t *testing.T, _ int) { + t.Helper() + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") +} + +func setMockModeForTest(t *testing.T, enabled bool) { + t.Helper() + testutil.SetMockMode(t, enabled) +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 55d4a601e..ee17aa458 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -37,6 +37,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/api/configapi" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/license/entitlements" "github.com/rcourtman/pulse-go-rewrite/internal/mock" @@ -213,9 +214,7 @@ func TestContract_DefaultDetectPVEClusterRetainsUnreachableMembers(t *testing.T) })) defer server.Close() - originalValidate := validatePVEClusterNode - t.Cleanup(func() { validatePVEClusterNode = originalValidate }) - validatePVEClusterNode = func(node proxmox.ClusterStatus, _ proxmox.ClientConfig) (bool, string, string) { + validator := func(node proxmox.ClusterStatus, _ proxmox.ClientConfig) (bool, string, string) { if node.Name == "pve-a" { return true, "fingerprint-a", "" } @@ -223,7 +222,7 @@ func TestContract_DefaultDetectPVEClusterRetainsUnreachableMembers(t *testing.T) } lastSeen := time.Now().Add(-time.Hour).UTC() - isCluster, clusterName, endpoints := defaultDetectPVECluster( + isCluster, clusterName, endpoints := configapi.DetectPVEClusterWithValidator( proxmox.ClientConfig{ Host: server.URL, TokenName: "root@pam!pulse", @@ -240,7 +239,7 @@ func TestContract_DefaultDetectPVEClusterRetainsUnreachableMembers(t *testing.T) NodeName: "pve-c", Host: "https://pve-c:8006", LastSeen: lastSeen.Add(-time.Hour), - }}, + }}, validator, ) if !isCluster || clusterName != "production" { t.Fatalf("cluster detection = (%v, %q), want (true, production)", isCluster, clusterName) @@ -264,7 +263,7 @@ func TestContract_DefaultDetectPVEClusterRetainsUnreachableMembers(t *testing.T) if offline.Fingerprint != "fingerprint-b" || offline.GuestURL != "https://guest.example/pve-b" || !offline.LastSeen.Equal(lastSeen) { t.Fatalf("powered-off member lost last-known endpoint evidence: %+v", offline) } - if pending, ok := findExistingClusterEndpoint("pve-c", endpoints); !ok || + if pending, ok := configapi.FindExistingClusterEndpoint("pve-c", endpoints); !ok || pending.Host != "https://pve-c:8006" || !pending.LastSeen.Equal(lastSeen.Add(-time.Hour)) { t.Fatalf("partially omitted endpoint was deleted before monitor reconciliation: %+v", endpoints) @@ -1432,11 +1431,11 @@ func TestContract_NodeConfigUpdateTracksOptionalConnectionFieldsAndRedactedSecre if err := json.Unmarshal([]byte(`{"name":"cluster","tokenName":"pulse-monitor@pve!pulse-pve","tokenValue":"********"}`), &preserveReq); err != nil { t.Fatalf("decode preserve request: %v", err) } - preserveReq.normalizeTokenAliases() - if preserveReq.hasGuestURLField() { + preserveReq.NormalizeTokenAliases() + if preserveReq.HasGuestURLField() { t.Fatal("omitted guestURL must preserve the stored connection URL") } - if preserveReq.hasFingerprintField() { + if preserveReq.HasFingerprintField() { t.Fatal("omitted fingerprint must preserve the stored TLS fingerprint") } if preserveReq.TokenValue != "" { @@ -1447,10 +1446,10 @@ func TestContract_NodeConfigUpdateTracksOptionalConnectionFieldsAndRedactedSecre if err := json.Unmarshal([]byte(`{"guestURL":"","fingerprint":""}`), &clearReq); err != nil { t.Fatalf("decode clear request: %v", err) } - if !clearReq.hasGuestURLField() { + if !clearReq.HasGuestURLField() { t.Fatal("explicit empty guestURL must remain observable so clients can clear it intentionally") } - if !clearReq.hasFingerprintField() { + if !clearReq.HasFingerprintField() { t.Fatal("explicit empty fingerprint must remain observable so clients can clear it intentionally") } } @@ -6541,9 +6540,7 @@ func TestContract_SetupTokenRoutesRejectQueryAuthToken(t *testing.T) { token := "fedcba9876543210fedcba9876543210" tokenHash := authpkg.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} - router.configHandlers.codeMutex.Unlock() + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}) t.Run("verify-temperature-ssh requires header token transport", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?auth_token="+token, strings.NewReader(`{"nodes":""}`)) @@ -10567,12 +10564,10 @@ func TestContract_CanonicalAutoRegisterSetupTokenAuthFailureText(t *testing.T) { const validSetupToken = "setup-token-123" tokenHash := authpkg.HashAPIToken(validSetupToken) - handler.codeMutex.Lock() - handler.setupTokens[tokenHash] = &SetupTokenRecord{ + handler.StoreSetupToken(tokenHash, &SetupTokenRecord{ ExpiresAt: time.Now().Add(5 * time.Minute), NodeType: "pve", - } - handler.codeMutex.Unlock() + }) requestBody.AuthToken = validSetupToken requestBody.TokenValue = "" @@ -11631,7 +11626,7 @@ func TestContract_CanonicalAutoRegisterDirectValidationContract(t *testing.T) { missingServerReq := httptest.NewRequest(http.MethodPost, "/api/auto-register", bytes.NewReader(missingServerJSON)) missingServerRec := httptest.NewRecorder() - handler.handleCanonicalAutoRegister(missingServerRec, missingServerReq, &reqBody, "127.0.0.1") + handler.HandleCanonicalAutoRegister(missingServerRec, missingServerReq, &reqBody, "127.0.0.1") if missingServerRec.Code != http.StatusBadRequest { t.Fatalf("missing-serverName status = %d, want 400", missingServerRec.Code) } @@ -11643,7 +11638,7 @@ func TestContract_CanonicalAutoRegisterDirectValidationContract(t *testing.T) { reqBody.TokenValue = "" mismatchedReq := httptest.NewRequest(http.MethodPost, "/api/auto-register", nil) mismatchedRec := httptest.NewRecorder() - handler.handleCanonicalAutoRegister(mismatchedRec, mismatchedReq, &reqBody, "127.0.0.1") + handler.HandleCanonicalAutoRegister(mismatchedRec, mismatchedReq, &reqBody, "127.0.0.1") if mismatchedRec.Code != http.StatusBadRequest { t.Fatalf("mismatched-completion status = %d, want 400", mismatchedRec.Code) } @@ -22998,10 +22993,7 @@ func TestContract_RequestOriginCannotRetargetTokenBearingCommands(t *testing.T) PublicURLAutoDetected: tc.publicAuto, AgentConnectURL: tc.agentConnectURL, } - handler := &ConfigHandlers{ - defaultConfig: cfg, - defaultPersistence: config.NewConfigPersistence(cfg.DataPath), - } + handler := newTestConfigHandlers(t, cfg) req := httptest.NewRequest( http.MethodPost, @@ -23662,7 +23654,7 @@ func TestContract_HostedInstallerOriginsFailClosedAtRouter(t *testing.T) { cfg.PublicURLAutoDetected = autoDetected cfg.AgentConnectURL = agentConnectURL router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") - if !router.hostedMode || router.configHandlers == nil || !router.configHandlers.hostedMode { + if !router.hostedMode || router.configHandlers == nil || !router.configHandlers.HostedMode() { t.Fatal("Router hosted mode was not propagated into ConfigHandlers") } return router, cfg @@ -23763,9 +23755,7 @@ func TestContract_HostedInstallerOriginsFailClosedAtRouter(t *testing.T) { if rec.Code != http.StatusServiceUnavailable { t.Fatalf("%s status = %d, want %d: %s", installType, rec.Code, http.StatusServiceUnavailable, rec.Body.String()) } - router.configHandlers.codeMutex.RLock() - setupTokenCount := len(router.configHandlers.setupTokens) - router.configHandlers.codeMutex.RUnlock() + setupTokenCount := router.configHandlers.SetupTokenCount() if setupTokenCount != 0 { t.Fatalf("%s setup token count = %d, want 0", installType, setupTokenCount) } diff --git a/internal/api/host_agent_install_token_test.go b/internal/api/host_agent_install_token_test.go index 2d3f298ce..e4c0f6ba7 100644 --- a/internal/api/host_agent_install_token_test.go +++ b/internal/api/host_agent_install_token_test.go @@ -134,7 +134,7 @@ func TestRouterSetupHandoffUsesCanonicalRuntimeConfig(t *testing.T) { if resp.Token == "" || resp.Record == nil { t.Fatalf("expected setup handoff to create a scoped install token") } - if got := router.configHandlers.getConfig(req.Context()); got != runtimeConfig { + if got := router.configHandlers.Config(req.Context()); got != runtimeConfig { t.Fatalf("config handler uses config %#v, want Router config %#v", got, runtimeConfig) } } diff --git a/internal/api/issue1644_exec_binding_integration_test.go b/internal/api/issue1644_exec_binding_integration_test.go new file mode 100644 index 000000000..5564ce476 --- /dev/null +++ b/internal/api/issue1644_exec_binding_integration_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// Issue #1644 follow-up: the auto-register path writes bound_hostname for host +// install tokens without bound_agent_id or a binding version. The first +// command-channel enrollment must take the clean first-use bind. +func TestIssue1644AutoRegisteredHostTokenBindsExecOnFirstUse(t *testing.T) { + stubAutoRegisterNetworkDeps(t) + + dataPath := t.TempDir() + cfg := &config.Config{DataPath: dataPath, AuthUser: "admin", AuthPass: "hashed-password"} + handler := newTestConfigHandlers(t, cfg) + + installReq := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", strings.NewReader(`{"type":"host","name":"issue-1644-exec","enableCommands":true}`)) + installReq.Host = "pulse.example:7655" + installRec := httptest.NewRecorder() + handler.HandleAgentInstallCommand(installRec, installReq) + if installRec.Code != http.StatusOK { + t.Fatalf("host install token mint status = %d, body=%s", installRec.Code, installRec.Body.String()) + } + var install AgentInstallCommandResponse + if err := json.Unmarshal(installRec.Body.Bytes(), &install); err != nil { + t.Fatalf("decode host install token response: %v", err) + } + rawToken := strings.TrimSpace(install.Token) + if rawToken == "" { + t.Fatal("host install token mint omitted runtime token") + } + if !cfg.APITokens[0].HasScope(config.ScopeAgentExec) { + t.Fatalf("commands-enabled host install token is missing %s: %v", config.ScopeAgentExec, cfg.APITokens[0].Scopes) + } + + registerRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{ + Type: "pve", Host: "https://pve-exec.local:8006", TokenID: "pulse-monitor@pve!pulse-pve-exec", + TokenValue: "proxmox-secret", ServerName: "pve-exec", Source: "agent", + }) + if registerRec.Code != http.StatusOK { + t.Fatalf("host-token pve registration status = %d, body=%s", registerRec.Code, registerRec.Body.String()) + } + if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "pve-exec" { + t.Fatalf("auto-register bound hostname = %q, want %q", got, "pve-exec") + } + if got := strings.TrimSpace(cfg.APITokens[0].Metadata["bound_agent_id"]); got != "" { + t.Fatalf("auto-register wrote bound_agent_id = %q; the exec first-use path assumes it is empty", got) + } + + decision := evaluateAgentExecBinding(&cfg.APITokens[0], "agent-machine-id", "pve-exec.lan") + if !decision.admit || !decision.firstBind { + t.Fatalf("auto-registered install token exec decision = %+v, want a clean first bind", decision) + } + if decision.legacyMigrate { + t.Fatal("auto-registered install token was admitted through the legacy migration branch") + } + + router := &Router{config: cfg, persistence: config.NewConfigPersistence(dataPath)} + admission, ok := router.admitAgentExecToken(rawToken, "agent-machine-id", "pve-exec.lan") + if !ok { + t.Fatal("auto-registered host install token was rejected on the command channel") + } + if admission.AgentID != "agent-machine-id" { + t.Fatalf("admitted agent id = %q, want %q", admission.AgentID, "agent-machine-id") + } + + config.Mu.RLock() + defer config.Mu.RUnlock() + bound := cfg.APITokens[0].Metadata + if got := bound["bound_agent_id"]; got != "agent-machine-id" { + t.Fatalf("bound_agent_id = %q, want %q", got, "agent-machine-id") + } + if got := bound[agentExecBindingVersionKey]; got != agentExecBindingVersion { + t.Fatalf("%s = %q, want %q", agentExecBindingVersionKey, got, agentExecBindingVersion) + } + if got := bound["bound_hostname"]; got != "pve-exec" { + t.Fatalf("bound_hostname after exec bind = %q, want the auto-registered %q", got, "pve-exec") + } +} diff --git a/internal/api/multi_tenant_setters_additional_test.go b/internal/api/multi_tenant_setters_additional_test.go index e932f0435..872e90503 100644 --- a/internal/api/multi_tenant_setters_additional_test.go +++ b/internal/api/multi_tenant_setters_additional_test.go @@ -13,8 +13,8 @@ import ( func TestConfigHandlersSetMultiTenantMonitor(t *testing.T) { handler := &ConfigHandlers{} handler.SetMultiTenantMonitor(nil) - if handler.mtMonitor != nil { - t.Fatalf("mtMonitor should be nil after SetMultiTenantMonitor(nil)") + if handler.Monitor(context.Background()) != nil { + t.Fatalf("monitor should be nil after SetMultiTenantMonitor(nil)") } } @@ -63,16 +63,16 @@ func TestRouterSetMultiTenantMonitorRefreshesConfigHandlerMonitorSource(t *testi } router.configHandlers.SetMultiTenantMonitor(oldMTM) - if got := router.configHandlers.getMonitor(context.Background()); got != oldMonitor { + if got := router.configHandlers.Monitor(context.Background()); got != oldMonitor { t.Fatalf("precondition monitor = %#v, want old monitor %#v", got, oldMonitor) } router.SetMultiTenantMonitor(newMTM) - if got := router.configHandlers.getMonitor(context.Background()); got != newMonitor { + if got := router.configHandlers.Monitor(context.Background()); got != newMonitor { t.Fatalf("config handler monitor = %#v, want reloaded monitor %#v", got, newMonitor) } - if got := router.configHandlers.getConfig(context.Background()); got != newConfig { + if got := router.configHandlers.Config(context.Background()); got != newConfig { t.Fatalf("config handler config = %#v, want reloaded config %#v", got, newConfig) } } @@ -84,15 +84,14 @@ func TestConfigHandlersNonDefaultMissingTenantMonitorFailsClosed(t *testing.T) { mtm := monitoring.NewMultiTenantMonitor(defaultConfig, mtp, nil) defer mtm.Stop() - handler := &ConfigHandlers{ - defaultConfig: defaultConfig, - defaultPersistence: config.NewConfigPersistence(defaultConfig.ConfigPath), - defaultMonitor: defaultMonitor, - } + handler := NewConfigHandlers(nil, nil, nil, nil, nil, nil, false) + handler.SetConfig(defaultConfig) + handler.SetPersistence(config.NewConfigPersistence(defaultConfig.ConfigPath)) + handler.SetMonitor(defaultMonitor) handler.SetMultiTenantMonitor(mtm) ctx := context.WithValue(context.Background(), OrgIDContextKey, "tenant-missing") - cfg, persistence, monitor := handler.getContextState(ctx) + cfg, persistence, monitor := handler.ContextState(ctx) if cfg != nil || persistence != nil || monitor != nil { t.Fatalf("expected missing non-default tenant state to fail closed, got cfg=%#v persistence=%#v monitor=%#v", cfg, persistence, monitor) } @@ -113,14 +112,12 @@ func TestConfigHandlersDefaultContextUsesPrimaryRuntimeState(t *testing.T) { setUnexportedField(t, primaryMonitor, "config", primaryConfig) primaryPersistence := config.NewConfigPersistence(primaryConfig.ConfigPath) - handler := &ConfigHandlers{ - defaultConfig: primaryConfig, - defaultPersistence: primaryPersistence, - defaultMonitor: primaryMonitor, - mtMonitor: mtm, - } + handler := NewConfigHandlers(nil, mtm, nil, nil, nil, nil, false) + handler.SetConfig(primaryConfig) + handler.SetPersistence(primaryPersistence) + handler.SetMonitor(primaryMonitor) - cfg, persistence, monitor := handler.getContextState(context.Background()) + cfg, persistence, monitor := handler.ContextState(context.Background()) if cfg != primaryConfig { t.Fatalf("default config = %#v, want primary config %#v", cfg, primaryConfig) } diff --git a/internal/api/router.go b/internal/api/router.go index f178d0d7c..c7f09eb77 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -442,19 +442,19 @@ func (r *Router) setupRoutes() { r.bindDefaultMetadataStores(r.monitor) } guestMetadataHandler.SetStoreResolver(func(ctx context.Context) *config.GuestMetadataStore { - if monitor := r.configHandlers.getMonitor(ctx); monitor != nil { + if monitor := r.configHandlers.Monitor(ctx); monitor != nil { return monitor.GuestMetadataStore() } return nil }) dockerMetadataHandler.SetStoreResolver(func(ctx context.Context) *config.DockerMetadataStore { - if monitor := r.configHandlers.getMonitor(ctx); monitor != nil { + if monitor := r.configHandlers.Monitor(ctx); monitor != nil { return monitor.DockerMetadataStore() } return nil }) hostMetadataHandler.SetStoreResolver(func(ctx context.Context) *config.HostMetadataStore { - if monitor := r.configHandlers.getMonitor(ctx); monitor != nil { + if monitor := r.configHandlers.Monitor(ctx); monitor != nil { return monitor.HostMetadataStore() } return nil @@ -462,20 +462,20 @@ func (r *Router) setupRoutes() { r.configHandlers.SetConfig(r.config) r.configHandlers.SetMockModeChangeHook(r.syncPlatformSupplementalProviders) r.trueNASHandlers = &TrueNASHandlers{ - getPersistence: r.configHandlers.getPersistence, - getConfig: r.configHandlers.getConfig, - getMonitor: r.configHandlers.getMonitor, + getPersistence: r.configHandlers.Persistence, + getConfig: r.configHandlers.Config, + getMonitor: r.configHandlers.Monitor, getPoller: func(context.Context) *monitoring.TrueNASPoller { return r.trueNASPoller }, } r.vmwareHandlers = &VMwareHandlers{ - getPersistence: r.configHandlers.getPersistence, - getMonitor: r.configHandlers.getMonitor, + getPersistence: r.configHandlers.Persistence, + getMonitor: r.configHandlers.Monitor, getPoller: func(context.Context) *monitoring.VMwarePoller { return r.vmwarePoller }, } r.connectionsHandlers = NewConnectionsHandlers( - r.configHandlers.getConfig, - r.configHandlers.getPersistence, - r.configHandlers.getMonitor, + r.configHandlers.Config, + r.configHandlers.Persistence, + r.configHandlers.Monitor, ) r.connectionsHandlers.SetPlatformPollers( func(context.Context) *monitoring.TrueNASPoller { return r.trueNASPoller }, @@ -486,8 +486,8 @@ func (r *Router) setupRoutes() { // HTTP handler uses, so the active-notification stream stays in // lockstep with the Settings → Infrastructure badges. Single-tenant // only for now; multi-tenant per-org wiring is a follow-up. - getCfg := r.configHandlers.getConfig - getPersist := r.configHandlers.getPersistence + getCfg := r.configHandlers.Config + getPersist := r.configHandlers.Persistence monitor := r.monitor trueNASPoller := r.trueNASPoller vmwarePoller := r.vmwarePoller @@ -501,8 +501,8 @@ func (r *Router) setupRoutes() { }) } r.availabilityHandlers = NewAvailabilityHandlers( - r.configHandlers.getPersistence, - r.configHandlers.getMonitor, + r.configHandlers.Persistence, + r.configHandlers.Monitor, // Resolved lazily: license handlers are constructed after this point. availabilityFeatureResolverFunc(func(ctx context.Context) licenseFeatureChecker { if r.licenseHandlers == nil { @@ -519,7 +519,7 @@ func (r *Router) setupRoutes() { ) recoveryManager := recoverymanager.New(r.multiTenant) r.recoveryHandlers = NewRecoveryHandlers(recoveryManager) - r.attentionHandlers = NewAttentionHandlers(r.configHandlers.getMonitor, recoveryManager) + r.attentionHandlers = NewAttentionHandlers(r.configHandlers.Monitor, recoveryManager) if r.mtMonitor != nil { r.mtMonitor.SetRecoveryManager(recoveryManager) } diff --git a/internal/api/router_decomposition_contract_test.go b/internal/api/router_decomposition_contract_test.go index defc0a349..30387bdc7 100644 --- a/internal/api/router_decomposition_contract_test.go +++ b/internal/api/router_decomposition_contract_test.go @@ -65,11 +65,11 @@ func TestRouterDecompositionRouteRegistrationDistribution(t *testing.T) { } func TestConfigHandlersDecompositionDelegationBoundaries(t *testing.T) { - fset, fileAST := parseAPISourceFile(t, "config_handlers.go") + fset, fileAST := parseAPISourceFile(t, filepath.Join("configapi", "config_handlers.go")) methods := exportedHandleMethods(fileAST, "ConfigHandlers") if len(methods) == 0 { - t.Fatal("no exported ConfigHandlers Handle* methods found in config_handlers.go") + t.Fatal("no exported ConfigHandlers Handle* methods found in configapi/config_handlers.go") } const maxMethodLines = 22 @@ -79,7 +79,7 @@ func TestConfigHandlersDecompositionDelegationBoundaries(t *testing.T) { lines := nodeLineSpan(fset, method) if lines > maxMethodLines { t.Errorf( - "%s is too large (%d lines > %d). exported Handle* methods in config_handlers.go should remain delegation-focused", + "%s is too large (%d lines > %d). exported Handle* methods in configapi/config_handlers.go should remain delegation-focused", method.Name.Name, lines, maxMethodLines, diff --git a/internal/api/router_helpers_more_test.go b/internal/api/router_helpers_more_test.go index f3fe4ef8d..ed3c3a7a8 100644 --- a/internal/api/router_helpers_more_test.go +++ b/internal/api/router_helpers_more_test.go @@ -190,8 +190,8 @@ func TestSetMultiTenantMonitor_WiresHandlers(t *testing.T) { if got := router.notificationHandlers.MonitorForContext(context.Background()); got == nil || got.GetNotificationManager() != defaultMonitor.GetNotificationManager() { t.Fatalf("expected notificationHandlers to resolve the default tenant monitor") } - if router.configHandlers.mtMonitor != mtm { - t.Fatalf("expected configHandlers mtMonitor to be set") + if router.configHandlers.Monitor(context.Background()) != defaultMonitor { + t.Fatalf("expected configHandlers to resolve the default multi-tenant monitor") } if router.dockerAgentHandlers.mtMonitor != mtm { t.Fatalf("expected dockerAgentHandlers mtMonitor to be set") diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 90f23bcf3..b5054cb59 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -1301,12 +1301,10 @@ func TestSetupScriptURLRejectsSetupTokenAuthWhenPulseAuthIsConfigured(t *testing token := "0123456789abcdef0123456789abcdef" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), NodeType: "pve", - } - router.configHandlers.codeMutex.Unlock() + }) req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{"type":"pve","host":"pve.local"}`)) req.Header.Set("Content-Type", "application/json") @@ -4655,7 +4653,7 @@ func TestSSHKeyGenerationBlockedInContainer(t *testing.T) { t.Setenv("HOME", homeDir) handler := NewConfigHandlers(nil, nil, func() error { return nil }, nil, nil, func() {}, false) - keys := handler.getOrGenerateSSHKeys() + keys := handler.GetOrGenerateSSHKeys() if keys.SensorsPublicKey != "" { t.Fatalf("expected empty key when container SSH generation is blocked") } @@ -4935,9 +4933,7 @@ func TestVerifyTemperatureSSHAllowsSetupToken(t *testing.T) { token := "0123456789abcdef0123456789abcdef" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} - router.configHandlers.codeMutex.Unlock() + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}) req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`)) req.Header.Set("X-Setup-Token", token) @@ -4959,12 +4955,10 @@ func TestVerifyTemperatureSSHRejectsSetupTokenOrgMismatch(t *testing.T) { token := "fedcba9876543210fedcba9876543210" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), OrgID: "org-a", - } - router.configHandlers.codeMutex.Unlock() + }) req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`)) req.Header.Set("X-Setup-Token", token) @@ -4984,12 +4978,10 @@ func TestVerifyTemperatureSSHRejectsSetupTokenOrgIDQueryBypass(t *testing.T) { token := "11223344556677889900aabbccddeeff" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), OrgID: "org-a", - } - router.configHandlers.codeMutex.Unlock() + }) req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?org_id=org-a", strings.NewReader(`{"nodes":""}`)) req.Header.Set("X-Setup-Token", token) @@ -5007,9 +4999,7 @@ func TestSSHConfigAllowsSetupToken(t *testing.T) { token := "abcdef0123456789abcdef0123456789" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} - router.configHandlers.codeMutex.Unlock() + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}) req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n")) req.Header.Set("X-Setup-Token", token) @@ -5032,12 +5022,10 @@ func TestSSHConfigRejectsSetupTokenOrgMismatch(t *testing.T) { token := "00112233445566778899aabbccddeeff" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), OrgID: "org-a", - } - router.configHandlers.codeMutex.Unlock() + }) req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n")) req.Header.Set("X-Setup-Token", token) @@ -5055,9 +5043,7 @@ func TestVerifyTemperatureSSHRejectsSetupTokenQueryParam(t *testing.T) { token := "abcdefabcdefabcdefabcdefabcdefab" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} - router.configHandlers.codeMutex.Unlock() + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}) req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?auth_token="+token, strings.NewReader(`{"nodes":""}`)) rec := httptest.NewRecorder() @@ -5074,9 +5060,7 @@ func TestSSHConfigRejectsSetupTokenQueryParam(t *testing.T) { token := "deadbeefdeadbeefdeadbeefdeadbeef" tokenHash := auth.HashAPIToken(token) - router.configHandlers.codeMutex.Lock() - router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} - router.configHandlers.codeMutex.Unlock() + router.configHandlers.StoreSetupToken(tokenHash, &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)}) req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config?auth_token="+token, strings.NewReader("Host example\nHostname example\n")) rec := httptest.NewRecorder() diff --git a/internal/api/update_readiness_test.go b/internal/api/update_readiness_test.go index 9b19b3f3f..757d2e3ac 100644 --- a/internal/api/update_readiness_test.go +++ b/internal/api/update_readiness_test.go @@ -20,11 +20,11 @@ func TestRouterUpdateReadinessConfigSnapshotUsesCanonicalRuntimeTokens(t *testin t.Fatalf("NewAPITokenRecord() error = %v", err) } + configHandlers := &ConfigHandlers{} + configHandlers.SetConfig(&config.Config{}) r := &Router{ - config: &config.Config{APITokens: []config.APITokenRecord{*token}}, - configHandlers: &ConfigHandlers{ - defaultConfig: &config.Config{}, - }, + config: &config.Config{APITokens: []config.APITokenRecord{*token}}, + configHandlers: configHandlers, } snapshot := r.updateReadinessConfigSnapshot(context.Background())