Stabilize Proxmox guest Docker inventory

This commit is contained in:
rcourtman 2026-06-12 12:05:13 +01:00
parent d221640086
commit b179b1782c
16 changed files with 359 additions and 50 deletions

View file

@ -799,6 +799,13 @@ profile and assignment columns, but embedded table framing must route through
the host, auto-detection and remote config must not start the Docker /
Podman module.
8. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
On Unix systemd hosts, `scripts/install.sh` must keep the default agent unit
hardened, but command-enabled Proxmox VE agents selected with
`--enable-commands --enable-proxmox` and type `pve` or `all` must explicitly
allow the minimum `pct exec` / `lxc-attach` service posture by setting
`NoNewPrivileges=false` and `RestrictSUIDSGID=false`. That exception is
limited to the PVE command-agent path; it must not become the default for
Docker / Podman, host-only, PBS-only, or ordinary non-command agents.
Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes.
Approval-gated command execution must expose stable rejection reasons for
invalid approval grants so fleet operators can distinguish missing, expired,
@ -2097,6 +2104,12 @@ server itself cannot use afterward. That same selection path must only persist
selected host; if every candidate fingerprint probe fails, registration must
fall back to the preferred normalized host with strict TLS disabled instead of
pretending public-CA verification is now safe for a self-signed Proxmox node.
Cluster-member Proxmox agents that match a non-primary endpoint of an existing
cluster source are already registered for lifecycle purposes, even when the
cluster source's primary connection is currently disconnected. Only the primary
configured endpoint may drive disconnected-source repair and token rotation; a
covered member endpoint must not rotate the cluster token just because it can
reach `/api/auto-register`.
That same canonical behavior also includes one auth transport for Proxmox
completion: runtime-side Unified Agent and script callers must send `/api/auto-register`
authentication through a one-time setup token in the request-body

View file

@ -719,6 +719,13 @@ payload shape change when the portal presents compact client rows.
36. `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.
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
configured endpoints from covered cluster-member endpoints. A caller whose
candidate hosts match a non-primary `clusterEndpoints` entry is already
registered and must not rotate the shared Proxmox token, even if the
primary connection is currently disconnected. A caller whose candidates
match the primary configured endpoint may still drive disconnected-source
repair.
That same contract now owns stale-marker verification as well: setup-token-authenticated `checkRegistration` requests may omit token completion fields and must answer `{registered:boolean}` from canonical candidate-host matching so runtime repair can distinguish real registrations from stale local marker files without rotating tokens first.
That same contract owns auto-register WebSocket event intent: a successful
completion that creates a new PVE/PBS node may broadcast

View file

@ -277,6 +277,12 @@ TLS floor in the dynamic config.
When enabled, the terminal summary must also state that Proxmox LXC Docker
inventory still requires explicit server-side
`PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY=true`.
For command-enabled PVE agents, the generated systemd unit must keep the
normal service hardening except for the two flags that block host-side
`pct exec` / `lxc-attach`: `NoNewPrivileges=false` and
`RestrictSUIDSGID=false`. That exception is deployment-owned operator truth
for the Proxmox LXC Docker inventory path and must not leak into non-PVE or
non-command agent installs.
## Extension Points
@ -397,6 +403,13 @@ TLS floor in the dynamic config.
first-run reset removes the runtime `.env`; otherwise the Pro backend fails
closed before binding the API port and the supervisor loops without ever
reaching browser-verifiable health.
Hot-dev backend launches, supervisor child launches, and takeover restarts
must also preserve `LOG_LEVEL` and the Proxmox guest-Docker opt-in
environment (`PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION`,
`PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY`, and
`PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS`) so live dev verification of
host-side LXC Docker inventory does not silently restart into default-off
monitoring.
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
That release-promotion boundary also owns prerelease note packet lineage:
shipped RC notes must remain historically accurate, the top-level

View file

@ -167,7 +167,12 @@ truth for live infrastructure data.
server opt-in. LXC Docker inventory may only emit Docker / Podman
module-compatible reports into `ApplyDockerReport`, must skip guests with a
linked online guest-local host agent, and must keep the command set to
minimal read-only Docker summary and aggregate stats collection.
minimal read-only Docker summary and aggregate stats collection. The socket
probe must run its yes/no marker inside the target LXC through `pct exec`;
host-side `pct` / `lxc-attach` failures are probe errors and must not be
converted into cached `HasDocker=false` results. Negative Docker detections
may be rechecked on a short cadence so command enrollment, daemon startup,
or transient Proxmox access failures do not hide later inventory.
15. Add or change mock-mode Discovery context through the canonical mock
fixture graph. Mock Discovery records must be derived from the same authored
state graph as mock nodes, guests, Docker hosts, containers, and Kubernetes

View file

@ -356,6 +356,11 @@ recovery scope, or a storage/recovery-owned secret source.
for host-side Docker-in-LXC collection must not be reinterpreted as backup
freshness, restore coverage, storage protection evidence, or remediation
authority for the LXC guest.
Proxmox auto-registration dedupe for cluster-member endpoints is likewise
adjacent API/lifecycle continuity: treating a non-primary cluster endpoint
as already registered prevents accidental shared-token rotation, but it does
not create backup visibility, recovery readiness, restore scope, or
storage-local authority for that endpoint.
Global resource timeline routing may also pass through shared
`internal/api/` handlers. Storage and recovery surfaces may read canonical
timeline records as adjacent evidence, but they must not reinterpret an

View file

@ -446,6 +446,56 @@ func TestHandleAutoRegisterCheckRegistrationReturnsFalseForMissingNode(t *testin
}
}
func TestAutoRegisteredPVEInstanceExistsTreatsClusterEndpointAsRegisteredWhenPrimaryDisconnected(t *testing.T) {
instances := []config.PVEInstance{
{
Name: "delly",
Host: "https://delly:8006",
ClusterName: "homelab",
IsCluster: true,
ClusterEndpoints: []config.ClusterEndpoint{
{
NodeName: "minipc",
Host: "https://minipc:8006",
IP: "192.168.0.134",
},
},
},
}
connStatuses := map[string]bool{
"delly": false,
}
if !autoRegisteredPVEInstanceExists(instances, connStatuses, []string{"https://192.168.0.134:8006"}) {
t.Fatal("expected covered cluster endpoint to remain registered even while primary connection is disconnected")
}
}
func TestAutoRegisteredPVEInstanceExistsAllowsPrimaryDisconnectedRepair(t *testing.T) {
instances := []config.PVEInstance{
{
Name: "delly",
Host: "https://delly:8006",
ClusterName: "homelab",
IsCluster: true,
ClusterEndpoints: []config.ClusterEndpoint{
{
NodeName: "minipc",
Host: "https://minipc:8006",
IP: "192.168.0.134",
},
},
},
}
connStatuses := map[string]bool{
"delly": false,
}
if autoRegisteredPVEInstanceExists(instances, connStatuses, []string{"https://delly:8006"}) {
t.Fatal("expected primary configured connection to be repairable when monitor confirms it is disconnected")
}
}
func TestHandleAutoRegisterRejectsMissingSource(t *testing.T) {
stubAutoRegisterNetworkDeps(t)

View file

@ -705,6 +705,52 @@ func autoRegisterHostMatchesCandidates(existingHost string, candidates []string)
return false
}
func autoRegisterClusterEndpointMatchesCandidates(endpoint config.ClusterEndpoint, candidates []string) bool {
for _, endpointHost := range []string{
endpoint.Host,
endpoint.GuestURL,
endpoint.IPOverride,
endpoint.IP,
} {
if autoRegisterHostMatchesCandidates(endpointHost, candidates) {
return true
}
if normalized, err := normalizeNodeHost(endpointHost, "pve"); err == nil && autoRegisterHostMatchesCandidates(normalized, candidates) {
return true
}
}
return false
}
func autoRegisterPVEInstanceMatchesCandidates(node config.PVEInstance, candidates []string) (matched bool, primary bool) {
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
return true, true
}
for _, endpoint := range node.ClusterEndpoints {
if autoRegisterClusterEndpointMatchesCandidates(endpoint, candidates) {
return true, false
}
}
return false, false
}
func autoRegisteredPVEInstanceExists(instances []config.PVEInstance, connStatuses map[string]bool, candidates []string) bool {
for _, node := range instances {
matched, primary := autoRegisterPVEInstanceMatchesCandidates(node, candidates)
if !matched {
continue
}
// A non-primary cluster endpoint is already covered by the cluster
// connection. Letting endpoint agents rotate the shared PVE token can
// invalidate the primary configured connection.
if primary && isKnownDisconnected(connStatuses, "pve", node.Name, node.Host) {
return false
}
return true
}
return false
}
func (h *ConfigHandlers) autoRegisteredNodeExists(ctx context.Context, req *AutoRegisterRequest, candidates []string) bool {
if req == nil {
return false
@ -718,18 +764,7 @@ func (h *ConfigHandlers) autoRegisteredNodeExists(ctx context.Context, req *Auto
switch req.Type {
case "pve":
for _, node := range h.getConfig(ctx).PVEInstances {
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
// Node exists in config. If the monitor has definitively confirmed
// it is disconnected (stale token), allow re-registration so the
// agent can update the token. Default to "registered" when there
// is no monitor status yet (e.g. startup race).
if isKnownDisconnected(connStatuses, "pve", node.Name, node.Host) {
return false
}
return true
}
}
return autoRegisteredPVEInstanceExists(h.getConfig(ctx).PVEInstances, connStatuses, candidates)
case "pbs":
for _, node := range h.getConfig(ctx).PBSInstances {
if autoRegisterHostMatchesCandidates(node.Host, candidates) {

View file

@ -884,9 +884,13 @@ func TestProxmoxGuestDockerInventoryUsesCanonicalReportIngestPath(t *testing.T)
requiredSnippets := map[string][]string{
"docker_detection.go": {
"type DockerInventoryCollector interface {",
"proxmoxGuestDockerSocketMarker",
"proxmoxGuestDockerNegativeRecheckAfter",
"func (m *Monitor) CollectProxmoxGuestDockerInventory(ctx context.Context, containers []models.Container) {",
"m.hasOnlineHostAgentForContainer(ct.ID)",
"m.ApplyDockerReport(report, nil)",
"pct exec %d -- sh -c",
"container docker socket probe failed",
`docker ps -a --no-trunc --format 'CONTAINER\\t{{json .ID}}`,
`docker stats --no-stream --no-trunc --format 'STAT\\t{{json .ID}}`,
"ParseProxmoxGuestDockerInventoryVMIDs",
@ -908,6 +912,9 @@ func TestProxmoxGuestDockerInventoryUsesCanonicalReportIngestPath(t *testing.T)
t.Fatalf("%s must contain %q", file, snippet)
}
}
if file == "docker_detection.go" && strings.Contains(source, "&& echo yes || echo no") {
t.Fatalf("%s must not mask host-side pct exec failures with a host-shell echo fallback", file)
}
}
}

View file

@ -15,7 +15,11 @@ import (
"github.com/rs/zerolog/log"
)
const proxmoxGuestDockerInventoryMarker = "PULSE_DOCKER_GUEST_INVENTORY_V1"
const (
proxmoxGuestDockerInventoryMarker = "PULSE_DOCKER_GUEST_INVENTORY_V1"
proxmoxGuestDockerSocketMarker = "PULSE_DOCKER_SOCKET"
proxmoxGuestDockerNegativeRecheckAfter = 5 * time.Minute
)
// DockerChecker provides the ability to check for Docker inside LXC containers.
// This is typically implemented by wrapping the agentexec.Server.
@ -157,6 +161,14 @@ func (m *Monitor) containerNeedsDockerCheck(ct models.Container, previousContain
return "first_check"
}
// Negative Docker checks can be transient during agent command enrollment,
// Proxmox token rotation, or Docker daemon startup. Recheck them on a short
// cadence so a single false result does not hide Docker-in-LXC inventory
// until the guest restarts.
if !prev.HasDocker && time.Since(prev.DockerCheckedAt) >= proxmoxGuestDockerNegativeRecheckAfter {
return "negative_cache_expired"
}
// Container restarted - uptime is less than before
// (This catches containers that were stopped and started again)
if ct.Uptime < prev.Uptime && prev.Uptime > 0 {
@ -402,36 +414,39 @@ func NewAgentDockerChecker(executeCommand func(ctx context.Context, hostname str
}
// CheckDockerInContainer checks if Docker is installed inside an LXC container
// by running `pct exec <vmid> -- test -S /var/run/docker.sock` on the Proxmox node.
// by running a socket probe inside the guest through the owning Proxmox node.
func (c *AgentDockerChecker) CheckDockerInContainer(ctx context.Context, node string, vmid int) (bool, error) {
if c.executeCommand == nil {
return false, fmt.Errorf("no command executor configured")
}
// Check for Docker socket - this is the most reliable indicator
// We use test -S which checks if the file exists and is a socket
cmd := fmt.Sprintf("pct exec %d -- test -S /var/run/docker.sock && echo yes || echo no", vmid)
probe := fmt.Sprintf("if test -S /var/run/docker.sock; then printf '%s\\tyes\\n'; else printf '%s\\tno\\n'; fi", proxmoxGuestDockerSocketMarker, proxmoxGuestDockerSocketMarker)
cmd := fmt.Sprintf("pct exec %d -- sh -c %s", vmid, shellSingleQuote(probe))
stdout, exitCode, err := c.executeCommand(ctx, node, cmd, 10) // 10 second timeout
if err != nil {
return false, fmt.Errorf("command execution failed: %w", err)
}
// The test command itself might fail if container is not accessible
// but our echo fallback should always give us output
stdout = strings.TrimSpace(stdout)
// If we got "yes", Docker socket exists
if strings.Contains(stdout, "yes") {
return true, nil
output := strings.TrimSpace(stdout)
if exitCode != 0 {
return false, fmt.Errorf("container docker socket probe failed (exit code %d): %s", exitCode, output)
}
// If exit code is non-zero and we didn't get "no", the container might not be accessible
if exitCode != 0 && !strings.Contains(stdout, "no") {
return false, fmt.Errorf("container not accessible (exit code %d): %s", exitCode, stdout)
for _, rawLine := range strings.Split(output, "\n") {
fields := strings.Split(strings.TrimSpace(rawLine), "\t")
if len(fields) != 2 || fields[0] != proxmoxGuestDockerSocketMarker {
continue
}
switch strings.ToLower(strings.TrimSpace(fields[1])) {
case "yes":
return true, nil
case "no":
return false, nil
}
}
return false, nil
return false, fmt.Errorf("docker socket probe output missing %s marker: %s", proxmoxGuestDockerSocketMarker, output)
}
// AgentDockerInventoryCollector implements DockerInventoryCollector using the

View file

@ -277,15 +277,15 @@ func TestAgentDockerChecker_ParsesOutput(t *testing.T) {
}{
{
name: "docker socket exists",
stdout: "yes\n",
stdout: proxmoxGuestDockerSocketMarker + "\tyes\n",
exitCode: 0,
wantDocker: true,
wantErr: false,
},
{
name: "docker socket does not exist",
stdout: "no\n",
exitCode: 1,
stdout: proxmoxGuestDockerSocketMarker + "\tno\n",
exitCode: 0,
wantDocker: false,
wantErr: false,
},
@ -296,6 +296,20 @@ func TestAgentDockerChecker_ParsesOutput(t *testing.T) {
wantDocker: false,
wantErr: true,
},
{
name: "host-side pct failure is not cached as no docker",
stdout: "no\npct: CT 101 is locked",
exitCode: 255,
wantDocker: false,
wantErr: true,
},
{
name: "missing marker is an error",
stdout: "no\n",
exitCode: 0,
wantDocker: false,
wantErr: true,
},
}
for _, tt := range tests {
@ -319,6 +333,28 @@ func TestAgentDockerChecker_ParsesOutput(t *testing.T) {
}
}
func TestAgentDockerChecker_ProbeRunsInsideContainer(t *testing.T) {
var gotCommand string
checker := NewAgentDockerChecker(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
gotCommand = command
return proxmoxGuestDockerSocketMarker + "\tyes\n", 0, nil
})
hasDocker, err := checker.CheckDockerInContainer(context.Background(), "node1", 101)
if err != nil {
t.Fatalf("CheckDockerInContainer returned error: %v", err)
}
if !hasDocker {
t.Fatal("expected Docker to be detected")
}
if !strings.Contains(gotCommand, "pct exec 101 -- sh -c") {
t.Fatalf("expected probe to execute inside the LXC, got %q", gotCommand)
}
if strings.Contains(gotCommand, "&& echo yes || echo no") {
t.Fatalf("host-side shell fallback must not mask pct exec failures: %q", gotCommand)
}
}
func TestAgentDockerInventoryCollector_MinimalCommandAndReport(t *testing.T) {
var gotCommand string
collector := NewAgentDockerInventoryCollector(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
@ -820,6 +856,48 @@ func TestContainerNeedsDockerCheck(t *testing.T) {
},
wantReason: "started",
},
{
name: "recheck expired negative docker result",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
previous: map[string]models.Container{
"ct-1": {
ID: "ct-1",
Status: "running",
Uptime: 3600,
HasDocker: false,
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter - time.Second),
},
},
wantReason: "negative_cache_expired",
},
{
name: "keep fresh negative docker result",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
previous: map[string]models.Container{
"ct-1": {
ID: "ct-1",
Status: "running",
Uptime: 3600,
HasDocker: false,
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter + time.Second),
},
},
wantReason: "",
},
{
name: "keep positive docker result",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
previous: map[string]models.Container{
"ct-1": {
ID: "ct-1",
Status: "running",
Uptime: 3600,
HasDocker: true,
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter - time.Hour),
},
},
wantReason: "",
},
{
name: "no check needed - same state",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},

View file

@ -568,6 +568,10 @@ start_hot_dev_child() {
PULSE_DEV_API_PORT="${PULSE_DEV_API_PORT}" \
PULSE_DEV_API_URL="${PULSE_DEV_API_URL}" \
PULSE_DEV_WS_URL="${PULSE_DEV_WS_URL}" \
LOG_LEVEL="${LOG_LEVEL:-}" \
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}" \
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}" \
PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}" \
HOT_DEV_SKIP_NPM_CLEANUP=true \
ROOT_DIR="${ROOT_DIR}" \
python3 - <<'PY' &
@ -648,6 +652,10 @@ spawn_detached_supervisor() {
PULSE_DEV_API_PORT="${PULSE_DEV_API_PORT}" \
PULSE_DEV_API_URL="${PULSE_DEV_API_URL}" \
PULSE_DEV_WS_URL="${PULSE_DEV_WS_URL}" \
LOG_LEVEL="${LOG_LEVEL:-}" \
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}" \
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}" \
PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}" \
ROOT_DIR="${ROOT_DIR}" \
LOG_FILE="${LOG_FILE}" \
HOT_DEV_BG_SCRIPT="${ROOT_DIR}/scripts/hot-dev-bg.sh" \
@ -1143,6 +1151,9 @@ main() {
;;
stop) stop_bg ;;
restart)
if [[ "${takeover}" == "true" ]]; then
stop_launchd_hot_dev_job
fi
stop_bg
start_bg "${takeover}"
;;

View file

@ -357,6 +357,7 @@ PULSE_DEV_DISABLE_BACKGROUND_AI="${PULSE_DEV_DISABLE_BACKGROUND_AI:-true}"
PULSE_AUTH_USER="$(hot_dev_resolve_auth_user)"
PULSE_AUTH_PASS="$(hot_dev_resolve_auth_pass)"
export FRONTEND_PORT PULSE_DEV_API_PORT PORT PULSE_DEV ALLOW_ADMIN_BYPASS PULSE_AUTH_USER PULSE_AUTH_PASS LOG_LEVEL PULSE_DEV_DISABLE_BACKGROUND_AI
export PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS
# Data Directory Setup
# Mock and real mode use SEPARATE data directories so switching between them
@ -496,6 +497,9 @@ start_backend_process() {
PULSE_DEV="${PULSE_DEV:-true}" \
PULSE_AUTH_USER="${PULSE_AUTH_USER:-}" \
PULSE_AUTH_PASS="${PULSE_AUTH_PASS:-}" \
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}" \
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}" \
PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}" \
ALLOWED_ORIGINS="${ALLOWED_ORIGINS:-}" \
LOG_FILE="${BACKEND_DEBUG_LOG}" \
LOG_MAX_SIZE="50" \

View file

@ -701,30 +701,36 @@ ensure_freebsd_agent_enabled() {
}
render_systemd_agent_unit() {
local unit_path="$1"
local exec_path="$2"
local exec_args="$3"
local after_targets="$4"
local unit_path="$1"
local exec_path="$2"
local exec_args="$3"
local after_targets="$4"
local wants_targets="$5"
local run_as_user="$6"
local log_target="$7"
local env_line=""
local wants_line=""
local user_line=""
local log_lines=""
local user_line=""
local log_lines=""
local no_new_privileges="true"
local restrict_suidsgid="true"
env_line="$SYSTEMD_ENV_LINES"
if [[ -n "$wants_targets" ]]; then
wants_line=$'\n'"Wants=${wants_targets}"
fi
env_line="$SYSTEMD_ENV_LINES"
if [[ -n "$wants_targets" ]]; then
wants_line=$'\n'"Wants=${wants_targets}"
fi
if [[ -n "$run_as_user" ]]; then
user_line=$'\n'"User=${run_as_user}"
fi
if [[ -n "$log_target" ]]; then
log_lines=$'\n'"StandardOutput=append:${log_target}"$'\n'"StandardError=append:${log_target}"
fi
if [[ -n "$log_target" ]]; then
log_lines=$'\n'"StandardOutput=append:${log_target}"$'\n'"StandardError=append:${log_target}"
fi
if systemd_agent_requires_lxc_attach; then
no_new_privileges="false"
restrict_suidsgid="false"
fi
cat > "$unit_path" <<EOF
cat > "$unit_path" <<EOF
[Unit]
Description=Pulse Unified Agent
After=${after_targets}${wants_line}
@ -736,13 +742,13 @@ ExecStart=${exec_path} ${exec_args}${env_line}
Restart=always
RestartSec=5s${user_line}${log_lines}
UMask=0077
NoNewPrivileges=true
NoNewPrivileges=${no_new_privileges}
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
RestrictSUIDSGID=${restrict_suidsgid}
SystemCallArchitectures=native
[Install]
@ -750,6 +756,20 @@ WantedBy=multi-user.target
EOF
}
systemd_agent_requires_lxc_attach() {
if [[ "$ENABLE_COMMANDS" != "true" || "$ENABLE_PROXMOX" != "true" ]]; then
return 1
fi
case "${PROXMOX_TYPE:-}" in
""|pve|all)
return 0
;;
*)
return 1
;;
esac
}
render_freebsd_rc_agent_script() {
local script_path="$1"
local exec_path="$2"

View file

@ -155,13 +155,15 @@ func TestInstallSHAgentServiceSecurityDefaults(t *testing.T) {
`if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi`,
`--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;`,
`UMask=0077`,
`NoNewPrivileges=true`,
`local no_new_privileges="true"`,
`NoNewPrivileges=${no_new_privileges}`,
`PrivateTmp=true`,
`ProtectKernelTunables=true`,
`ProtectKernelModules=true`,
`ProtectControlGroups=true`,
`LockPersonality=true`,
`RestrictSUIDSGID=true`,
`local restrict_suidsgid="true"`,
`RestrictSUIDSGID=${restrict_suidsgid}`,
`SystemCallArchitectures=native`,
}
for _, needle := range required {
@ -171,6 +173,27 @@ func TestInstallSHAgentServiceSecurityDefaults(t *testing.T) {
}
}
func TestInstallSHAllowsProxmoxCommandAgentLXCAttach(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "install.sh"))
if err != nil {
t.Fatalf("read install.sh: %v", err)
}
script := string(content)
required := []string{
`systemd_agent_requires_lxc_attach() {`,
`if [[ "$ENABLE_COMMANDS" != "true" || "$ENABLE_PROXMOX" != "true" ]]; then`,
`""|pve|all)`,
`no_new_privileges="false"`,
`restrict_suidsgid="false"`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
t.Fatalf("install.sh missing Proxmox command-agent LXC attach service handling: %s", needle)
}
}
}
func TestInstallSHPreflightChecksAgentDownloadArtifact(t *testing.T) {
var requestedDownloadPath string
var requestedDownloadArch string

View file

@ -882,6 +882,17 @@ test_hot_dev_bg_script_advertises_managed_entrypoint() {
assert_contains "hot-dev-bg can recover a missing managed pid file" "${output}" "recover_managed_pid_file()"
}
test_hot_dev_bg_preserves_proxmox_guest_docker_env() {
local output
output="$(cat "${ROOT_DIR}/scripts/hot-dev-bg.sh")"
assert_contains "hot-dev-bg child preserves Proxmox guest Docker detection opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}"'
assert_contains "hot-dev-bg child preserves Proxmox guest Docker inventory opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}"'
assert_contains "hot-dev-bg child preserves scoped Proxmox guest Docker VMIDs" "${output}" 'PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}"'
assert_contains "hot-dev-bg child preserves debug log level" "${output}" 'LOG_LEVEL="${LOG_LEVEL:-}"'
assert_contains "hot-dev-bg restart takeover clears launchd before env-sensitive relaunch" "${output}" 'stop_launchd_hot_dev_job'
}
test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor() {
local test_dir output
test_dir="$(mktemp -d)"
@ -1568,6 +1579,7 @@ main() {
test_hot_dev_reconciles_agent_reachable_bind_address
test_hot_dev_health_monitor_probes_api_health
test_hot_dev_bg_script_advertises_managed_entrypoint
test_hot_dev_bg_preserves_proxmox_guest_docker_env
test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor
test_hot_dev_bg_preserves_pid_file_when_signal_probe_is_denied
test_hot_dev_bg_status_degrades_ownership_when_process_inspection_is_restricted

View file

@ -129,6 +129,16 @@ test_hot_dev_keeps_backend_launch_errors_in_debug_log() {
assert_contains "backend LOG_FILE follows debug log override" "${output}" "LOG_FILE=\"\${BACKEND_DEBUG_LOG}\""
}
test_hot_dev_preserves_proxmox_guest_docker_env() {
local output
output="$(sed -n '1,760p' "${HOT_DEV}")"
assert_contains "hot-dev exports Proxmox guest Docker detection opt-in" "${output}" "export PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS"
assert_contains "backend launch preserves Proxmox guest Docker detection opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}"'
assert_contains "backend launch preserves Proxmox guest Docker inventory opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}"'
assert_contains "backend launch preserves scoped Proxmox guest Docker VMIDs" "${output}" 'PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}"'
}
test_hot_dev_avoids_self_killing_npm_wrapper() {
local output
output="$(sed -n '1,330p' "${HOT_DEV}")"
@ -215,6 +225,7 @@ test_pulse_process_count_handles_zero_matches_under_pipefail
test_pulse_process_count_counts_matching_processes
test_hot_dev_uses_resilient_backend_process_count
test_hot_dev_keeps_backend_launch_errors_in_debug_log
test_hot_dev_preserves_proxmox_guest_docker_env
test_hot_dev_avoids_self_killing_npm_wrapper
test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in
test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts