diff --git a/device-plane/deployment/device-edge-vps-tailscale-retirement-v1.json b/device-plane/deployment/device-edge-vps-tailscale-retirement-v1.json new file mode 100644 index 0000000..c88a992 --- /dev/null +++ b/device-plane/deployment/device-edge-vps-tailscale-retirement-v1.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": "nodedc.device-edge-vps.tailscale-retirement.v1", + "mode": "retire-superseded-vps-tailnet-after-accepted-core-channel", + "status": "core-channel-only", + "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", + "component": "device-edge-vps", + "phase": "tailscale-retirement", + "runtimeHost": "koffyvngij", + "predecessorPatch": "device-edge-vps-core-channel-20260812-010", + "predecessorArtifactSha256": "c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3", + "runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust", + "publicIngress": "tcp/443-mtls-only", + "trackerIngress": "disabled", + "rawDeviceTcp9921": "closed", + "commandTransport": "disabled", + "gelios": "untouched", + "preserved": [ + "management-ssh-key", + "accepted-node-runtime", + "accepted-core-channel-source-runtime-and-trust", + "core-channel-registration", + "foundation-source-for-audit-and-rollback", + "gelios-production-path" + ], + "retired": [ + "nodedc-b2-tailscaled.service", + "userspace-socks5-127.0.0.1:1055", + "tailscale-local-state", + "tailscale-runtime-binaries", + "superseded-backhaul-private-key" + ], + "forbidden": [ + "tailscale-runtime", + "tailnet-address", + "vps-initiated-synology-connection", + "generic-tcp-forwarding", + "tailscale-ssh-backhaul", + "docker", + "public-health", + "tracker-tcp/9921" + ], + "acceptance": [ + "exact-core-channel-010-predecessor", + "core-channel-remains-accepted", + "tailscale-service-absent-inactive-and-disabled", + "tailscale-userspace-listeners-absent", + "tailscale-local-state-and-runtime-binaries-absent", + "superseded-backhaul-private-key-absent", + "public-443-only-beside-management-ssh", + "tracker-tcp-9921-closed", + "command-transport-disabled" + ], + "externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok", + "rollback": "before-external-tailnet-revocation-restore-backed-up-local-tailscale-runtime-state-unit-and-core-channel-predecessor" +} diff --git a/infra/deploy-runner/build-device-edge-vps-artifact.mjs b/infra/deploy-runner/build-device-edge-vps-artifact.mjs index ec52355..fea1b1f 100644 --- a/infra/deploy-runner/build-device-edge-vps-artifact.mjs +++ b/infra/deploy-runner/build-device-edge-vps-artifact.mjs @@ -35,11 +35,12 @@ if ( "backhaul", "relay", "core-channel", + "tailscale-retirement", ].includes(phase) || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "") ) { throw new Error( - "usage: build-device-edge-vps-artifact.mjs ", + "usage: build-device-edge-vps-artifact.mjs ", ); } @@ -92,6 +93,9 @@ const entriesByPhase = { "vps/systemd/nodedc-device-edge-channel.service", "deployment/device-edge-vps-core-channel-v1.json", ], + "tailscale-retirement": [ + "deployment/device-edge-vps-tailscale-retirement-v1.json", + ], }; const entries = entriesByPhase[phase]; const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); @@ -151,7 +155,7 @@ try { entries, publicIngress: phase === "relay" ? "tcp/9921" - : phase === "core-channel" + : ["core-channel", "tailscale-retirement"].includes(phase) ? "tcp/443-mtls-only" : "disabled", commandTransport: "disabled", @@ -289,6 +293,27 @@ async function assertBoundary() { } } } + if (phase === "tailscale-retirement") { + for (const required of [ + '"predecessorPatch": "device-edge-vps-core-channel-20260812-010"', + '"runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust"', + '"trackerIngress": "disabled"', + '"externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok"', + ]) { + if (!combined.includes(required)) { + throw new Error(`tailscale_retirement_boundary_missing:${required}`); + } + } + for (const forbidden of [ + "tcp dport 9921", + "LocalForward", + "commandTransport\": \"enabled", + ]) { + if (combined.includes(forbidden)) { + throw new Error(`tailscale_retirement_boundary_violation:${forbidden}`); + } + } + } } function canonicalTarScript() { diff --git a/infra/deploy-runner/nodedc-b2-vps-deploy b/infra/deploy-runner/nodedc-b2-vps-deploy index 8cbe4ea..c17f7e3 100755 --- a/infra/deploy-runner/nodedc-b2-vps-deploy +++ b/infra/deploy-runner/nodedc-b2-vps-deploy @@ -102,6 +102,10 @@ CHANNEL_CORE_CERTIFICATE = CHANNEL_TRUST_ROOT / "core-certificate.pem" CHANNEL_RUNTIME_CONFIG = CHANNEL_TRUST_ROOT / "runtime.json" CHANNEL_HEALTH_PORT = 18222 CHANNEL_PUBLIC_PORT = 443 +CORE_CHANNEL_ACCEPTED_PATCH = "device-edge-vps-core-channel-20260812-010" +CORE_CHANNEL_ACCEPTED_SHA256 = ( + "c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3" +) FOUNDATION_ENTRIES = ( "vps/config/00-nodedc-b2-vps.conf", @@ -136,6 +140,9 @@ CORE_CHANNEL_ENTRIES = ( "vps/systemd/nodedc-device-edge-channel.service", "deployment/device-edge-vps-core-channel-v1.json", ) +TAILSCALE_RETIREMENT_ENTRIES = ( + "deployment/device-edge-vps-tailscale-retirement-v1.json", +) PHASE_ENTRIES = { "foundation": FOUNDATION_ENTRIES, @@ -143,6 +150,7 @@ PHASE_ENTRIES = { "backhaul": BACKHAUL_ENTRIES, "relay": RELAY_ENTRIES, "core-channel": CORE_CHANNEL_ENTRIES, + "tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES, } SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"}) @@ -206,6 +214,10 @@ PHASE_FILE_SHA256 = { "deployment/device-edge-vps-core-channel-v1.json": "ecbd66404d197f6d939736bbc1c28cc1e7091781639ffb78d2199a8db2b3ccd2", }, + "tailscale-retirement": { + "deployment/device-edge-vps-tailscale-retirement-v1.json": + "bbe11e8cf4103f44ae7888b4d3f3dde7015eeaef8ea5a7c4c00427a7b85f8e33", + }, } # Exact immutable baselines from terminally accepted predecessor artifacts. @@ -622,6 +634,18 @@ def current_phase_preflight(phase: str): for port in (CHANNEL_HEALTH_PORT, CHANNEL_PUBLIC_PORT, 9921): assert_port_closed(port) return {"predecessor": "accepted-foundation-closed-channel"} + if phase == "tailscale-retirement": + core_record = applied_phase_record("core-channel") + if ( + core_record.get("patch") != CORE_CHANNEL_ACCEPTED_PATCH + or core_record.get("sha256") != CORE_CHANNEL_ACCEPTED_SHA256 + ): + die("VPS Tailscale retirement Core channel predecessor mismatch") + source_file_state("core-channel") + validate_core_channel_runtime() + if (LIVE_ROOT / TAILSCALE_RETIREMENT_ENTRIES[0]).exists(): + die("VPS Tailscale retirement target path already exists") + return {"predecessor": "accepted-core-channel-010-with-live-tailnet"} if phase == "backhaul": for tool in (Path("/usr/bin/ssh"), Path("/usr/bin/nc")): assert_executable_command_path( @@ -693,6 +717,13 @@ def backup_targets_for_phase(phase: str): return common + [BACKHAUL_UNIT, BACKHAUL_KNOWN_HOSTS] if phase == "core-channel": return common + [CHANNEL_UNIT, NFTABLES_CONFIG, CHANNEL_TRUST_ROOT] + if phase == "tailscale-retirement": + return common + [ + TAILSCALE_UNIT, + TAILSCALE_STATE.parent, + TAILSCALE_BIN.parent, + TRUST_ROOT, + ] return common + [RELAY_UNIT, NFTABLES_CONFIG] @@ -1141,6 +1172,28 @@ def apply_core_channel(payload: Path): validate_core_channel_runtime() +def apply_tailscale_retirement(_payload: Path): + # Remote machine deletion is intentionally post-deploy: doing a logout + # inside apply would invalidate the exact local state needed by automatic + # rollback. The accepted local boundary is nevertheless closed completely. + systemctl("disable", "--now", "nodedc-b2-tailscaled.service", check=False) + for path in ( + TAILSCALE_UNIT, + TAILSCALE_STATE.parent, + TAILSCALE_BIN.parent, + TRUST_ROOT, + ): + if path.is_symlink(): + die(f"VPS Tailscale retirement target symlink rejected: {path}") + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + systemctl("daemon-reload") + systemctl("reset-failed", "nodedc-b2-tailscaled.service", check=False) + validate_tailscale_retirement_runtime() + + def sshd_effective(): return run(["/usr/sbin/sshd", "-T"]).stdout.lower() @@ -1348,6 +1401,67 @@ def validate_core_channel_runtime(): return health +def validate_tailscale_retirement_runtime(): + source_file_state("foundation") + source_file_state("core-channel") + source_file_state("tailscale-retirement") + core_record = applied_phase_record("core-channel") + if ( + core_record.get("patch") != CORE_CHANNEL_ACCEPTED_PATCH + or core_record.get("sha256") != CORE_CHANNEL_ACCEPTED_SHA256 + ): + die("retired Tailscale runtime Core channel identity mismatch") + if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": + die("retired Tailscale runtime Node version mismatch") + if service_active("nodedc-b2-tailscaled.service"): + die("retired Tailscale service remains active") + if systemctl("is-enabled", "nodedc-b2-tailscaled.service", check=False).returncode == 0: + die("retired Tailscale service remains enabled") + for path in ( + TAILSCALE_UNIT, + TAILSCALE_SOCKET, + TAILSCALE_STATE.parent, + TAILSCALE_BIN.parent, + TRUST_ROOT, + ): + if path.exists() or path.is_symlink(): + die(f"retired Tailscale boundary remains present: {path}") + if run(["/usr/bin/pgrep", "-x", "tailscaled"], check=False).returncode == 0: + die("retired Tailscale process remains running") + assert_management_key() + if not port_is_open(PUBLIC_IPV4, 22, timeout=5): + die("management SSH became unavailable after Tailscale retirement") + if port_is_open("127.0.0.1", 1055): + die("retired Tailscale SOCKS listener remains open") + source_file_state("core-channel") + assert_channel_trust(require_runtime_owner=True) + if not service_active("nodedc-device-edge-channel.service"): + die("Core channel became inactive after Tailscale retirement") + health = core_channel_health(require_accepted=True) + expected = { + "ok": True, + "service": "nodedc-device-edge-channel", + "channel": "accepted", + "trackerIngress": "disabled", + "commandTransport": "disabled", + } + for key, value in expected.items(): + if health.get(key) != value: + die(f"retired Tailscale Core channel contract mismatch: {key}") + if not port_is_open(PUBLIC_IPV4, CHANNEL_PUBLIC_PORT, timeout=5): + die("Core channel listener became unavailable after Tailscale retirement") + assert_port_closed(9921) + nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout + if ( + "policy drop" not in nft + or "tcp dport 22" not in nft + or "tcp dport 443" not in nft + or "tcp dport 9921" in nft + ): + die("retired Tailscale firewall contract mismatch") + return health + + def validate_relay_runtime(): validate_backhaul_runtime() source_file_state("relay") @@ -1499,7 +1613,7 @@ def plan_artifact(artifact_argument: str): print("source_admission=public-ipv4-only") print("services=nodedc-b2-relay") print(f"relay_runtime_identity={RELAY_USER}:no-credentials") - else: + elif phase == "core-channel": print("public_core_channel=155.212.211.15:443/tcp:tls13-mtls-h2") print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}") print("public_b2_ingress=disabled") @@ -1507,6 +1621,16 @@ def plan_artifact(artifact_argument: str): print("services=nodedc-device-edge-channel") print(f"channel_runtime_identity={CHANNEL_USER}:host-local-private-key") print("peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint") + else: + print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") + print(f"health=preserved:127.0.0.1:{CHANNEL_HEALTH_PORT}") + print("tailscale=stop+disable+destroy-local-runtime-state") + print("tailscale_socks_1055=removed") + print("superseded_backhaul_private_key=removed") + print("public_b2_ingress=disabled") + print("tracker_tcp_9921=closed") + print("services=preserved:nodedc-device-edge-channel") + print("external_tailnet_machine_cleanup=required-after-deploy-ok") print("command_transport=disabled") print("gelios=untouched") print("dns=unchanged") @@ -1540,8 +1664,10 @@ def apply_artifact(artifact_argument: str): apply_backhaul(loaded["payload"]) elif loaded["phase"] == "relay": apply_relay(loaded["payload"]) - else: + elif loaded["phase"] == "core-channel": apply_core_channel(loaded["payload"]) + else: + apply_tailscale_retirement(loaded["payload"]) archived = archive_artifact(loaded["artifact"], APPLIED_ROOT) record = { diff --git a/infra/deploy-runner/test_device_edge_vps_artifact.py b/infra/deploy-runner/test_device_edge_vps_artifact.py index a493427..a651725 100644 --- a/infra/deploy-runner/test_device_edge_vps_artifact.py +++ b/infra/deploy-runner/test_device_edge_vps_artifact.py @@ -98,6 +98,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): "backhaul", "relay", "core-channel", + "tailscale-retirement", ): with self.subTest(phase=phase), tempfile.TemporaryDirectory( prefix=f"nodedc-vps-{phase}-" @@ -176,6 +177,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): "backhaul", "relay", "core-channel", + "tailscale-retirement", ): result = self.build( inbox, @@ -311,6 +313,54 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("public_core_channel=disabled", rendered) self.assertIn("tracker_tcp_9921=closed", rendered) + def test_tailscale_retirement_plan_preserves_channel_and_opens_no_tracker_port(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-retirement-plan-") as directory: + inbox = Path(directory) / "inbox" + inbox.mkdir() + result = self.build( + inbox, + "tailscale-retirement", + "device-edge-vps-tailscale-retirement-plan-001", + ) + self.assertEqual(result.returncode, 0, result.stderr) + artifact = Path(json.loads(result.stdout)["artifact"]) + old_inbox = RUNNER.INBOX_ROOT + RUNNER.INBOX_ROOT = inbox + try: + with patch.object(RUNNER, "assert_root"), patch.object( + RUNNER, + "preflight", + return_value={ + "predecessor": "accepted-core-channel-010-with-live-tailnet", + }, + ), patch("builtins.print") as output: + RUNNER.plan_artifact(str(artifact)) + finally: + RUNNER.INBOX_ROOT = old_inbox + rendered = "\n".join( + " ".join(str(arg) for arg in call.args) + for call in output.call_args_list + ) + self.assertIn("phase=tailscale-retirement", rendered) + self.assertIn( + "predecessor=accepted-core-channel-010-with-live-tailnet", + rendered, + ) + self.assertIn( + "public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2", + rendered, + ) + self.assertIn( + "tailscale=stop+disable+destroy-local-runtime-state", + rendered, + ) + self.assertIn("tailscale_socks_1055=removed", rendered) + self.assertIn("superseded_backhaul_private_key=removed", rendered) + self.assertIn("tracker_tcp_9921=closed", rendered) + self.assertIn("external_tailnet_machine_cleanup=required-after-deploy-ok", rendered) + self.assertIn("command_transport=disabled", rendered) + self.assertIn("gelios=untouched", rendered) + def test_publish_payload_preserves_unselected_executable_modes(self): with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory: root = Path(directory)