110 lines
5.4 KiB
Python
110 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Interactive local configuration. Reads Cloudflare DNS; never changes DNS."""
|
|
import getpass
|
|
import argparse
|
|
import grp
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from watchdog import APIError, Cloudflare, SafetyError, canonical_name, public_v4, load_config, observed_mode, validate_inventory
|
|
|
|
|
|
def private_write(path, text, mode, gid=None):
|
|
fd, tmp = tempfile.mkstemp(prefix=".configure-", dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
os.fchmod(stream.fileno(), mode)
|
|
if gid is not None:
|
|
os.fchown(stream.fileno(), 0, gid)
|
|
stream.write(text)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(tmp, path)
|
|
finally:
|
|
if os.path.exists(tmp):
|
|
os.unlink(tmp)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--local", action="store_true", help="Save private runtime files beside this script; no sudo/systemd needed")
|
|
parser.add_argument("--health-host", help="Permanent proxied health hostname (default: edge-health.<domain>)")
|
|
parser.add_argument("--app", action="append", help="Existing A record to manage; repeat as needed (default: apex and wildcard)")
|
|
args = parser.parse_args()
|
|
if not args.local and os.geteuid() != 0:
|
|
raise SafetyError("Use --local without sudo, or run the optional system installer")
|
|
directory = Path(__file__).resolve().parent / "runtime" if args.local else Path("/etc/oracle-edge")
|
|
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
if args.local:
|
|
os.chmod(directory, 0o700)
|
|
domain = canonical_name(input("Cloudflare domain (for example, example.com): ").strip())
|
|
oracle_ip = public_v4(input("Oracle reserved public IPv4: ").strip())
|
|
home_ip = public_v4(input("Current home public IPv4: ").strip())
|
|
if oracle_ip == home_ip:
|
|
raise SafetyError("Oracle and home IPv4 addresses must differ")
|
|
health_host = canonical_name(args.health_host or "edge-health." + domain)
|
|
apps = [canonical_name(name, allow_wildcard=True) for name in (args.app or ["*." + domain, domain])]
|
|
if any(name != domain and not name.endswith("." + domain) for name in [health_host, *apps]):
|
|
raise SafetyError("All configured names must belong to the chosen domain")
|
|
print("Only these application A records will switch: " + ", ".join(apps))
|
|
zone = input("Cloudflare Zone ID for " + domain + ": ").strip()
|
|
if not re.fullmatch(r"[a-fA-F0-9]{32}", zone):
|
|
raise SafetyError("Zone ID must be 32 hexadecimal characters")
|
|
token = getpass.getpass("Cloudflare API token (input hidden): ").strip()
|
|
if not token or len(token) > 4096 or any(ord(c) < 33 or ord(c) > 126 for c in token):
|
|
raise SafetyError("Invalid API token format")
|
|
local_ip = input("Existing NPM LAN IPv4 (read it from your current OPNsense forward): ").strip()
|
|
address = ipaddress.ip_address(local_ip)
|
|
private_ranges = [ipaddress.ip_network(net) for net in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
|
|
if address.version != 4 or not any(address in net for net in private_ranges):
|
|
raise SafetyError("Enter NPM's actual private LAN IPv4, not Oracle or your public home IP")
|
|
cfg = {
|
|
"zone_id": zone, "oracle_ip": oracle_ip, "home_ip": home_ip,
|
|
"health": {"primary_host": health_host, "local_ip": str(address),
|
|
"path": "/__edge_health", "expected_body": "edge-ok-v1\n", "timeout_seconds": 5},
|
|
"token_file": str(directory / "cf-token"),
|
|
"state_file": str(directory / "state.json") if args.local else "/var/lib/oracle-edge/state.json",
|
|
"interval_seconds": 15, "failures_required": 4, "recovery_seconds": 300,
|
|
"minimum_dwell_seconds": 600, "api_timeout_seconds": 10,
|
|
}
|
|
api = Cloudflare(cfg, token)
|
|
inventory = api.inventory()
|
|
|
|
def reference(name):
|
|
selected = [r for r in inventory if r.get("name", "").lower().rstrip(".") == name and r.get("type") == "A"]
|
|
if len(selected) != 1:
|
|
raise SafetyError(f"Expected exactly one existing A record for {name}")
|
|
return {"name": name, "id": selected[0]["id"]}
|
|
|
|
cfg["canary"] = reference(health_host)
|
|
cfg["apps"] = [reference(name) for name in apps]
|
|
# Validate the complete schema before saving credentials or the active config.
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8") as candidate:
|
|
json.dump(cfg, candidate)
|
|
candidate.flush()
|
|
cfg = load_config(candidate.name)
|
|
by_id, _ = validate_inventory(cfg, inventory)
|
|
mode = observed_mode(cfg, by_id)
|
|
if mode == "mixed":
|
|
raise SafetyError("Managed records disagree; put them in the same tested mode before setup")
|
|
gid = None if args.local else grp.getgrnam("oracle-edge").gr_gid
|
|
private_write(directory / "cf-token", token + "\n", 0o600)
|
|
private_write(directory / "watchdog.json", json.dumps(cfg, indent=2) + "\n", 0o600 if args.local else 0o640,
|
|
gid)
|
|
print(f"Local config saved. Observed DNS mode: {mode}. No DNS records were changed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except (APIError, SafetyError, OSError, ValueError) as exc:
|
|
print("Setup stopped: " + str(exc), file=sys.stderr)
|
|
sys.exit(1)
|