Harden dev agent deploy SSH host verification

This commit is contained in:
rcourtman
2026-04-22 11:41:50 +01:00
parent ca26ed2f44
commit e8b93db1e1
6 changed files with 155 additions and 45 deletions
+2
View File
@@ -231,6 +231,7 @@ scripts/release_control/*
!scripts/release_control/generate_platform_support_frontend_module.py
!scripts/release_control/control_plane_audit.py
!scripts/release_control/control_plane_audit_test.py
!scripts/release_control/dev_runtime_governance_test.py
!scripts/release_control/documentation_currentness_test.py
!scripts/release_control/format_staged_go.py
!scripts/release_control/format_staged_go_test.py
@@ -266,6 +267,7 @@ scripts/release_control/*
!scripts/release_control/subsystem_contracts_test.py
!scripts/release_control/subsystem_lookup.py
!scripts/release_control/subsystem_lookup_test.py
!scripts/release_control/ssh_host_key_policy_test.py
!scripts/release_control/work_claim.py
!scripts/release_control/work_claim_test.py
.agent/
@@ -54,38 +54,39 @@ server-side update execution surfaces.
32. `scripts/clean-mock-alerts.sh`
33. `scripts/com.pulse.hot-dev.plist.template`
34. `scripts/dev-check.sh`
35. `scripts/dev-launchd-setup.sh`
36. `scripts/dev-launchd-wrapper.sh`
37. `scripts/hot-dev-bg.sh`
38. `scripts/hot-dev.sh`
39. `scripts/install-container-agent.sh`
40. `scripts/install.ps1`
41. `scripts/install.sh`
42. `scripts/pulse-auto-update.sh`
43. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`
44. `scripts/release_control/record_rc_to_ga_rehearsal.py`
45. `scripts/release_control/release_promotion_policy_support.py`
46. `scripts/release_control/resolve_release_promotion.py`
47. `scripts/release_ldflags.sh`
48. `scripts/run_demo_public_browser_smoke.sh`
49. `scripts/demo_public_browser_smoke.cjs`
50. `scripts/run_hosted_staging_smoke.sh`
51. `scripts/trigger-release-dry-run.sh`
52. `scripts/trigger-release.sh`
53. `scripts/toggle-mock.sh`
54. `deploy/helm/pulse/`
55. `tests/integration/playwright.config.ts`
56. `tests/integration/QUICK_START.md`
57. `tests/integration/README.md`
58. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
59. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
60. `tests/integration/scripts/hosted-tenant-runtime.mjs`
61. `tests/integration/scripts/managed-dev-runtime.mjs`
62. `tests/integration/scripts/relay-mobile-token-helper.go`
63. `tests/integration/tests/helpers.ts`
64. `tests/integration/tests/runtime-defaults.ts`
65. `docker-compose.yml`
66. `scripts/install-docker.sh`
35. `scripts/dev-deploy-agent.sh`
36. `scripts/dev-launchd-setup.sh`
37. `scripts/dev-launchd-wrapper.sh`
38. `scripts/hot-dev-bg.sh`
39. `scripts/hot-dev.sh`
40. `scripts/install-container-agent.sh`
41. `scripts/install.ps1`
42. `scripts/install.sh`
43. `scripts/pulse-auto-update.sh`
44. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`
45. `scripts/release_control/record_rc_to_ga_rehearsal.py`
46. `scripts/release_control/release_promotion_policy_support.py`
47. `scripts/release_control/resolve_release_promotion.py`
48. `scripts/release_ldflags.sh`
49. `scripts/run_demo_public_browser_smoke.sh`
50. `scripts/demo_public_browser_smoke.cjs`
51. `scripts/run_hosted_staging_smoke.sh`
52. `scripts/trigger-release-dry-run.sh`
53. `scripts/trigger-release.sh`
54. `scripts/toggle-mock.sh`
55. `deploy/helm/pulse/`
56. `tests/integration/playwright.config.ts`
57. `tests/integration/QUICK_START.md`
58. `tests/integration/README.md`
59. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
60. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
61. `tests/integration/scripts/hosted-tenant-runtime.mjs`
62. `tests/integration/scripts/managed-dev-runtime.mjs`
63. `tests/integration/scripts/relay-mobile-token-helper.go`
64. `tests/integration/tests/helpers.ts`
65. `tests/integration/tests/runtime-defaults.ts`
66. `docker-compose.yml`
67. `scripts/install-docker.sh`
## Shared Boundaries
@@ -100,7 +101,7 @@ server-side update execution surfaces.
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.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`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `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`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.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`, and `.github/workflows/update-demo-server.yml`
3. Add or change shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh`
4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts`
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
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
@@ -527,6 +528,11 @@ to be listening.
Changes to `scripts/hot-dev.sh` and `scripts/hot-dev-bg.sh` must therefore
stay on their own direct dev-runtime orchestration proof path instead of
piggybacking on installer proof coverage for unrelated deployment scripts.
That same dev-runtime helper boundary also owns trusted-host behavior for the
developer agent deploy wrapper: `scripts/dev-deploy-agent.sh` may TOFU new SSH
targets, but it must persist host keys in a known_hosts file and fail closed
on host-key changes instead of disabling verification with
`StrictHostKeyChecking=no`.
That same dev-runtime orchestration boundary also owns watcher stability for
the managed local stack: `scripts/hot-dev.sh` may only rebuild the backend for
runtime Go sources, not `*_test.go` churn, and it must suppress `pulse` binary
@@ -2441,6 +2441,7 @@
"scripts/com.pulse.hot-dev.plist.template",
"scripts/demo_public_browser_smoke.cjs",
"scripts/dev-check.sh",
"scripts/dev-deploy-agent.sh",
"scripts/dev-launchd-setup.sh",
"scripts/dev-launchd-wrapper.sh",
"scripts/hot-dev-bg.sh",
@@ -2666,6 +2667,7 @@
"scripts/clean-mock-alerts.sh",
"scripts/com.pulse.hot-dev.plist.template",
"scripts/dev-check.sh",
"scripts/dev-deploy-agent.sh",
"scripts/dev-launchd-setup.sh",
"scripts/dev-launchd-wrapper.sh",
"scripts/hot-dev-bg.sh",
@@ -2681,6 +2683,7 @@
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/release_control/ssh_host_key_policy_test.py",
"scripts/tests/test-hot-dev-bg.sh",
"scripts/tests/test-toggle-mock.sh",
"tests/integration/tests/16-dev-runtime-recovery.spec.ts"
+20 -12
View File
@@ -16,7 +16,15 @@ GOARCH="${GOARCH:-amd64}"
GOOS="${GOOS:-linux}"
# SSH options
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
# Default to TOFU for new hosts while still failing closed on host-key changes.
SSH_KNOWN_HOSTS_FILE="${PULSE_DEV_SSH_KNOWN_HOSTS_FILE:-$HOME/.ssh/known_hosts}"
SSH_OPTS=(
-o BatchMode=yes
-o StrictHostKeyChecking=accept-new
-o UpdateHostKeys=yes
-o ConnectTimeout=10
-o "UserKnownHostsFile=$SSH_KNOWN_HOSTS_FILE"
)
# Remote paths
REMOTE_AGENT_PATH="/usr/local/bin/pulse-agent"
@@ -76,7 +84,7 @@ for host in "${HOSTS[@]}"; do
log_info "Deploying to $host..."
# Check if host is reachable and get architecture
HOST_ARCH=$(ssh $SSH_OPTS "$host" "uname -m" 2>/dev/null || echo "unknown")
HOST_ARCH=$(ssh "${SSH_OPTS[@]}" "$host" "uname -m" 2>/dev/null || echo "unknown")
if [ "$HOST_ARCH" == "unknown" ]; then
log_error "Cannot connect to $host or determine architecture - skipping"
FAILED_HOSTS+=("$host")
@@ -102,11 +110,11 @@ for host in "${HOSTS[@]}"; do
# Stop the service
log_info " Stopping pulse-agent..."
ssh $SSH_OPTS "$host" "sudo systemctl stop $REMOTE_SERVICE 2>/dev/null || pkill -f pulse-agent || true"
ssh "${SSH_OPTS[@]}" "$host" "sudo systemctl stop $REMOTE_SERVICE 2>/dev/null || pkill -f pulse-agent || true"
# Copy the binary
log_info " Copying binary..."
if ! scp $SSH_OPTS "$BINARY_PATH" "$host:/tmp/pulse-agent-new"; then
if ! scp "${SSH_OPTS[@]}" "$BINARY_PATH" "$host:/tmp/pulse-agent-new"; then
log_error " Failed to copy binary to $host"
FAILED_HOSTS+=("$host")
continue
@@ -114,7 +122,7 @@ for host in "${HOSTS[@]}"; do
# Install the binary
log_info " Installing binary..."
if ! ssh $SSH_OPTS "$host" "sudo mv /tmp/pulse-agent-new $REMOTE_AGENT_PATH && sudo chmod +x $REMOTE_AGENT_PATH"; then
if ! ssh "${SSH_OPTS[@]}" "$host" "sudo mv /tmp/pulse-agent-new $REMOTE_AGENT_PATH && sudo chmod +x $REMOTE_AGENT_PATH"; then
log_error " Failed to install binary on $host"
FAILED_HOSTS+=("$host")
continue
@@ -123,25 +131,25 @@ for host in "${HOSTS[@]}"; do
# Start the service
log_info " Starting pulse-agent..."
# Try systemd first, then Unraid go.d script, then manual start via existing scripts
if ! ssh $SSH_OPTS "$host" "sudo systemctl start $REMOTE_SERVICE 2>/dev/null"; then
if ssh $SSH_OPTS "$host" "test -f /boot/config/go.d/pulse-agent.sh" 2>/dev/null; then
if ! ssh "${SSH_OPTS[@]}" "$host" "sudo systemctl start $REMOTE_SERVICE 2>/dev/null"; then
if ssh "${SSH_OPTS[@]}" "$host" "test -f /boot/config/go.d/pulse-agent.sh" 2>/dev/null; then
log_info " Using Unraid startup script..."
ssh $SSH_OPTS "$host" "bash /boot/config/go.d/pulse-agent.sh" >/dev/null 2>&1
elif ssh $SSH_OPTS "$host" "test -f /etc/init.d/pulse-agent" 2>/dev/null; then
ssh "${SSH_OPTS[@]}" "$host" "bash /boot/config/go.d/pulse-agent.sh" >/dev/null 2>&1
elif ssh "${SSH_OPTS[@]}" "$host" "test -f /etc/init.d/pulse-agent" 2>/dev/null; then
log_info " Using init.d script..."
ssh $SSH_OPTS "$host" "sudo /etc/init.d/pulse-agent start" >/dev/null 2>&1
ssh "${SSH_OPTS[@]}" "$host" "sudo /etc/init.d/pulse-agent start" >/dev/null 2>&1
fi
fi
# Verify it's running
sleep 2
if ssh $SSH_OPTS "$host" "pgrep -x pulse-agent >/dev/null 2>&1"; then
if ssh "${SSH_OPTS[@]}" "$host" "pgrep -x pulse-agent >/dev/null 2>&1"; then
log_success " Agent deployed and running on $host"
SUCCESS_HOSTS+=("$host")
else
# Try one last ditch effort: run it via the background helper if we can find it
log_warn " Agent not running, checking logs..."
ssh $SSH_OPTS "$host" "tail -n 5 /var/log/pulse-agent.log /boot/logs/pulse-agent.log 2>/dev/null" | log_warn
ssh "${SSH_OPTS[@]}" "$host" "tail -n 5 /var/log/pulse-agent.log /boot/logs/pulse-agent.log 2>/dev/null" | log_warn
FAILED_HOSTS+=("$host")
fi
done
@@ -37,6 +37,32 @@ class DevRuntimeGovernanceTest(unittest.TestCase):
match["verification_requirement"]["exact_files"],
)
def test_lookup_maps_dev_agent_deploy_helper_to_installability(self) -> None:
result = lookup_paths(["scripts/dev-deploy-agent.sh"])
self.assertEqual(result["unowned_runtime_files"], [])
self.assertEqual(
{entry["subsystem"] for entry in result["impacted_subsystems"]},
{"deployment-installability"},
)
file_entry = result["files"][0]
self.assertEqual(file_entry["classification"], "runtime")
self.assertEqual(
{match["subsystem"] for match in file_entry["matches"]},
{"deployment-installability"},
)
match = file_entry["matches"][0]
self.assertEqual(
match["contract"],
"docs/release-control/v6/internal/subsystems/deployment-installability.md",
)
self.assertEqual(match["lane_context"]["lane_id"], "L1")
self.assertEqual(match["verification_requirement"]["id"], "dev-runtime-orchestration")
self.assertIn(
"scripts/release_control/ssh_host_key_policy_test.py",
match["verification_requirement"]["exact_files"],
)
def test_lookup_maps_integration_browser_default_surfaces_to_installability(self) -> None:
result = lookup_paths(
[
@@ -0,0 +1,65 @@
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
TEXT_RUNTIME_SUFFIXES = (".go", ".py", ".sh", ".yml", ".yaml")
ALLOWED_STRICT_HOST_KEY_CHECKING_NO = {
"internal/hostagent/cluster_sensors.go",
}
ALLOWED_USER_KNOWN_HOSTS_DEV_NULL = {
"internal/hostagent/cluster_sensors.go",
}
def iter_runtime_files():
for base in (REPO_ROOT / "internal", REPO_ROOT / "scripts", REPO_ROOT / ".github" / "workflows"):
if not base.exists():
continue
for path in base.rglob("*"):
if not path.is_file():
continue
relative = path.relative_to(REPO_ROOT).as_posix()
if not relative.endswith(TEXT_RUNTIME_SUFFIXES):
continue
if relative.endswith(("_test.go", "_test.py", ".md")):
continue
if relative.startswith("scripts/installtests/") or relative.startswith("scripts/tests/"):
continue
yield path, relative
class SSHHostKeyPolicyTest(unittest.TestCase):
def test_runtime_paths_do_not_disable_ssh_host_key_verification(self) -> None:
strict_host_key_checking_no = []
user_known_hosts_dev_null = []
for path, relative in iter_runtime_files():
content = path.read_text(encoding="utf-8")
if "StrictHostKeyChecking=no" in content:
strict_host_key_checking_no.append(relative)
if "UserKnownHostsFile=/dev/null" in content:
user_known_hosts_dev_null.append(relative)
self.assertEqual(
sorted(strict_host_key_checking_no),
sorted(ALLOWED_STRICT_HOST_KEY_CHECKING_NO),
f"unexpected StrictHostKeyChecking=no runtime surfaces: {strict_host_key_checking_no}",
)
self.assertEqual(
sorted(user_known_hosts_dev_null),
sorted(ALLOWED_USER_KNOWN_HOSTS_DEV_NULL),
f"unexpected UserKnownHostsFile=/dev/null runtime surfaces: {user_known_hosts_dev_null}",
)
def test_dev_agent_deploy_uses_tofu_instead_of_disabling_host_checks(self) -> None:
script = (REPO_ROOT / "scripts" / "dev-deploy-agent.sh").read_text(encoding="utf-8")
self.assertNotIn("StrictHostKeyChecking=no", script)
self.assertIn("StrictHostKeyChecking=accept-new", script)
self.assertIn("UpdateHostKeys=yes", script)
self.assertIn("UserKnownHostsFile=$SSH_KNOWN_HOSTS_FILE", script)
if __name__ == "__main__":
unittest.main()