Add repo-managed Tailscale ACL policy
This commit is contained in:
parent
f38b065599
commit
acb6817c98
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
9
jeannie
9
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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue