diff --git a/README.md b/README.md index 8a4b53f..0ebdd84 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This repo currently provisions NixOS hosts with: - New machines are installed with `nixos-anywhere` - Ongoing changes are deployed with `colmena` - Hosts authenticate to OpenBao as clients -- Tailscale auth keys are fetched from OpenBao namespace `it`, path `tailscale`, field `auth_key` +- Tailscale auth keys are fetched from OpenBao namespace `it`, KV mount `kv`, path `tailscale`, field `auth_key` - Public SSH must work independently of Tailscale for first access and recovery ## Repo Layout @@ -227,6 +227,7 @@ The host uses: - OpenBao address: `https://secrets.api.nodeiwest.se` - namespace: `it` +- KV mount: `kv` - auth mount: `auth/approle` - secret path: `tailscale` - field: `auth_key` @@ -247,12 +248,12 @@ Create a minimal read-only policy for the Tailscale secret. If the secret is accessible as: ```bash -BAO_NAMESPACE=it bao kv get tailscale +BAO_NAMESPACE=it bao kv get -mount=kv tailscale ``` then create the matching read policy for that mount. -Example shape for a KV v2 mount named `kv`: +Example shape for the KV v2 mount `kv`: ```hcl path "kv/data/tailscale" { @@ -341,7 +342,7 @@ On first boot: 1. `vault-agent-tailscale.service` starts using `pkgs.openbao` 2. it authenticates to OpenBao with AppRole -3. it renders `auth_key` from `it/tailscale` to `/run/nodeiwest/tailscale-auth-key` +3. it renders `auth_key` from namespace `it`, KV mount `kv`, path `tailscale` to `/run/nodeiwest/tailscale-auth-key` 4. `nodeiwest-tailscale-authkey-ready.service` waits until that file exists 5. `tailscaled-autoconnect.service` uses that file and runs `tailscale up --ssh` diff --git a/hosts/lab/configuration.nix b/hosts/lab/configuration.nix new file mode 100644 index 0000000..f0de599 --- /dev/null +++ b/hosts/lab/configuration.nix @@ -0,0 +1,30 @@ +{ lib, ... }: +{ + # Generated by nodeiwest host init. + imports = [ + ./disko.nix + ./hardware-configuration.nix + ]; + + networking.hostName = "lab"; + networking.useDHCP = lib.mkDefault true; + + time.timeZone = "UTC"; + + boot.loader.efi.canTouchEfiVariables = true; + boot.loader.grub = { + enable = true; + efiSupport = true; + device = "nodev"; + }; + + nodeiwest.ssh.userCAPublicKeys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE6c2oMkM7lLg9qWHVgbrFaFBDrrFyynFlPviiydQdFi openbao-user-ca" + ]; + + nodeiwest.tailscale.openbao = { + enable = true; + }; + + system.stateVersion = "25.05"; +} diff --git a/hosts/lab/disko.nix b/hosts/lab/disko.nix new file mode 100644 index 0000000..377830e --- /dev/null +++ b/hosts/lab/disko.nix @@ -0,0 +1,47 @@ +{ + lib, + ... +}: +{ + # Generated by nodeiwest host init. + # Replace the disk only if the provider exposes a different primary device. + disko.devices = { + disk.main = { + type = "disk"; + device = lib.mkDefault "/dev/sda 11"; + content = { + type = "gpt"; + partitions = { + ESP = { + priority = 1; + name = "ESP"; + start = "1MiB"; + end = "512MiB"; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = [ "umask=0077" ]; + }; + }; + swap = { + size = "4GiB"; + content = { + type = "swap"; + resumeDevice = true; + }; + }; + root = { + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + }; +} diff --git a/hosts/lab/hardware-configuration.nix b/hosts/lab/hardware-configuration.nix new file mode 100644 index 0000000..3f6bc7b --- /dev/null +++ b/hosts/lab/hardware-configuration.nix @@ -0,0 +1,5 @@ +{ ... }: +{ + # Placeholder generated by nodeiwest host init. + # nixos-anywhere will replace this with the generated hardware config. +} diff --git a/pkgs/helpers/__pycache__/cli.cpython-313.pyc b/pkgs/helpers/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..4a14814 Binary files /dev/null and b/pkgs/helpers/__pycache__/cli.cpython-313.pyc differ diff --git a/pkgs/helpers/cli.py b/pkgs/helpers/cli.py index 8fd35d9..486cd3f 100644 --- a/pkgs/helpers/cli.py +++ b/pkgs/helpers/cli.py @@ -128,8 +128,9 @@ def build_parser() -> argparse.ArgumentParser: init_host_parser = openbao_subparsers.add_parser("init-host", help="Create policy, AppRole, and bootstrap files.") init_host_parser.add_argument("--name", required=True, help="Host name, e.g. vps2.") init_host_parser.add_argument("--namespace", default="it", help="OpenBao namespace. Default: it.") + init_host_parser.add_argument("--kv-mount", default="kv", help="KV v2 mount name. Default: kv.") init_host_parser.add_argument("--secret-path", default="tailscale", help="Logical secret path. Default: tailscale.") - init_host_parser.add_argument("--field", default="auth_key", help="Secret field. Default: auth_key.") + init_host_parser.add_argument("--field", default="CLIENT_SECRET", help="Secret field. Default: CLIENT_SECRET.") init_host_parser.add_argument("--auth-path", default="auth/approle", help="AppRole auth mount. Default: auth/approle.") init_host_parser.add_argument("--policy-name", help="Policy name. Default: tailscale-.") init_host_parser.add_argument("--role-name", help="AppRole name. Default: tailscale-.") @@ -351,7 +352,7 @@ def cmd_openbao_init_host(args: argparse.Namespace) -> int: role_id_path = output_dir / "var" / "lib" / "nodeiwest" / "openbao-approle-role-id" secret_id_path = output_dir / "var" / "lib" / "nodeiwest" / "openbao-approle-secret-id" - secret_data = bao_kv_get(args.namespace, args.secret_path) + secret_data = bao_kv_get(args.namespace, args.kv_mount, args.secret_path) fields = secret_data.get("data", {}) if isinstance(fields.get("data"), dict): fields = fields["data"] @@ -363,11 +364,12 @@ def cmd_openbao_init_host(args: argparse.Namespace) -> int: if args.kv_mount_path: policy_content = render_openbao_policy(args.kv_mount_path) else: - policy_content = derive_openbao_policy(args.namespace, args.secret_path) + policy_content = derive_openbao_policy(args.namespace, args.kv_mount, args.secret_path) role_command = build_approle_write_command(args.auth_path, role_name, policy_name, args.cidr) print(f"Namespace: {args.namespace}") + print(f"KV mount: {args.kv_mount}") print(f"Policy name: {policy_name}") print(f"Role name: {role_name}") print(f"Secret path: {args.secret_path}") @@ -1022,13 +1024,13 @@ def ensure_bao_authenticated() -> None: ) -def bao_kv_get(namespace: str, secret_path: str) -> dict[str, Any]: +def bao_kv_get(namespace: str, kv_mount: str, secret_path: str) -> dict[str, Any]: result = run_command( - ["bao", "kv", "get", "-format=json", secret_path], + ["bao", "kv", "get", f"-mount={kv_mount}", "-format=json", secret_path], env={"BAO_NAMESPACE": namespace}, next_fix=( - "Check BAO_ADDR, BAO_NAMESPACE, and the logical secret path. " - "If the path or mount is ambiguous, re-run with --kv-mount-path." + "Check BAO_ADDR, BAO_NAMESPACE, the KV mount, and the logical secret path. " + "If the KV mount is not the default, re-run with --kv-mount." ), ) try: @@ -1037,12 +1039,13 @@ def bao_kv_get(namespace: str, secret_path: str) -> dict[str, Any]: raise NodeiwestError(f"Failed to parse `bao kv get` JSON output: {exc}") from exc -def derive_openbao_policy(namespace: str, secret_path: str) -> str: +def derive_openbao_policy(namespace: str, kv_mount: str, secret_path: str) -> str: result = run_command( - ["bao", "kv", "get", "-output-policy", secret_path], + ["bao", "kv", "get", f"-mount={kv_mount}", "-output-policy", secret_path], env={"BAO_NAMESPACE": namespace}, next_fix=( - "Check BAO_ADDR, BAO_NAMESPACE, and the logical secret path. " + "Check BAO_ADDR, BAO_NAMESPACE, the KV mount, and the logical secret path. " + "If the KV mount is not the default, re-run with --kv-mount. " "If policy derivation still does not match your mount layout, re-run with --kv-mount-path." ), ) @@ -1220,8 +1223,8 @@ def infer_verify_failures( messages.append("Missing AppRole files on the host. Check /var/lib/nodeiwest/openbao-approle-role-id and ...secret-id.") if any(fragment in combined for fragment in ["invalid secret id", "permission denied", "approle", "failed to authenticate"]): messages.append("OpenBao AppRole authentication failed. Re-check the role, secret_id, namespace, and auth mount.") - if any(fragment in combined for fragment in ["auth_key", "timed out waiting for rendered tailscale auth key", "no data", "secret path"]): - messages.append("OpenBao rendered no Tailscale auth key. Check the secret path, KV mount path, and auth_key field.") + if any(fragment in combined for fragment in ["CLIENT_SECRET", "timed out waiting for rendered tailscale auth key", "no data", "secret path"]): + messages.append("OpenBao rendered no Tailscale auth key. Check the secret path, KV mount path, and CLIENT_SECRET field.") if tailscale_status.returncode != 0 or "logged out" in (tailscale_status.stdout or "").lower(): messages.append("Tailscale autoconnect is blocked. Check tailscaled-autoconnect, the rendered auth key, and outbound access to Tailscale.") diff --git a/pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc b/pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc new file mode 100644 index 0000000..dba937d Binary files /dev/null and b/pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc differ diff --git a/pkgs/helpers/tests/test_cli.py b/pkgs/helpers/tests/test_cli.py index 7a12230..50bc1db 100644 --- a/pkgs/helpers/tests/test_cli.py +++ b/pkgs/helpers/tests/test_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util import sys import unittest +from unittest import mock from pathlib import Path @@ -45,6 +46,28 @@ class HelperCliTests(unittest.TestCase): self.assertIn('device = lib.mkDefault "/dev/vda";', rendered) self.assertIn('size = "8GiB";', rendered) + def test_bao_kv_get_uses_explicit_kv_mount(self) -> None: + completed = mock.Mock() + completed.stdout = '{"data": {"data": {"CLIENT_ID": "x"}}}' + with mock.patch.object(cli, "run_command", return_value=completed) as run_command: + data = cli.bao_kv_get("it", "kv", "tailscale") + + self.assertEqual(data["data"]["data"]["CLIENT_ID"], "x") + command = run_command.call_args.args[0] + self.assertEqual(command, ["bao", "kv", "get", "-mount=kv", "-format=json", "tailscale"]) + self.assertEqual(run_command.call_args.kwargs["env"], {"BAO_NAMESPACE": "it"}) + + def test_derive_openbao_policy_uses_explicit_kv_mount(self) -> None: + completed = mock.Mock() + completed.stdout = 'path "kv/data/tailscale" { capabilities = ["read"] }\n' + with mock.patch.object(cli, "run_command", return_value=completed) as run_command: + policy = cli.derive_openbao_policy("it", "kv", "tailscale") + + self.assertIn('path "kv/data/tailscale"', policy) + command = run_command.call_args.args[0] + self.assertEqual(command, ["bao", "kv", "get", "-mount=kv", "-output-policy", "tailscale"]) + self.assertEqual(run_command.call_args.kwargs["env"], {"BAO_NAMESPACE": "it"}) + if __name__ == "__main__": unittest.main()