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
|
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
|
The `bootstrap/edge` stack renders configs from `bootstrap/edge/templates` and
|
||||||
deploys them to `/opt/homelab-edge` on the OCI host. Defaults are in
|
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`
|
`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
|
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
|
The `bootstrap/edge` stack renders configs from `bootstrap/edge/templates` and
|
||||||
deploys them to `/opt/homelab-edge` on the OCI host. Defaults are in
|
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`
|
`bootstrap/edge/variables.tf`; override them through `TF_VAR_*` or a `.tfvars`
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
<span>public-edge</span>
|
<span>public-edge</span>
|
||||||
<h2>oci_edge</h2>
|
<h2>oci_edge</h2>
|
||||||
<p><strong>Address:</strong> 132.145.170.74</p>
|
<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>
|
<p><strong>Storage:</strong> n/a</p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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 |
|
| 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 |
|
| rpi4 | dns-edge-worker | 192.168.100.89 | 100.77.80.72 | /nvme-storage/docker |
|
||||||
| opi5_pimox | pimox | 192.168.100.80 | | opi5_ssd |
|
| 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
|
## Services
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ hosts:
|
||||||
role: public-edge
|
role: public-edge
|
||||||
user: ubuntu
|
user: ubuntu
|
||||||
public_ip: 132.145.170.74
|
public_ip: 132.145.170.74
|
||||||
|
tailscale_ip: 100.118.255.19
|
||||||
install_dir: /opt/homelab-edge
|
install_dir: /opt/homelab-edge
|
||||||
|
|
||||||
services:
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tailnet_policy_check() {
|
||||||
|
"${REPO_ROOT}/scripts/validate-tailnet-policy"
|
||||||
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
up)
|
up)
|
||||||
up
|
up
|
||||||
|
|
@ -4554,6 +4558,9 @@ case "${1:-}" in
|
||||||
secrets-check)
|
secrets-check)
|
||||||
secrets_check
|
secrets_check
|
||||||
;;
|
;;
|
||||||
|
tailnet-policy-check)
|
||||||
|
tailnet_policy_check
|
||||||
|
;;
|
||||||
openwrt)
|
openwrt)
|
||||||
openwrt
|
openwrt
|
||||||
;;
|
;;
|
||||||
|
|
@ -4561,7 +4568,7 @@ case "${1:-}" in
|
||||||
nuke
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
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