Development

This commit is contained in:
Matthew Grotke 2026-05-21 03:45:14 -04:00
parent 21db91d512
commit be7ccd3390
4 changed files with 67 additions and 52 deletions

View file

@ -57,7 +57,7 @@ def _check_ip_conflicts(ip, vlan):
try:
if (ipaddress.IPv4Address(pool_start) <= ipaddress.IPv4Address(ip)
<= ipaddress.IPv4Address(pool_end)):
return f'{ip} falls within the dynamic pool range ({pool_start}{pool_end}).'
return f'{ip} falls within the dynamic pool range ({pool_start}-{pool_end}).'
except Exception:
pass
identity_ips = {s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')}

View file

@ -14,7 +14,7 @@ CONFIGS_DIR = '/configs'
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
# -- Access level --------------------------------------------------------------
# Access level ======================================================
def _client_level():
return LEVEL_RANK.get(session.get('access_level', 'nothing'), 0)
@ -36,7 +36,7 @@ def _passes(req, level):
return False
# -- File loaders --------------------------------------------------------------
# File loaders ======================================================
def _load_json(path):
try:
@ -58,7 +58,7 @@ def _load_css():
return ''
# -- Shell helper --------------------------------------------------------------
# Shell helper ======================================================
def _run(cmd):
try:
@ -166,7 +166,7 @@ def _resolve_iface(vlan, core):
return lan if vid == 1 else f'{lan}.{vid}'
# -- Live data loaders ---------------------------------------------------------
# Live data loaders =================================================
def _live_dhcp_leases():
rows = []
@ -233,7 +233,7 @@ def _fmt_bytes(n):
return f'{n:.1f} TB'
# -- Config data loaders -------------------------------------------------------
# Config data loaders ===============================================
def _config_datasource(name):
core = _load_core()
@ -307,7 +307,7 @@ def _config_datasource(name):
row['credentials'] = f"U: {p.get('username', '-')}"
elif ptype in ('cloudflare', 'duckdns'):
tok = p.get('api_token', '')
row['credentials'] = f'API Token: {tok[:8]}' if tok else '(not set)'
row['credentials'] = f'API Token: {tok[:8]}...' if tok else '(not set)'
else:
row['credentials'] = '-'
row['hostnames'] = json.dumps(p.get('hostnames', p.get('subdomains', [])))
@ -343,7 +343,7 @@ def _config_datasource(name):
return []
# -- Live stat helpers ---------------------------------------------------------
# Live stat helpers =================================================
def _get_dnsmasq_stats():
stats = {'queries': '-', 'hits': '-', 'hit_rate': '-',
@ -492,7 +492,7 @@ def _vpn_info():
return {}
# -- Token collection ----------------------------------------------------------
# Token collection ==================================================
def collect_tokens():
tokens = {}
@ -650,7 +650,7 @@ def collect_tokens():
return tokens
# -- HTML helpers --------------------------------------------------------------
# HTML helpers ======================================================
def e(text):
return html_mod.escape(str(text))
@ -686,7 +686,7 @@ def _expand_fields(obj, tokens):
return obj
# -- Content item renderers ----------------------------------------------------
# Content item renderers ============================================
def render_items(items, tokens, inherited_req=None):
level = _client_level()
@ -1325,7 +1325,7 @@ def _load_datasource(spec):
return []
# -- Layout renderer -----------------------------------------------------------
# Layout renderer ===================================================
def render_layout(view_id, content_html, tokens):
css = _load_css()
@ -1433,7 +1433,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=
return ''
# -- Inline JavaScript ---------------------------------------------------------
# Inline JavaScript =================================================
def _inline_js():
return r"""
@ -1988,7 +1988,7 @@ var validateEl;
range: 'Octet out of range' },
url: { invalid_char: 'Invalid character', invalid_struct: 'Invalid URL format' },
port: { invalid_char: 'Digits only', out_of_range: 'Must be between 1 and 65535' },
ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 032',
ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 0-32',
invalid_range: 'Octet out of range' },
endpoint: { invalid_char: 'Invalid character', invalid_struct: 'Invalid hostname or IP',
invalid_range: 'Octet out of range', invalid: 'Invalid IP address' },
@ -2027,7 +2027,7 @@ var validateEl;
}
};
// Regular fields (not inside editable lists) initial state + expose _triggerValidate
// Regular fields (not inside editable lists) - initial state + expose _triggerValidate
document.querySelectorAll('input[data-validate]').forEach(function(el) {
if (el.closest('.editable-list')) return;
el._triggerValidate = function() { validateEl(el); };
@ -2072,7 +2072,7 @@ var validateEl;
submitBtns.forEach(function(b) { b.disabled = true; });
cancelBtns.forEach(function(b) { b.disabled = true; });
// Only track fields named in original naturally excludes config_hash,
// Only track fields named in original - naturally excludes config_hash,
// row_index, etc., while including hidden inputs (e.g. picker values).
function snapshot() {
var state = {};
@ -2342,7 +2342,7 @@ function startApplyPoller(uuid, bar, mine) {
"""
# -- Routes --------------------------------------------------------------------
# Routes ============================================================
@bp.route('/')
def index():

View file

@ -2556,18 +2556,18 @@ def disable_all(data):
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
print("systemd daemon reloaded.")
print()
print("-- Removing nftables rules -------------------------------------------")
print("Removing nftables rules =============================================")
delete_our_tables()
remove_nat_service()
if radius_enabled(data):
print()
print("-- Stopping RADIUS ---------------------------------------------------")
print("Stopping RADIUS =====================================================")
subprocess.run(["systemctl", "disable", "--now", "freeradius"],
capture_output=True, text=True)
print("freeradius stopped and disabled.")
if avahi_enabled(data):
print()
print("-- Stopping mDNS Reflector -------------------------------------------")
print("Stopping mDNS Reflector =============================================")
disable_avahi()
def _write_client_network(iface, dhcp, static_cidr=None):
@ -2667,7 +2667,7 @@ def _svc_enabled(unit):
return r.stdout.strip() in ("enabled", "enabled-runtime")
def _dry_run_conflicting_services(data):
print("-- Conflicting services (dry-run) ------------------------------------")
print("Conflicting services (dry-run) ======================================")
for unit, label in [("systemd-resolved", "systemd-resolved"),
("systemd-timesyncd", "systemd-timesyncd")]:
@ -2727,7 +2727,7 @@ def _dry_run_conflicting_services(data):
print(f" /etc/resolv.conf already points to {gw} - no change needed")
def _dry_run_blocklists(data):
print("-- Blocklists (dry-run) ----------------------------------------------")
print("Blocklists (dry-run) ================================================")
for entry in data.get("blocklists", []):
print(f" Would download: {entry['description']}")
print(f" URL: {entry['url']}")
@ -2744,7 +2744,7 @@ def _dry_run_blocklists(data):
print(f" Sources: {', '.join(sorted(names))}")
def _dry_run_timer(data):
print("-- Timer (dry-run) ---------------------------------------------------")
print("Timer (dry-run) =====================================================")
general = data.get("general", {})
execute_time = general.get("daily_execute_time_24hr_local", "02:30")
for path, label in [(BLIST_TIMER_FILE, "timer unit"), (BLIST_TIMER_SVC_FILE, "service unit")]:
@ -2753,7 +2753,7 @@ def _dry_run_timer(data):
print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)")
def _dry_run_boot_service():
print("-- Boot service (dry-run) --------------------------------------------")
print("Boot service (dry-run) ==============================================")
script_path = Path(__file__).resolve()
action = "update" if NAT_SERVICE_FILE.exists() else "create and enable"
print(f" Would {action}: {NAT_SERVICE_FILE}")
@ -2791,7 +2791,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print(f" {NAT_SERVICE_NAME}.service: not installed - no action needed")
print()
print("-- Restoring NTP client (dry-run) ------------------------------------")
print("Restoring NTP client (dry-run) ======================================")
state = _svc_state("chrony")
if state == "active":
print(f" Would stop and disable: chrony (currently: active)")
@ -2805,7 +2805,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print(f" systemd-timesyncd: not available on this system")
print()
print("-- Network interface (dry-run) ----------------------------------------")
print("Network interface (dry-run) =========================================")
router_net = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.network"))
router_dev = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.netdev"))
client_file = NETWORKD_DIR / f"10-client-{iface}.network"
@ -2822,7 +2822,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print()
if not resolv_ok:
print("-- DNS (dry-run) -----------------------------------------------------")
print("DNS (dry-run) =======================================================")
if dns_choice == "resolved":
print(" Would enable: systemd-resolved")
print(" Would restore: /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf")
@ -2998,16 +2998,16 @@ def cmd_disable(data, dry_run=False):
disable_all(data)
print()
print("-- Restoring NTP client ----------------------------------------------")
print("Restoring NTP client ================================================")
restore_ntp()
print()
print("-- Configuring network interface -------------------------------------")
print("Configuring network interface =======================================")
_write_client_network(iface, dhcp=use_dhcp, static_cidr=static_cidr)
print()
if not resolv_ok:
print("-- Configuring DNS ---------------------------------------------------")
print("Configuring DNS =====================================================")
if dns_choice == "static":
_configure_dns_static(static_nameserver)
else:
@ -3045,13 +3045,13 @@ def cmd_apply(data, dry_run=False):
print()
_dry_run_conflicting_services(data)
print()
print("-- systemd-networkd (dry-run) ----------------------------------------")
print("systemd-networkd (dry-run) ==========================================")
apply_networkd(data, dry_run=True)
print()
print("-- dnsmasq instances (dry-run) ---------------------------------------")
print("dnsmasq instances (dry-run) =========================================")
apply_dnsmasq_instances(data, dry_run=True, start_if_needed=True)
print()
print("-- nftables (dry-run) ------------------------------------------------")
print("nftables (dry-run) ==================================================")
apply_nftables(data, dry_run=True)
print()
_dry_run_timer(data)
@ -3059,7 +3059,7 @@ def cmd_apply(data, dry_run=False):
_dry_run_boot_service()
if radius_enabled(data):
print()
print("-- RADIUS (dry-run) --------------------------------------------------")
print("RADIUS (dry-run) ====================================================")
num_clients = len(radius_clients(data))
default_vlan = next((v for v in data["vlans"] if v.get("radius_default") is True), None)
total_macs = sum(
@ -3075,7 +3075,7 @@ def cmd_apply(data, dry_run=False):
print(f" Would ensure freeradius is running")
if avahi_enabled(data):
print()
print("-- mDNS Reflection (dry-run) -----------------------------------------")
print("mDNS Reflection (dry-run) ===========================================")
ifaces = avahi_interfaces(data)
print(f" Would write: {AVAHI_CONF_FILE}")
print(f" Reflecting across: {', '.join(ifaces)}")
@ -3098,67 +3098,67 @@ def cmd_apply(data, dry_run=False):
f"{total_enabled} reservation(s), {total_disabled} skipped{wg_part}.")
print()
print("-- Conflicting services ----------------------------------------------")
print("Conflicting services ================================================")
disable_systemd_timesyncd()
ensure_chrony(data)
disable_ufw()
print()
print("-- systemd-networkd --------------------------------------------------")
print("systemd-networkd ====================================================")
apply_networkd(data, only_if_changed=True)
print()
if any(is_wg(v) for v in data["vlans"]):
print("-- WireGuard interfaces ----------------------------------------------")
print("WireGuard interfaces ================================================")
ensure_wg_interfaces(data)
print()
print("-- dnsmasq instances -------------------------------------------------")
print("dnsmasq instances ===================================================")
if not blocklists_available(data):
print(" NOTE: No merged blocklist files found -- blocklist rules will be absent.")
print(" Run --update-blocklists to download and merge blocklists.")
apply_dnsmasq_instances(data, start_if_needed=True)
print()
print("-- nftables ----------------------------------------------------------")
print("nftables ============================================================")
apply_nftables(data)
print()
print("-- Timer -------------------------------------------------------------")
print("Timer ===============================================================")
install_timer(data)
print()
print("-- Dashboard timer ---------------------------------------------------")
print("Dashboard timer =====================================================")
install_dashboard_timer()
print()
print("-- Boot service ------------------------------------------------------")
print("Boot service ========================================================")
install_nat_service()
print()
if radius_enabled(data):
print("-- RADIUS ------------------------------------------------------------")
print("RADIUS ==============================================================")
apply_radius(data)
print()
else:
svc = "freeradius"
if subprocess.run(["systemctl", "is-active", svc],
capture_output=True, text=True).stdout.strip() == "active":
print("-- RADIUS ------------------------------------------------------------")
print("RADIUS ==============================================================")
subprocess.run(["systemctl", "disable", "--now", svc],
capture_output=True, text=True)
print("freeradius stopped and disabled (no radius_client reservations).")
print()
if avahi_enabled(data):
print("-- mDNS Reflection ---------------------------------------------------")
print("mDNS Reflection =====================================================")
apply_avahi(data)
print()
else:
svc = "avahi-daemon"
if subprocess.run(["systemctl", "is-active", svc],
capture_output=True, text=True).stdout.strip() == "active":
print("-- mDNS Reflection ---------------------------------------------------")
print("mDNS Reflection =====================================================")
disable_avahi()
print()
@ -3170,11 +3170,11 @@ def cmd_update_blocklists(data):
cmd_apply to reload dnsmasq instances with the new blocklists.
"""
check_root()
print("-- Updating blocklists -----------------------------------------------")
print("Updating blocklists =================================================")
success = update_blocklists(data)
print()
if success:
print("-- Applying updated configs ------------------------------------------")
print("Applying updated configs ============================================")
cmd_apply(data)
else:
print("WARNING: Blocklist update had errors -- skipping --apply.")

View file

@ -228,7 +228,7 @@ def _set_env_var(content, key, value):
replacement = rf"\g<1>{value}"
new, count = re.subn(pattern, replacement, content, flags=re.MULTILINE)
if count == 0:
# Key not found shouldn't happen with our template, warn and move on
# Key not found; shouldn't happen with our template, warn and move on
print(f" WARNING: could not find {key} in docker-compose.yml")
return new
@ -242,6 +242,21 @@ def setup_docker_compose():
content = COMPOSE_FILE.read_text()
existing_key = re.search(r"^\s*- SECRET_KEY=(.+)$", content, re.MULTILINE)
if existing_key and existing_key.group(1).strip():
print(" Dashboard is already configured.")
if not prompt_yn("Reconfigure? (generates a new SECRET_KEY, invalidates existing sessions)", default="n"):
print()
print(" Starting dashboard container...")
result = subprocess.run(
["docker", "compose", "up", "-d", "--build"],
cwd=COMPOSE_FILE.parent, check=False
)
if result.returncode != 0:
die("docker compose up failed. Check the output above.")
print(" Dashboard container started.")
return
print(" Generating SECRET_KEY...")
secret_key = secrets.token_urlsafe(96) # ~128 chars
@ -363,8 +378,8 @@ def setup_caddy(domain, email):
if CADDYFILE.exists():
existing = CADDYFILE.read_text()
if domain in existing:
print(f" {domain} is already present in {CADDYFILE} — skipping block.")
if f"127.0.0.1:{FLASK_PORT}" in existing:
print(f" Routlin block already present in {CADDYFILE}, skipping.")
else:
CADDYFILE.write_text(existing + block)
print(f" Appended {domain} block to {CADDYFILE}")