mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Extract configuration API runtime package
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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), "/")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+15
-10
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import "testing"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+10
-5
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
+65
-25
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+19
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
-96
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package configapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
+1
-1
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user