Files
Anthropic-Cybersecurity-Skills/skills/deploying-tailscale-for-zero-trust-vpn/scripts/process.py
T

341 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Tailscale Zero Trust VPN Management and Monitoring.
Manages Tailscale deployment, ACL generation, network health monitoring,
and compliance reporting for zero trust mesh VPN infrastructure.
"""
import json
import subprocess
import datetime
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class TailscaleNode:
hostname: str
ip4: str
ip6: str = ""
os: str = ""
online: bool = False
tags: list = field(default_factory=list)
key_expiry: str = ""
last_seen: str = ""
exit_node: bool = False
subnet_routes: list = field(default_factory=list)
@dataclass
class ACLRule:
action: str # "accept" or "deny"
src: list
dst: list
description: str = ""
@dataclass
class ACLGroup:
name: str
members: list
class TailscaleACLGenerator:
"""Generate and validate Tailscale ACL configurations."""
def __init__(self):
self.groups: dict[str, list] = {}
self.tag_owners: dict[str, list] = {}
self.acls: list[dict] = []
self.ssh_rules: list[dict] = []
self.auto_approvers: dict = {"routes": {}, "exitNode": []}
self.node_attrs: list[dict] = []
def add_group(self, name: str, members: list):
if not name.startswith("group:"):
name = f"group:{name}"
self.groups[name] = members
def add_tag(self, tag: str, owners: list):
if not tag.startswith("tag:"):
tag = f"tag:{tag}"
self.tag_owners[tag] = owners
def add_acl_rule(self, src: list, dst: list, action: str = "accept"):
self.acls.append({"action": action, "src": src, "dst": dst})
def add_ssh_rule(self, src: list, dst: list, users: list, action: str = "check"):
self.ssh_rules.append({
"action": action,
"src": src,
"dst": dst,
"users": users,
})
def add_auto_approver_route(self, cidr: str, approvers: list):
self.auto_approvers["routes"][cidr] = approvers
def add_auto_approver_exit_node(self, approvers: list):
self.auto_approvers["exitNode"] = approvers
def generate_policy(self) -> dict:
policy = {}
if self.groups:
policy["groups"] = self.groups
if self.tag_owners:
policy["tagOwners"] = self.tag_owners
if self.acls:
policy["acls"] = self.acls
if self.ssh_rules:
policy["ssh"] = self.ssh_rules
if self.auto_approvers["routes"] or self.auto_approvers["exitNode"]:
policy["autoApprovers"] = self.auto_approvers
if self.node_attrs:
policy["nodeAttrs"] = self.node_attrs
return policy
def export_policy(self, output_path: str):
policy = self.generate_policy()
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(policy, f, indent=2)
return policy
def validate_policy(self) -> list:
"""Validate ACL policy for common issues."""
issues = []
policy = self.generate_policy()
# Check for empty ACLs
if not policy.get("acls"):
issues.append("WARNING: No ACL rules defined - all traffic will be denied")
# Check for overly permissive rules
for i, rule in enumerate(self.acls):
if "*" in rule.get("src", []) and "*" in rule.get("dst", []):
issues.append(f"CRITICAL: Rule {i} allows all-to-all access")
for dst in rule.get("dst", []):
if dst.endswith(":*"):
issues.append(f"WARNING: Rule {i} allows all ports to {dst}")
# Check groups reference valid members
for group_name, members in self.groups.items():
if not members:
issues.append(f"WARNING: Group {group_name} has no members")
# Check tag owners exist
for tag, owners in self.tag_owners.items():
for owner in owners:
if owner.startswith("group:") and owner not in self.groups:
issues.append(f"ERROR: Tag {tag} references undefined group {owner}")
# Check SSH rules
for i, rule in enumerate(self.ssh_rules):
if "root" in rule.get("users", []) and rule.get("action") != "check":
issues.append(f"WARNING: SSH rule {i} allows root access without re-auth check")
return issues
class TailscaleMonitor:
"""Monitor Tailscale network health and compliance."""
def __init__(self):
self.nodes: list[TailscaleNode] = []
def get_status(self) -> dict:
"""Get current Tailscale status via CLI."""
try:
result = subprocess.run(
["tailscale", "status", "--json"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
pass
return {}
def parse_nodes(self, status: dict) -> list[TailscaleNode]:
"""Parse Tailscale status into node objects."""
self.nodes = []
peers = status.get("Peer", {})
for peer_id, peer_data in peers.items():
node = TailscaleNode(
hostname=peer_data.get("HostName", "unknown"),
ip4=peer_data.get("TailscaleIPs", [""])[0] if peer_data.get("TailscaleIPs") else "",
ip6=peer_data.get("TailscaleIPs", ["", ""])[1] if len(peer_data.get("TailscaleIPs", [])) > 1 else "",
os=peer_data.get("OS", ""),
online=peer_data.get("Online", False),
tags=peer_data.get("Tags", []),
key_expiry=peer_data.get("KeyExpiry", ""),
last_seen=peer_data.get("LastSeen", ""),
exit_node=peer_data.get("ExitNode", False),
)
self.nodes.append(node)
return self.nodes
def check_health(self) -> dict:
"""Run health checks on the tailnet."""
report = {
"timestamp": datetime.datetime.now().isoformat(),
"total_nodes": len(self.nodes),
"online_nodes": sum(1 for n in self.nodes if n.online),
"offline_nodes": sum(1 for n in self.nodes if not n.online),
"expiring_keys": [],
"untagged_nodes": [],
"exit_nodes": [],
"issues": [],
}
for node in self.nodes:
# Check for expiring keys
if node.key_expiry:
try:
expiry = datetime.datetime.fromisoformat(node.key_expiry.replace("Z", "+00:00"))
days_until = (expiry - datetime.datetime.now(datetime.timezone.utc)).days
if days_until < 30:
report["expiring_keys"].append({
"hostname": node.hostname,
"expires_in_days": days_until,
})
except (ValueError, TypeError):
pass
# Check for untagged nodes
if not node.tags:
report["untagged_nodes"].append(node.hostname)
# Track exit nodes
if node.exit_node:
report["exit_nodes"].append(node.hostname)
# Generate issues
if report["offline_nodes"] > 0:
report["issues"].append(
f"{report['offline_nodes']} nodes offline"
)
if report["expiring_keys"]:
report["issues"].append(
f"{len(report['expiring_keys'])} nodes with keys expiring within 30 days"
)
if report["untagged_nodes"]:
report["issues"].append(
f"{len(report['untagged_nodes'])} nodes without tags (ungoverned by ACLs)"
)
return report
def generate_compliance_report(self) -> dict:
"""Generate zero trust compliance report for the tailnet."""
report = {
"report_date": datetime.datetime.now().isoformat(),
"zero_trust_checks": {
"encryption": {
"status": "PASS",
"detail": "All connections use WireGuard end-to-end encryption",
},
"identity_based_access": {
"status": "PASS" if all(n.tags for n in self.nodes) else "FAIL",
"detail": "All nodes should have identity tags for ACL enforcement",
},
"least_privilege": {
"status": "REVIEW",
"detail": "ACL policy review required - validate minimum necessary access",
},
"continuous_verification": {
"status": "PASS" if not any(
n.key_expiry == "" for n in self.nodes
) else "WARNING",
"detail": "Key expiry should be enabled for all non-server nodes",
},
"device_trust": {
"status": "REVIEW",
"detail": "Verify device authorization and Network Lock status",
},
},
}
return report
def generate_example_policy():
"""Generate an example zero trust ACL policy for Tailscale."""
gen = TailscaleACLGenerator()
# Define groups
gen.add_group("engineering", ["eng1@company.com", "eng2@company.com"])
gen.add_group("sre", ["sre1@company.com", "sre2@company.com"])
gen.add_group("security", ["sec1@company.com"])
gen.add_group("management", ["mgr1@company.com"])
# Define tags
gen.add_tag("production", ["group:sre"])
gen.add_tag("staging", ["group:engineering", "group:sre"])
gen.add_tag("development", ["group:engineering"])
gen.add_tag("database", ["group:sre"])
gen.add_tag("monitoring", ["group:sre", "group:security"])
gen.add_tag("ci-runner", ["group:sre"])
# ACL rules - zero trust, least privilege
gen.add_acl_rule(
src=["group:engineering"],
dst=["tag:development:*", "tag:staging:443,8080"]
)
gen.add_acl_rule(
src=["group:sre"],
dst=["tag:production:22,443,8080", "tag:staging:*", "tag:database:5432,3306"]
)
gen.add_acl_rule(
src=["group:security"],
dst=["tag:monitoring:443,9090", "tag:production:443"]
)
gen.add_acl_rule(
src=["tag:ci-runner"],
dst=["tag:staging:443,8080", "tag:production:443"]
)
# SSH rules with re-authentication
gen.add_ssh_rule(
src=["group:sre"],
dst=["tag:production"],
users=["admin"],
action="check" # Requires re-auth, records session
)
gen.add_ssh_rule(
src=["group:engineering"],
dst=["tag:development"],
users=["autogroup:nonroot"],
action="accept"
)
# Auto-approvers for subnet routes
gen.add_auto_approver_route("10.0.0.0/16", ["group:sre"])
gen.add_auto_approver_exit_node(["group:sre"])
# Validate
issues = gen.validate_policy()
if issues:
print("Policy Validation Issues:")
for issue in issues:
print(f" - {issue}")
else:
print("Policy validation: PASSED")
# Export
policy = gen.export_policy("tailscale_acl_policy.json")
print(f"\nGenerated ACL policy with:")
print(f" Groups: {len(gen.groups)}")
print(f" Tags: {len(gen.tag_owners)}")
print(f" ACL Rules: {len(gen.acls)}")
print(f" SSH Rules: {len(gen.ssh_rules)}")
print(f"\nPolicy saved to: tailscale_acl_policy.json")
print(f"\nPolicy preview:")
print(json.dumps(policy, indent=2))
if __name__ == "__main__":
generate_example_policy()