diff --git a/README.md b/README.md index 4391890..195c879 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,13 @@ The OCI jump box runs the public edge path: nginx -> HAProxy -> Varnish/Squid -> Traefik MetalLB IP ``` +Tailnet access policy is tracked in `infra/tailscale/tailnet-policy.hujson`. +Validate it before applying with: + +```bash +./jeannie tailnet-policy-check +``` + The `bootstrap/edge` stack renders configs from `bootstrap/edge/templates` and deploys them to `/opt/homelab-edge` on the OCI host. Defaults are in `bootstrap/edge/variables.tf`; override them through `TF_VAR_*` or a `.tfvars` diff --git a/README.md.tmpl b/README.md.tmpl index f94cdbd..bd2242c 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -580,6 +580,13 @@ The OCI jump box runs the public edge path: nginx -> HAProxy -> Varnish/Squid -> Traefik MetalLB IP ``` +Tailnet access policy is tracked in `infra/tailscale/tailnet-policy.hujson`. +Validate it before applying with: + +```bash +./{{ main_script }} tailnet-policy-check +``` + The `bootstrap/edge` stack renders configs from `bootstrap/edge/templates` and deploys them to `/opt/homelab-edge` on the OCI host. Defaults are in `bootstrap/edge/variables.tf`; override them through `TF_VAR_*` or a `.tfvars` diff --git a/apps/demos-static/public/homelab-catalog/index.html b/apps/demos-static/public/homelab-catalog/index.html index 1e41a68..c7311d8 100644 --- a/apps/demos-static/public/homelab-catalog/index.html +++ b/apps/demos-static/public/homelab-catalog/index.html @@ -45,7 +45,7 @@ public-edge

oci_edge

Address: 132.145.170.74

-

Tailscale: n/a

+

Tailscale: 100.118.255.19

Storage: n/a

diff --git a/docs/service-catalog.md b/docs/service-catalog.md index f607460..2974b29 100644 --- a/docs/service-catalog.md +++ b/docs/service-catalog.md @@ -12,7 +12,7 @@ Update `homelab.yml`, then run `scripts/render-service-catalog`. | debian | control-plane | 192.168.100.73 | 100.85.138.30 | /data/docker | | rpi4 | dns-edge-worker | 192.168.100.89 | 100.77.80.72 | /nvme-storage/docker | | opi5_pimox | pimox | 192.168.100.80 | | opi5_ssd | -| oci_edge | public-edge | 132.145.170.74 | | | +| oci_edge | public-edge | 132.145.170.74 | 100.118.255.19 | | ## Services diff --git a/homelab.yml b/homelab.yml index ceca651..8252efd 100644 --- a/homelab.yml +++ b/homelab.yml @@ -60,6 +60,7 @@ hosts: role: public-edge user: ubuntu public_ip: 132.145.170.74 + tailscale_ip: 100.118.255.19 install_dir: /opt/homelab-edge services: diff --git a/infra/tailscale/README.md b/infra/tailscale/README.md new file mode 100644 index 0000000..3d7d571 --- /dev/null +++ b/infra/tailscale/README.md @@ -0,0 +1,39 @@ +# Tailscale ACLs + +This directory stores the intended tailnet policy as code. + +- `tailnet-policy.hujson` is the policy to review and apply in Tailscale. +- `scripts/validate-tailnet-policy` performs repo-local checks before you copy + or apply the policy. + +The policy keeps the critical public edge path explicit: + +```text +OCI edge -> Debian Gitea over Tailscale +OCI edge -> Traefik MetalLB IP through approved subnet route +tailnet members -> Pi-hole DNS and selected homelab admin ports +``` + +The OCI edge node must be tagged `tag:homelab-edge` in Tailscale before the +edge ACL is enforced. The tag owner is limited to tailnet admins. + +## Validate + +```bash +scripts/validate-tailnet-policy +``` + +The validator checks that the HUJSON parses after comment/trailing-comma +normalization and that host aliases match `homelab.yml`. + +## Apply + +Apply manually from the Tailscale admin console: + +1. Open the tailnet ACL policy editor. +2. Paste `infra/tailscale/tailnet-policy.hujson`. +3. Use the preview/test flow in the console. +4. Save only after the preview matches the intended access. + +Do not apply this blindly from automation until the Tailscale API token and +tailnet name are stored as SOPS-encrypted secrets. diff --git a/infra/tailscale/tailnet-policy.hujson b/infra/tailscale/tailnet-policy.hujson new file mode 100644 index 0000000..1a0a804 --- /dev/null +++ b/infra/tailscale/tailnet-policy.hujson @@ -0,0 +1,81 @@ +// Homelab tailnet policy managed in this repository. +// +// Review in the Tailscale admin console or with the Tailscale API before +// applying. Device tags are optional until you tag machines in the console. +{ + "tagOwners": { + "tag:homelab-admin": ["autogroup:admin"], + "tag:homelab-control-plane": ["autogroup:admin"], + "tag:homelab-rpi": ["autogroup:admin"], + "tag:homelab-edge": ["autogroup:admin"], + }, + + "hosts": { + "debian": "100.85.138.30", + "rpi4": "100.77.80.72", + "oci-edge": "100.118.255.19", + "traefik-lan": "192.168.100.240", + }, + + "acls": [ + // Full access for tailnet admins. + { + "action": "accept", + "src": ["autogroup:admin"], + "dst": ["*:*"], + }, + + // Public edge path back into the homelab over Tailscale and approved + // subnet routes. Tag the OCI edge node with tag:homelab-edge before + // relying on this rule for lab2025.duckdns.org. + { + "action": "accept", + "src": ["tag:homelab-edge"], + "dst": [ + "debian:3000", + "traefik-lan:80,443", + ], + }, + + // Homelab members can reach normal operational surfaces. + { + "action": "accept", + "src": ["autogroup:member"], + "dst": [ + "debian:22,3000,32222,30500,8088,11434", + "rpi4:22,53,8081,3001", + "oci-edge:22,80,443", + ], + }, + + // DNS from tailnet clients to the RPi resolver. + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["rpi4:53"], + }, + ], + + "ssh": [ + { + "action": "accept", + "src": ["autogroup:admin"], + "dst": [ + "tag:homelab-admin", + "tag:homelab-control-plane", + "tag:homelab-rpi", + "tag:homelab-edge", + ], + "users": ["jv", "ubuntu", "root"], + }, + ], + + "autoApprovers": { + "routes": { + "192.168.100.0/24": [ + "tag:homelab-edge", + "tag:homelab-rpi", + ], + }, + }, +} diff --git a/jeannie b/jeannie index 452fde8..c9ca3a9 100755 --- a/jeannie +++ b/jeannie @@ -4484,6 +4484,10 @@ secrets_check() { fi } +tailnet_policy_check() { + "${REPO_ROOT}/scripts/validate-tailnet-policy" +} + case "${1:-}" in up) up @@ -4554,6 +4558,9 @@ case "${1:-}" in secrets-check) secrets_check ;; + tailnet-policy-check) + tailnet_policy_check + ;; openwrt) openwrt ;; @@ -4561,7 +4568,7 @@ case "${1:-}" in nuke ;; *) - echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|secrets-init|secrets-check|openwrt|nuke}" + echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|secrets-init|secrets-check|tailnet-policy-check|openwrt|nuke}" exit 1 ;; esac diff --git a/scripts/validate-tailnet-policy b/scripts/validate-tailnet-policy new file mode 100755 index 0000000..f849ec8 --- /dev/null +++ b/scripts/validate-tailnet-policy @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Validate the repo-managed Tailscale policy without contacting Tailscale.""" + +from __future__ import annotations + +import json +import pathlib +import re +import sys + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +POLICY_PATH = REPO_ROOT / "infra" / "tailscale" / "tailnet-policy.hujson" +INVENTORY_PATH = REPO_ROOT / "homelab.yml" + + +def strip_hujson(source: str) -> str: + output: list[str] = [] + index = 0 + in_string = False + escaped = False + + while index < len(source): + char = source[index] + next_char = source[index + 1] if index + 1 < len(source) else "" + + if in_string: + output.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + index += 1 + continue + + if char == '"': + in_string = True + output.append(char) + index += 1 + continue + + if char == "/" and next_char == "/": + index += 2 + while index < len(source) and source[index] not in "\r\n": + index += 1 + continue + + if char == "/" and next_char == "*": + index += 2 + while index + 1 < len(source) and source[index : index + 2] != "*/": + index += 1 + index += 2 + continue + + output.append(char) + index += 1 + + normalized = "".join(output) + cleaned: list[str] = [] + index = 0 + in_string = False + escaped = False + while index < len(normalized): + char = normalized[index] + if in_string: + cleaned.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + index += 1 + continue + + if char == '"': + in_string = True + cleaned.append(char) + index += 1 + continue + + if char == ",": + lookahead = index + 1 + while lookahead < len(normalized) and normalized[lookahead].isspace(): + lookahead += 1 + if lookahead < len(normalized) and normalized[lookahead] in "]}": + index += 1 + continue + + cleaned.append(char) + index += 1 + + return "".join(cleaned) + + +def parse_simple_inventory(path: pathlib.Path) -> dict[str, str]: + values: dict[str, str] = {} + stack: list[tuple[int, str]] = [] + pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$") + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "): + continue + match = pattern.match(raw_line) + if not match: + continue + indent = len(match.group(1)) + key = match.group(2) + value = (match.group(3) or "").strip() + while stack and stack[-1][0] >= indent: + stack.pop() + path_key = ".".join([item[1] for item in stack] + [key]) + if value == "": + stack.append((indent, key)) + continue + if " #" in value: + value = value.split(" #", 1)[0].strip() + values[path_key] = value.strip("\"'") + + return values + + +def require(condition: bool, message: str, failures: list[str]) -> None: + if not condition: + failures.append(message) + + +def main() -> int: + failures: list[str] = [] + policy = json.loads(strip_hujson(POLICY_PATH.read_text(encoding="utf-8"))) + inventory = parse_simple_inventory(INVENTORY_PATH) + + require(isinstance(policy.get("hosts"), dict), "policy must define hosts", failures) + require(isinstance(policy.get("acls"), list) and policy["acls"], "policy must define non-empty acls", failures) + require(isinstance(policy.get("tagOwners"), dict), "policy must define tagOwners", failures) + + hosts = policy.get("hosts", {}) + if isinstance(hosts, dict): + expected_hosts = { + "debian": inventory.get("hosts.debian.tailscale_ip"), + "rpi4": inventory.get("hosts.rpi4.tailscale_ip"), + "oci-edge": inventory.get("hosts.oci_edge.tailscale_ip"), + "traefik-lan": inventory.get("network.metallb.traefik_ip"), + } + for name, expected_value in expected_hosts.items(): + require(bool(expected_value), f"homelab.yml missing inventory value for {name}", failures) + require(hosts.get(name) == expected_value, f"policy host {name}={hosts.get(name)!r}, expected {expected_value!r}", failures) + + for index, acl in enumerate(policy.get("acls", [])): + require(acl.get("action") == "accept", f"acl[{index}] action must be accept", failures) + require(isinstance(acl.get("src"), list) and acl["src"], f"acl[{index}] must have non-empty src list", failures) + require(isinstance(acl.get("dst"), list) and acl["dst"], f"acl[{index}] must have non-empty dst list", failures) + + if failures: + print("tailnet policy validation failed:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print("tailnet policy static validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())