Add repo-managed Tailscale ACL policy

This commit is contained in:
juvdiaz 2026-06-27 17:46:21 -06:00
parent f38b065599
commit acb6817c98
9 changed files with 312 additions and 3 deletions

View File

@ -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`

View File

@ -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`

View File

@ -45,7 +45,7 @@
<span>public-edge</span>
<h2>oci_edge</h2>
<p><strong>Address:</strong> 132.145.170.74</p>
<p><strong>Tailscale:</strong> n/a</p>
<p><strong>Tailscale:</strong> 100.118.255.19</p>
<p><strong>Storage:</strong> n/a</p>
</article>
</section>

View File

@ -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

View File

@ -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:

39
infra/tailscale/README.md Normal file
View File

@ -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.

View File

@ -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",
],
},
},
}

View File

@ -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

167
scripts/validate-tailnet-policy Executable file
View File

@ -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())