168 lines
5.3 KiB
Python
Executable File
168 lines
5.3 KiB
Python
Executable File
#!/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())
|