fix: correct field from bao
This commit is contained in:
@@ -17,7 +17,7 @@ This repo currently provisions NixOS hosts with:
|
|||||||
- New machines are installed with `nixos-anywhere`
|
- New machines are installed with `nixos-anywhere`
|
||||||
- Ongoing changes are deployed with `colmena`
|
- Ongoing changes are deployed with `colmena`
|
||||||
- Hosts authenticate to OpenBao as clients
|
- 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
|
- Public SSH must work independently of Tailscale for first access and recovery
|
||||||
|
|
||||||
## Repo Layout
|
## Repo Layout
|
||||||
@@ -227,6 +227,7 @@ The host uses:
|
|||||||
|
|
||||||
- OpenBao address: `https://secrets.api.nodeiwest.se`
|
- OpenBao address: `https://secrets.api.nodeiwest.se`
|
||||||
- namespace: `it`
|
- namespace: `it`
|
||||||
|
- KV mount: `kv`
|
||||||
- auth mount: `auth/approle`
|
- auth mount: `auth/approle`
|
||||||
- secret path: `tailscale`
|
- secret path: `tailscale`
|
||||||
- field: `auth_key`
|
- field: `auth_key`
|
||||||
@@ -247,12 +248,12 @@ Create a minimal read-only policy for the Tailscale secret.
|
|||||||
If the secret is accessible as:
|
If the secret is accessible as:
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
```hcl
|
||||||
path "kv/data/tailscale" {
|
path "kv/data/tailscale" {
|
||||||
@@ -341,7 +342,7 @@ On first boot:
|
|||||||
|
|
||||||
1. `vault-agent-tailscale.service` starts using `pkgs.openbao`
|
1. `vault-agent-tailscale.service` starts using `pkgs.openbao`
|
||||||
2. it authenticates to OpenBao with AppRole
|
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
|
4. `nodeiwest-tailscale-authkey-ready.service` waits until that file exists
|
||||||
5. `tailscaled-autoconnect.service` uses that file and runs `tailscale up --ssh`
|
5. `tailscaled-autoconnect.service` uses that file and runs `tailscale up --ssh`
|
||||||
|
|
||||||
|
|||||||
30
hosts/lab/configuration.nix
Normal file
30
hosts/lab/configuration.nix
Normal file
@@ -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";
|
||||||
|
}
|
||||||
47
hosts/lab/disko.nix
Normal file
47
hosts/lab/disko.nix
Normal file
@@ -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 = "/";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
5
hosts/lab/hardware-configuration.nix
Normal file
5
hosts/lab/hardware-configuration.nix
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
# Placeholder generated by nodeiwest host init.
|
||||||
|
# nixos-anywhere will replace this with the generated hardware config.
|
||||||
|
}
|
||||||
BIN
pkgs/helpers/__pycache__/cli.cpython-313.pyc
Normal file
BIN
pkgs/helpers/__pycache__/cli.cpython-313.pyc
Normal file
Binary file not shown.
@@ -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 = 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("--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("--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("--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("--auth-path", default="auth/approle", help="AppRole auth mount. Default: auth/approle.")
|
||||||
init_host_parser.add_argument("--policy-name", help="Policy name. Default: tailscale-<host>.")
|
init_host_parser.add_argument("--policy-name", help="Policy name. Default: tailscale-<host>.")
|
||||||
init_host_parser.add_argument("--role-name", help="AppRole name. Default: tailscale-<host>.")
|
init_host_parser.add_argument("--role-name", help="AppRole name. Default: tailscale-<host>.")
|
||||||
@@ -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"
|
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_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", {})
|
fields = secret_data.get("data", {})
|
||||||
if isinstance(fields.get("data"), dict):
|
if isinstance(fields.get("data"), dict):
|
||||||
fields = fields["data"]
|
fields = fields["data"]
|
||||||
@@ -363,11 +364,12 @@ def cmd_openbao_init_host(args: argparse.Namespace) -> int:
|
|||||||
if args.kv_mount_path:
|
if args.kv_mount_path:
|
||||||
policy_content = render_openbao_policy(args.kv_mount_path)
|
policy_content = render_openbao_policy(args.kv_mount_path)
|
||||||
else:
|
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)
|
role_command = build_approle_write_command(args.auth_path, role_name, policy_name, args.cidr)
|
||||||
|
|
||||||
print(f"Namespace: {args.namespace}")
|
print(f"Namespace: {args.namespace}")
|
||||||
|
print(f"KV mount: {args.kv_mount}")
|
||||||
print(f"Policy name: {policy_name}")
|
print(f"Policy name: {policy_name}")
|
||||||
print(f"Role name: {role_name}")
|
print(f"Role name: {role_name}")
|
||||||
print(f"Secret path: {args.secret_path}")
|
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(
|
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},
|
env={"BAO_NAMESPACE": namespace},
|
||||||
next_fix=(
|
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 path or mount is ambiguous, re-run with --kv-mount-path."
|
"If the KV mount is not the default, re-run with --kv-mount."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
try:
|
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
|
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(
|
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},
|
env={"BAO_NAMESPACE": namespace},
|
||||||
next_fix=(
|
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."
|
"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.")
|
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"]):
|
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.")
|
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"]):
|
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 auth_key field.")
|
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():
|
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.")
|
messages.append("Tailscale autoconnect is blocked. Check tailscaled-autoconnect, the rendered auth key, and outbound access to Tailscale.")
|
||||||
|
|
||||||
|
|||||||
BIN
pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc
Normal file
BIN
pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc
Normal file
Binary file not shown.
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +46,28 @@ class HelperCliTests(unittest.TestCase):
|
|||||||
self.assertIn('device = lib.mkDefault "/dev/vda";', rendered)
|
self.assertIn('device = lib.mkDefault "/dev/vda";', rendered)
|
||||||
self.assertIn('size = "8GiB";', 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user