Extract configuration API runtime package

This commit is contained in:
rcourtman
2026-08-21 14:12:57 +01:00
parent 58bf77c1ae
commit c68d5dd3d8
65 changed files with 1549 additions and 466 deletions
+11 -11
View File
@@ -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",
@@ -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.
@@ -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.
@@ -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
@@ -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": [],
@@ -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.
@@ -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
+11 -96
View File
@@ -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)
}
+2 -13
View File
@@ -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), "/")
+117
View File
@@ -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)
}
+39
View File
@@ -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")
}
}
+133
View File
@@ -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
}
+40
View File
@@ -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)
}
}
+110
View File
@@ -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)
}
@@ -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]
}
@@ -1,4 +1,4 @@
package api
package configapi
import (
"testing"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"context"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"context"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -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")
@@ -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.
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"testing"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"encoding/json"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import "testing"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"encoding/json"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"net"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"testing"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"context"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"errors"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"net/http"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"testing"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -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)
@@ -1,4 +1,4 @@
package api
package configapi
import (
"context"
@@ -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,
@@ -1,4 +1,4 @@
package api
package configapi
import (
"testing"
@@ -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()
@@ -1,4 +1,4 @@
package api
package configapi
import "testing"
+221
View File
@@ -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
}
+62
View File
@@ -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; }`
}
@@ -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{
@@ -1,4 +1,4 @@
package api
package configapi
import (
"errors"
@@ -1,4 +1,4 @@
package api
package configapi
import (
"bytes"
@@ -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 + "'"
}
@@ -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
+102
View File
@@ -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)
}
+18 -28
View File
@@ -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)
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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)
}
+16 -16
View File
@@ -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)
}
@@ -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,
+2 -2
View File
@@ -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")
+13 -29
View File
@@ -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()
+4 -4
View File
@@ -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())