Development
This commit is contained in:
parent
9f1b4a9119
commit
8eec61a1df
8 changed files with 697 additions and 56 deletions
|
|
@ -48,9 +48,18 @@
|
||||||
"label": "VLAN",
|
"label": "VLAN",
|
||||||
"field": "vlan_name"
|
"field": "vlan_name"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "Obtained",
|
||||||
|
"field": "obtained"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Expires",
|
"label": "Expires",
|
||||||
"field": "expires"
|
"field": "expires"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Recent",
|
||||||
|
"field": "recent",
|
||||||
|
"render": "badge_yes_no"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,12 @@ def options_save():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
before = copy.deepcopy(cfg.get('free_radius', {}).get('options', {}))
|
before = copy.deepcopy(cfg.get('radius', {}).get('options', {}))
|
||||||
after = {'mac_format': mac_format, 'apply_to': apply_to}
|
after = {'mac_format': mac_format, 'apply_to': apply_to}
|
||||||
cfg.setdefault('free_radius', {})['options'] = after
|
cfg.setdefault('radius', {})['options'] = after
|
||||||
|
|
||||||
changes = diff_fields(before, after)
|
changes = diff_fields(before, after)
|
||||||
flash(record_group(cfg, 'free_radius.options', 'setting', 'free_radius', changes, 'core apply'), 'success')
|
flash(record_group(cfg, 'radius.options', 'setting', 'radius', changes, 'core apply'), 'success')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,12 +64,12 @@ def logging_save():
|
||||||
logging = 'logging' in request.form
|
logging = 'logging' in request.form
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
before = copy.deepcopy(cfg.get('free_radius', {}).get('general', {}))
|
before = copy.deepcopy(cfg.get('radius', {}).get('general', {}))
|
||||||
after = {'logging': logging, 'log_max_kb': log_max_kb}
|
after = {'logging': logging, 'log_max_kb': log_max_kb}
|
||||||
cfg.setdefault('free_radius', {})['general'] = after
|
cfg.setdefault('radius', {})['general'] = after
|
||||||
|
|
||||||
changes = diff_fields(before, after)
|
changes = diff_fields(before, after)
|
||||||
flash(record_group(cfg, 'free_radius.general', 'setting', 'free_radius', changes, 'core apply'), 'success')
|
flash(record_group(cfg, 'radius.general', 'setting', 'radius', changes, 'core apply'), 'success')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ def logging_download():
|
||||||
def api_log_tail():
|
def api_log_tail():
|
||||||
try:
|
try:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
log_max_kb = cfg.get('free_radius', {}).get('general', {}).get('log_max_kb', 1024)
|
log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024)
|
||||||
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
|
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
|
||||||
with open(RADIUS_LOG_FILE) as f:
|
with open(RADIUS_LOG_FILE) as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,22 @@
|
||||||
"type": "raw_html",
|
"type": "raw_html",
|
||||||
"html": "%RADIUS_LOG_SUMMARY%"
|
"html": "%RADIUS_LOG_SUMMARY%"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "button_row",
|
||||||
|
"justify": "space-between",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "button_ghost",
|
||||||
|
"action": "/action/radius/logging_download",
|
||||||
|
"text": "Download Log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button_danger",
|
||||||
|
"formaction": "/action/radius/logging_clear",
|
||||||
|
"text": "Clear Log"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "hr"
|
"type": "hr"
|
||||||
},
|
},
|
||||||
|
|
@ -155,23 +171,6 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "button_row",
|
|
||||||
"justify": "space-between",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "button_ghost",
|
|
||||||
"action": "/action/radius/logging_download",
|
|
||||||
"text": "Download Log"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "button_danger",
|
|
||||||
"action": "/action/radius/logging_clear",
|
|
||||||
"method": "post",
|
|
||||||
"text": "Clear Log"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,21 +151,64 @@ def resolve_iface(vlan, cfg):
|
||||||
|
|
||||||
# Live data loaders =================================================
|
# Live data loaders =================================================
|
||||||
|
|
||||||
|
def _parse_lease_secs(s):
|
||||||
|
s = str(s).strip().lower()
|
||||||
|
try:
|
||||||
|
if s.endswith('h'): return int(s[:-1]) * 3600
|
||||||
|
if s.endswith('m'): return int(s[:-1]) * 60
|
||||||
|
if s.endswith('d'): return int(s[:-1]) * 86400
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _dnsmasq_start_time(vlan_name):
|
||||||
|
"""Return epoch timestamp when the dnsmasq instance for this VLAN last started."""
|
||||||
|
try:
|
||||||
|
pid = int(open(f'/run/dnsmasq-routlin-{vlan_name}.pid').read().strip())
|
||||||
|
start_ticks = int(open(f'/proc/{pid}/stat').read().split()[21])
|
||||||
|
clk_tck = os.sysconf('SC_CLK_TCK')
|
||||||
|
boot_time = next(
|
||||||
|
int(line.split()[1]) for line in open('/proc/stat') if line.startswith('btime ')
|
||||||
|
)
|
||||||
|
return boot_time + start_ticks / clk_tck
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def live_dhcp_leases():
|
def live_dhcp_leases():
|
||||||
rows = []
|
rows = []
|
||||||
for leases_file in glob.glob('/var/lib/misc/*.leases'):
|
now = int(datetime.now(tz=timezone.utc).timestamp())
|
||||||
|
vlans = load_config().get('vlans', [])
|
||||||
|
vlan_lease_secs = {
|
||||||
|
v['name']: _parse_lease_secs(v.get('dhcp', {}).get('lease_time', ''))
|
||||||
|
for v in vlans if v.get('name')
|
||||||
|
}
|
||||||
|
for leases_file in glob.glob('/var/lib/misc/dnsmasq-routlin-*.leases'):
|
||||||
|
stem = os.path.basename(leases_file)
|
||||||
|
vlan_name = stem[len('dnsmasq-routlin-'):-len('.leases')]
|
||||||
|
lease_secs = vlan_lease_secs.get(vlan_name)
|
||||||
|
restart_time = _dnsmasq_start_time(vlan_name)
|
||||||
try:
|
try:
|
||||||
with open(leases_file) as f:
|
with open(leases_file) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.strip().split()
|
parts = line.strip().split()
|
||||||
if len(parts) >= 4:
|
if len(parts) < 4:
|
||||||
rows.append({
|
continue
|
||||||
'hostname': parts[3] if parts[3] != '*' else '-',
|
expiry = int(parts[0])
|
||||||
'ip_address': parts[2],
|
if expiry < now:
|
||||||
'mac_address': parts[1],
|
continue
|
||||||
'vlan_name': _vlan_name_for_ip(parts[2]),
|
obtained_ts = (expiry - lease_secs) if lease_secs else None
|
||||||
'expires': fmt_timestamp(int(parts[0])),
|
obtained = relative_time(obtained_ts) if obtained_ts else '-'
|
||||||
})
|
recent = (obtained_ts is not None and restart_time is not None
|
||||||
|
and obtained_ts >= restart_time)
|
||||||
|
rows.append({
|
||||||
|
'hostname': parts[3] if parts[3] != '*' else '-',
|
||||||
|
'ip_address': parts[2],
|
||||||
|
'mac_address': parts[1],
|
||||||
|
'vlan_name': vlan_name,
|
||||||
|
'obtained': obtained,
|
||||||
|
'expires': relative_time_future(expiry),
|
||||||
|
'recent': recent,
|
||||||
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return rows
|
return rows
|
||||||
|
|
@ -210,6 +253,24 @@ def relative_time(ts):
|
||||||
except Exception:
|
except Exception:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def relative_time_future(ts):
|
||||||
|
try:
|
||||||
|
diff = int(ts) - int(datetime.now(tz=timezone.utc).timestamp())
|
||||||
|
if diff <= 0:
|
||||||
|
return 'expired'
|
||||||
|
if diff < 60:
|
||||||
|
return f'in {diff} second{"s" if diff != 1 else ""}'
|
||||||
|
m = diff // 60
|
||||||
|
if m < 60:
|
||||||
|
return f'in {m} minute{"s" if m != 1 else ""}'
|
||||||
|
h, rem_m = divmod(m, 60)
|
||||||
|
if h < 24:
|
||||||
|
return f'in {h}h {rem_m}m' if rem_m else f'in {h} hour{"s" if h != 1 else ""}'
|
||||||
|
d = h // 24
|
||||||
|
return f'in {d} day{"s" if d != 1 else ""}'
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
def live_vpn_sessions():
|
def live_vpn_sessions():
|
||||||
rows = []
|
rows = []
|
||||||
out = run('wg show all dump 2>/dev/null')
|
out = run('wg show all dump 2>/dev/null')
|
||||||
|
|
@ -509,7 +570,7 @@ RADIUS_LOG_FILE = '/var/log/freeradius/radius.log'
|
||||||
def _radius_log_tail():
|
def _radius_log_tail():
|
||||||
try:
|
try:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
log_max_kb = cfg.get('free_radius', {}).get('general', {}).get('log_max_kb', 1024)
|
log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024)
|
||||||
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
|
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
|
||||||
with open(RADIUS_LOG_FILE) as f:
|
with open(RADIUS_LOG_FILE) as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
|
|
@ -848,7 +909,7 @@ def collect_tokens():
|
||||||
tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip()
|
tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
tokens['RADIUS_SECRET'] = '(Generation is pending - visit Actions to apply generation command)'
|
tokens['RADIUS_SECRET'] = '(Generation is pending - visit Actions to apply generation command)'
|
||||||
_fr = cfg.get('free_radius', {})
|
_fr = cfg.get('radius', {})
|
||||||
_fr_opts = _fr.get('options', {})
|
_fr_opts = _fr.get('options', {})
|
||||||
_fr_gen = _fr.get('general', {})
|
_fr_gen = _fr.get('general', {})
|
||||||
tokens['RADIUS_MAC_FORMAT'] = _fr_opts.get('mac_format', 'aabbccddeeff')
|
tokens['RADIUS_MAC_FORMAT'] = _fr_opts.get('mac_format', 'aabbccddeeff')
|
||||||
|
|
|
||||||
|
|
@ -829,7 +829,7 @@
|
||||||
"vlan": "vpn"
|
"vlan": "vpn"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"free_radius": {
|
"radius": {
|
||||||
"general": {
|
"general": {
|
||||||
"logging": false,
|
"logging": false,
|
||||||
"log_max_kb": 1024
|
"log_max_kb": 1024
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,9 @@ SYSTEMD_DIR = Path("/etc/systemd/system")
|
||||||
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
|
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
|
||||||
BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
|
BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
|
||||||
BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"
|
BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"
|
||||||
DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update"
|
MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance"
|
||||||
DDNS_TIMER_FILE = SYSTEMD_DIR / f"{DDNS_TIMER_NAME}.timer"
|
MAINT_TIMER_FILE = SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.timer"
|
||||||
DDNS_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DDNS_TIMER_NAME}.service"
|
MAINT_TIMER_SVC_FILE = SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.service"
|
||||||
HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check"
|
HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check"
|
||||||
HEALTH_TIMER_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer"
|
HEALTH_TIMER_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer"
|
||||||
HEALTH_TIMER_SVC_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service"
|
HEALTH_TIMER_SVC_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service"
|
||||||
|
|
@ -1143,10 +1143,10 @@ def _parse_ddns_interval(interval_str):
|
||||||
raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.")
|
raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.")
|
||||||
|
|
||||||
|
|
||||||
def install_ddns_timer(data):
|
def install_maint_timer(data):
|
||||||
ddns = data.get("ddns", {})
|
ddns = data.get("ddns", {})
|
||||||
interval = ddns.get("general", {}).get("timer_interval", "10m")
|
interval = ddns.get("general", {}).get("timer_interval", "10m")
|
||||||
script_path = SCRIPT_DIR / "ddns.py"
|
script_path = SCRIPT_DIR / "maintenance.py"
|
||||||
try:
|
try:
|
||||||
systemd_unit = _parse_ddns_interval(interval)
|
systemd_unit = _parse_ddns_interval(interval)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -1182,19 +1182,19 @@ def install_ddns_timer(data):
|
||||||
"WantedBy=timers.target",
|
"WantedBy=timers.target",
|
||||||
"",
|
"",
|
||||||
])
|
])
|
||||||
for path, content in ((DDNS_TIMER_SVC_FILE, service_content), (DDNS_TIMER_FILE, timer_content)):
|
for path, content in ((MAINT_TIMER_SVC_FILE, service_content), (MAINT_TIMER_FILE, timer_content)):
|
||||||
if not path.exists() or path.read_text() != content:
|
if not path.exists() or path.read_text() != content:
|
||||||
path.write_text(content)
|
path.write_text(content)
|
||||||
print(f"Written: {path}")
|
print(f"Written: {path}")
|
||||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||||
active = subprocess.run(
|
active = subprocess.run(
|
||||||
["systemctl", "is-active", f"{DDNS_TIMER_NAME}.timer"],
|
["systemctl", "is-active", f"{MAINT_TIMER_NAME}.timer"],
|
||||||
capture_output=True, text=True
|
capture_output=True, text=True
|
||||||
).stdout.strip() == "active"
|
).stdout.strip() == "active"
|
||||||
verb = "restart" if active else "enable --now"
|
verb = "restart" if active else "enable --now"
|
||||||
subprocess.run(["systemctl"] + verb.split() + [f"{DDNS_TIMER_NAME}.timer"],
|
subprocess.run(["systemctl"] + verb.split() + [f"{MAINT_TIMER_NAME}.timer"],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
print(f"Timer {DDNS_TIMER_NAME}.timer enabled (runs every {interval}).")
|
print(f"Timer {MAINT_TIMER_NAME}.timer enabled (runs every {interval}).")
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# banned_ips expansion
|
# banned_ips expansion
|
||||||
|
|
@ -1898,7 +1898,7 @@ def build_radius_users(data):
|
||||||
Generate freeradius users file.
|
Generate freeradius users file.
|
||||||
Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID.
|
Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID.
|
||||||
Unknown MACs fall through to DEFAULT which returns the radius_default VLAN.
|
Unknown MACs fall through to DEFAULT which returns the radius_default VLAN.
|
||||||
MAC format and DEFAULT rule scope are read from free_radius.options in config.
|
MAC format and DEFAULT rule scope are read from radius.options in config.
|
||||||
"""
|
"""
|
||||||
default_vlan = next(
|
default_vlan = next(
|
||||||
(v for v in data["vlans"] if v.get("radius_default") is True), None
|
(v for v in data["vlans"] if v.get("radius_default") is True), None
|
||||||
|
|
@ -1906,7 +1906,7 @@ def build_radius_users(data):
|
||||||
if default_vlan is None:
|
if default_vlan is None:
|
||||||
die("No VLAN has radius_default: true. Cannot generate RADIUS users file.")
|
die("No VLAN has radius_default: true. Cannot generate RADIUS users file.")
|
||||||
|
|
||||||
fr_opts = data.get('free_radius', {}).get('options', {})
|
fr_opts = data.get('radius', {}).get('options', {})
|
||||||
mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff')
|
mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff')
|
||||||
apply_to = fr_opts.get('apply_to', 'all')
|
apply_to = fr_opts.get('apply_to', 'all')
|
||||||
|
|
||||||
|
|
@ -1979,7 +1979,7 @@ def apply_radius(data):
|
||||||
clients_content = build_radius_clients_conf(data, secret)
|
clients_content = build_radius_clients_conf(data, secret)
|
||||||
users_content = build_radius_users(data)
|
users_content = build_radius_users(data)
|
||||||
|
|
||||||
logging = data.get('free_radius', {}).get('general', {}).get('logging', False)
|
logging = data.get('radius', {}).get('general', {}).get('logging', False)
|
||||||
|
|
||||||
changed = _set_freeradius_log(logging)
|
changed = _set_freeradius_log(logging)
|
||||||
for path, content in [(RADIUS_CLIENTS_CONF, clients_content),
|
for path, content in [(RADIUS_CLIENTS_CONF, clients_content),
|
||||||
|
|
@ -2449,9 +2449,9 @@ def show_metrics(data):
|
||||||
def stop_instances(data):
|
def stop_instances(data):
|
||||||
"""Remove timers and stop all per-VLAN instances (config files preserved)."""
|
"""Remove timers and stop all per-VLAN instances (config files preserved)."""
|
||||||
_remove_timers(
|
_remove_timers(
|
||||||
names=[BLIST_TIMER_NAME, HEALTH_TIMER_NAME, DDNS_TIMER_NAME],
|
names=[BLIST_TIMER_NAME, HEALTH_TIMER_NAME, MAINT_TIMER_NAME],
|
||||||
timer_files=[BLIST_TIMER_FILE, HEALTH_TIMER_FILE, DDNS_TIMER_FILE],
|
timer_files=[BLIST_TIMER_FILE, HEALTH_TIMER_FILE, MAINT_TIMER_FILE],
|
||||||
svc_files=[BLIST_TIMER_SVC_FILE, HEALTH_TIMER_SVC_FILE, DDNS_TIMER_SVC_FILE],
|
svc_files=[BLIST_TIMER_SVC_FILE, HEALTH_TIMER_SVC_FILE, MAINT_TIMER_SVC_FILE],
|
||||||
daemon_reload=True,
|
daemon_reload=True,
|
||||||
)
|
)
|
||||||
print()
|
print()
|
||||||
|
|
@ -3051,9 +3051,9 @@ def cmd_apply(data, dry_run=False):
|
||||||
print("DDNS timer ==========================================================")
|
print("DDNS timer ==========================================================")
|
||||||
enabled_ddns = [p for p in data.get("ddns", {}).get("providers", []) if p.get("enabled")]
|
enabled_ddns = [p for p in data.get("ddns", {}).get("providers", []) if p.get("enabled")]
|
||||||
if enabled_ddns:
|
if enabled_ddns:
|
||||||
install_ddns_timer(data)
|
install_maint_timer(data)
|
||||||
else:
|
else:
|
||||||
_remove_timers([DDNS_TIMER_NAME], [DDNS_TIMER_FILE], [DDNS_TIMER_SVC_FILE])
|
_remove_timers([MAINT_TIMER_NAME], [MAINT_TIMER_FILE], [MAINT_TIMER_SVC_FILE])
|
||||||
print("No enabled DDNS providers — timer not installed.")
|
print("No enabled DDNS providers — timer not installed.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
|
||||||
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
|
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
|
||||||
DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
|
DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
|
||||||
HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check"
|
HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check"
|
||||||
DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update"
|
MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance"
|
||||||
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
|
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
|
||||||
NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat"
|
NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat"
|
||||||
BLOCKLIST_STALE_SECS = 36 * 3600
|
BLOCKLIST_STALE_SECS = 36 * 3600
|
||||||
|
|
@ -179,8 +179,8 @@ def check_services(data):
|
||||||
has_ddns = any(p.get("enabled") for p in data.get("ddns", {}).get("providers", []))
|
has_ddns = any(p.get("enabled") for p in data.get("ddns", {}).get("providers", []))
|
||||||
exp_ddns_active = "active" if has_ddns else "inactive"
|
exp_ddns_active = "active" if has_ddns else "inactive"
|
||||||
exp_ddns_enabled = "enabled" if has_ddns else "not-found"
|
exp_ddns_enabled = "enabled" if has_ddns else "not-found"
|
||||||
units.append({"id": f"{DDNS_TIMER_NAME}.timer",
|
units.append({"id": f"{MAINT_TIMER_NAME}.timer",
|
||||||
"name": f"{DDNS_TIMER_NAME}.timer",
|
"name": f"{MAINT_TIMER_NAME}.timer",
|
||||||
"expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled,
|
"expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled,
|
||||||
"severity": "warning"})
|
"severity": "warning"})
|
||||||
|
|
||||||
|
|
|
||||||
572
routlin/maintenance.py
Normal file
572
routlin/maintenance.py
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
maintenance.py -- Periodic maintenance tasks run by the routlin-maintenance systemd timer.
|
||||||
|
|
||||||
|
Tasks performed on each run:
|
||||||
|
1. DDNS: fetch current public IP and update enabled provider(s) if changed.
|
||||||
|
2. FreeRADIUS log rotation: truncate radius.log if it exceeds radius.general.log_max_kb.
|
||||||
|
|
||||||
|
Reads config.json in the same directory. Designed to be invoked by core.py --apply
|
||||||
|
via the routlin-maintenance.timer systemd timer.
|
||||||
|
|
||||||
|
IP check services are rotated each run using .ddns-last-service so
|
||||||
|
no single provider is spammed. If the selected service fails, the
|
||||||
|
script falls back through the remaining services in order.
|
||||||
|
|
||||||
|
Per-provider cache files are named .ddns-last-ip-<description>.
|
||||||
|
DDNS activity is logged to ddns.log in the same directory as this script.
|
||||||
|
DDNS log is cleared when it exceeds ddns.general.log_max_kb from config.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 maintenance.py --update Run all tasks once (used by timer)
|
||||||
|
python3 maintenance.py --force Force DDNS update regardless of cached IP
|
||||||
|
python3 maintenance.py --getip Print current public IP and exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
CONFIG_FILE = SCRIPT_DIR / "config.json"
|
||||||
|
CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service"
|
||||||
|
LOG_FILE = SCRIPT_DIR / "ddns.log"
|
||||||
|
RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log")
|
||||||
|
|
||||||
|
# log is assigned in setup_logging() after config is loaded
|
||||||
|
log = None
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Load config
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with open(CONFIG_FILE) as f:
|
||||||
|
full = json.load(f)
|
||||||
|
data = full.get("ddns", {})
|
||||||
|
|
||||||
|
# Validate general block
|
||||||
|
required_general = {"log_max_kb", "log_errors_only"}
|
||||||
|
missing = required_general - set(data.get("general", {}).keys())
|
||||||
|
if missing:
|
||||||
|
print(f"ERROR: Missing keys in ddns.general block: {missing}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
services = data.get("ip_check_services", [])
|
||||||
|
if not services:
|
||||||
|
print("ERROR: ddns.general.ip_check_services is empty.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
for svc in services:
|
||||||
|
if not isinstance(svc, dict) or "type" not in svc:
|
||||||
|
print(f"ERROR: ip_check_services entry missing 'type': {svc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if svc["type"] == "http" and "url" not in svc:
|
||||||
|
print(f"ERROR: ip_check_services 'http' entry missing 'url': {svc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if svc["type"] == "dig" and "url" not in svc:
|
||||||
|
print(f"ERROR: ip_check_services 'dig' entry missing 'url': {svc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Validate providers block
|
||||||
|
if not data.get("providers"):
|
||||||
|
print("ERROR: No DDNS providers defined in config.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
for p in data["providers"]:
|
||||||
|
base_required = {"description", "provider", "enabled"}
|
||||||
|
missing = base_required - set(p.keys())
|
||||||
|
if missing:
|
||||||
|
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
ptype = p.get("provider", "").lower()
|
||||||
|
if ptype == "noip":
|
||||||
|
extra = {"username", "password", "hostnames"}
|
||||||
|
elif ptype == "duckdns":
|
||||||
|
extra = {"api_token", "hostnames"}
|
||||||
|
elif ptype == "cloudflare":
|
||||||
|
extra = {"api_token", "hostnames"}
|
||||||
|
else:
|
||||||
|
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
missing = extra - set(p.keys())
|
||||||
|
if missing:
|
||||||
|
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
data['_radius'] = full.get("radius", {})
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Helpers
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def chown_to_script_dir_owner(path):
|
||||||
|
"""Chown a file to the owner of the script directory.
|
||||||
|
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
|
||||||
|
or as a normal user - the script directory owner is always the right target.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stat = SCRIPT_DIR.stat()
|
||||||
|
os.chown(path, stat.st_uid, stat.st_gid)
|
||||||
|
except OSError:
|
||||||
|
pass # non-fatal
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Logging
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def setup_logging(max_kb, errors_only):
|
||||||
|
"""Clear log if oversized, then initialise logger. Must be called before log is used."""
|
||||||
|
global log
|
||||||
|
max_bytes = int(max_kb * 1024)
|
||||||
|
try:
|
||||||
|
if LOG_FILE.exists() and LOG_FILE.stat().st_size > max_bytes:
|
||||||
|
LOG_FILE.write_text("")
|
||||||
|
if not LOG_FILE.exists():
|
||||||
|
LOG_FILE.touch()
|
||||||
|
chown_to_script_dir_owner(LOG_FILE)
|
||||||
|
file_handler = logging.FileHandler(LOG_FILE)
|
||||||
|
except PermissionError:
|
||||||
|
print(f"WARNING: Cannot write to {LOG_FILE} (permission denied). "
|
||||||
|
f"Run with sudo or fix ownership: sudo chown $USER {LOG_FILE}")
|
||||||
|
file_handler = None
|
||||||
|
level = logging.ERROR if errors_only else logging.INFO
|
||||||
|
handlers = [logging.StreamHandler(sys.stdout)]
|
||||||
|
if file_handler:
|
||||||
|
handlers.insert(0, file_handler)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
handlers=handlers,
|
||||||
|
)
|
||||||
|
log = logging.getLogger("ddns")
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Per-provider IP cache
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def cache_file_for(description):
|
||||||
|
"""Return the cache file path for a given provider description."""
|
||||||
|
safe_name = description.replace(" ", "-")
|
||||||
|
return SCRIPT_DIR / f".ddns-last-ip-{safe_name}"
|
||||||
|
|
||||||
|
def get_cached_ip(description):
|
||||||
|
f = cache_file_for(description)
|
||||||
|
if f.exists():
|
||||||
|
return f.read_text().strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_cached_ip(description, ip):
|
||||||
|
f = cache_file_for(description)
|
||||||
|
f.write_text(ip)
|
||||||
|
chown_to_script_dir_owner(f)
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Service rotation
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def get_next_service_index(total):
|
||||||
|
"""Read last used index, increment, wrap around, return next index."""
|
||||||
|
if CACHE_SERVICE_FILE.exists():
|
||||||
|
try:
|
||||||
|
last = int(CACHE_SERVICE_FILE.read_text().strip())
|
||||||
|
except ValueError:
|
||||||
|
last = -1
|
||||||
|
else:
|
||||||
|
last = -1
|
||||||
|
return (last + 1) % total
|
||||||
|
|
||||||
|
def save_service_index(index):
|
||||||
|
CACHE_SERVICE_FILE.write_text(str(index))
|
||||||
|
chown_to_script_dir_owner(CACHE_SERVICE_FILE)
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Public IP detection
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def _extract_ip(body):
|
||||||
|
"""Extract an IPv4 address from an HTTP response body.
|
||||||
|
Handles plain text, key=value (e.g. Cloudflare /cdn-cgi/trace), and HTML.
|
||||||
|
"""
|
||||||
|
for line in body.splitlines():
|
||||||
|
if line.startswith("ip="):
|
||||||
|
candidate = line[3:].strip()
|
||||||
|
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', candidate):
|
||||||
|
return candidate
|
||||||
|
plain = body.strip()
|
||||||
|
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', plain):
|
||||||
|
return plain
|
||||||
|
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', body)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ip_via_http(spec):
|
||||||
|
"""Fetch public IP from an HTTP endpoint. spec: {"type": "http", "url": "..."}"""
|
||||||
|
req = urllib.request.Request(spec["url"], headers={"User-Agent": "ddns-update/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return _extract_ip(r.read().decode().strip())
|
||||||
|
|
||||||
|
|
||||||
|
_SAFE_DIG_RE = re.compile(r'^[a-zA-Z0-9.\-_@+:\s]+$')
|
||||||
|
|
||||||
|
def _get_ip_via_dig(spec):
|
||||||
|
"""Query public IP via dig. spec: {"type": "dig", "url": "<dig args>"}
|
||||||
|
Requires the 'dig' utility to be installed.
|
||||||
|
"""
|
||||||
|
url = spec["url"]
|
||||||
|
if not _SAFE_DIG_RE.match(url):
|
||||||
|
log.warning(f"Skipping dig service with disallowed characters: {url!r}")
|
||||||
|
return None
|
||||||
|
cmd = ["dig", "+short"] + url.split()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
match = re.search(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b', result.stdout)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.warning("'dig' not found; cannot use dig IP check service.")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def get_public_ip(services):
|
||||||
|
"""
|
||||||
|
Start at the next service in rotation. If it fails, fall through
|
||||||
|
the remaining services in order. Saves the index of the service
|
||||||
|
that succeeded so the next run starts with the following one.
|
||||||
|
"""
|
||||||
|
total = len(services)
|
||||||
|
start = get_next_service_index(total)
|
||||||
|
ordered = [services[(start + i) % total] for i in range(total)]
|
||||||
|
|
||||||
|
for i, spec in enumerate(ordered):
|
||||||
|
stype = spec.get("type", "http")
|
||||||
|
label = spec.get("url", "?")
|
||||||
|
try:
|
||||||
|
if stype == "dig":
|
||||||
|
ip = _get_ip_via_dig(spec)
|
||||||
|
else:
|
||||||
|
ip = _get_ip_via_http(spec)
|
||||||
|
if ip:
|
||||||
|
save_service_index((start + i) % total)
|
||||||
|
log.info(f"Public IP retrieved from {label}: {ip}")
|
||||||
|
return ip
|
||||||
|
except Exception as ex:
|
||||||
|
log.warning(f"IP check failed for {label}: {ex}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.error("Could not determine public IP from any configured service.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# No-IP update
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def update_noip(provider, ip):
|
||||||
|
"""
|
||||||
|
No-IP HTTP update API.
|
||||||
|
Docs: https://www.noip.com/integrate/request
|
||||||
|
Uses HTTP Basic Auth. Supports comma-separated list of hostnames.
|
||||||
|
"""
|
||||||
|
username = provider["username"]
|
||||||
|
password = provider["password"]
|
||||||
|
hostnames = ",".join(provider["hostnames"])
|
||||||
|
|
||||||
|
url = f"https://dynupdate.no-ip.com/nic/update?hostname={hostnames}&myip={ip}"
|
||||||
|
|
||||||
|
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
||||||
|
password_mgr.add_password(None, url, username, password)
|
||||||
|
handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
|
||||||
|
opener = urllib.request.build_opener(handler)
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=10) as r:
|
||||||
|
return r.read().decode().strip()
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
log.error(f"Network error contacting No-IP: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def interpret_noip_response(response, hostnames, ip):
|
||||||
|
"""
|
||||||
|
No-IP response codes:
|
||||||
|
good <ip> -- update successful
|
||||||
|
nochg <ip> -- IP already set to this value (no change needed)
|
||||||
|
nohost -- hostname not found in account
|
||||||
|
badauth -- invalid credentials
|
||||||
|
badagent -- client blocked
|
||||||
|
!donator -- feature requires paid account
|
||||||
|
abuse -- account blocked for abuse
|
||||||
|
911 -- server-side error, retry later
|
||||||
|
"""
|
||||||
|
if response is None:
|
||||||
|
return False
|
||||||
|
if response.startswith("good"):
|
||||||
|
log.info(f"No-IP updated successfully: {hostnames} -> {ip}")
|
||||||
|
return True
|
||||||
|
elif response.startswith("nochg"):
|
||||||
|
log.info(f"No-IP: no change needed ({hostnames} already set to {ip})")
|
||||||
|
return True
|
||||||
|
elif response == "nohost":
|
||||||
|
log.error(f"No-IP: hostname '{hostnames}' not found in account.")
|
||||||
|
elif response == "badauth":
|
||||||
|
log.error(f"No-IP: authentication failed for '{hostnames}'. Check username and password.")
|
||||||
|
elif response == "badagent":
|
||||||
|
log.error("No-IP: client blocked by No-IP.")
|
||||||
|
elif response == "!donator":
|
||||||
|
log.error("No-IP: this feature requires a paid account.")
|
||||||
|
elif response == "abuse":
|
||||||
|
log.error("No-IP: account blocked for abuse.")
|
||||||
|
elif response == "911":
|
||||||
|
log.error("No-IP: server error. Will retry on next run.")
|
||||||
|
else:
|
||||||
|
log.error(f"No-IP: unexpected response: {response}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# DuckDNS update
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def update_duckdns(provider, ip):
|
||||||
|
"""
|
||||||
|
DuckDNS HTTP update API.
|
||||||
|
Docs: https://www.duckdns.org/spec.jsp
|
||||||
|
Token-based, no username/password. Subdomains are the short name only
|
||||||
|
(e.g. "myhome", not "myhome.duckdns.org"). Supports multiple subdomains
|
||||||
|
as a comma-separated list.
|
||||||
|
Returns True on success, False on failure.
|
||||||
|
"""
|
||||||
|
token = provider["api_token"]
|
||||||
|
subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"])
|
||||||
|
description = provider["description"]
|
||||||
|
|
||||||
|
url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
response = r.read().decode().strip()
|
||||||
|
if response == "OK":
|
||||||
|
log.info(f"DuckDNS updated successfully: {subdomains} -> {ip}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.error(f"DuckDNS update failed for '{description}': response was '{response}'")
|
||||||
|
return False
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
log.error(f"Network error contacting DuckDNS: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Cloudflare DNS update
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def _cf_api_get(url, headers):
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Cloudflare API GET error ({url}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cf_get_zone_id(zone_name, headers):
|
||||||
|
data = _cf_api_get(
|
||||||
|
f"https://api.cloudflare.com/client/v4/zones?name={zone_name}", headers
|
||||||
|
)
|
||||||
|
if data and data.get("success") and data["result"]:
|
||||||
|
return data["result"][0]["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cf_get_record_id(zone_id, hostname, headers):
|
||||||
|
data = _cf_api_get(
|
||||||
|
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?name={hostname}&type=A",
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
if data and data.get("success") and data["result"]:
|
||||||
|
return data["result"][0]["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_cloudflare(provider, ip):
|
||||||
|
"""
|
||||||
|
Cloudflare DNS update API.
|
||||||
|
Docs: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/
|
||||||
|
Bearer-token auth. Looks up zone and record IDs dynamically, then PATCHes each A record.
|
||||||
|
"""
|
||||||
|
token = provider["api_token"]
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "ddns-update/1.0",
|
||||||
|
}
|
||||||
|
success = True
|
||||||
|
for hostname in provider["hostnames"]:
|
||||||
|
zone_name = ".".join(hostname.split(".")[-2:])
|
||||||
|
zone_id = _cf_get_zone_id(zone_name, headers)
|
||||||
|
if not zone_id:
|
||||||
|
log.error(f"Cloudflare: zone '{zone_name}' not found in account.")
|
||||||
|
success = False
|
||||||
|
continue
|
||||||
|
record_id = _cf_get_record_id(zone_id, hostname, headers)
|
||||||
|
if not record_id:
|
||||||
|
log.error(f"Cloudflare: A record for '{hostname}' not found in zone '{zone_name}'.")
|
||||||
|
success = False
|
||||||
|
continue
|
||||||
|
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}"
|
||||||
|
payload = json.dumps({"content": ip}).encode()
|
||||||
|
req = urllib.request.Request(url, data=payload, headers=headers, method="PATCH")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
data = json.loads(r.read().decode())
|
||||||
|
if data.get("success"):
|
||||||
|
log.info(f"Cloudflare updated successfully: {hostname} -> {ip}")
|
||||||
|
else:
|
||||||
|
log.error(f"Cloudflare update failed for '{hostname}': {data.get('errors')}")
|
||||||
|
success = False
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Cloudflare API PATCH error for '{hostname}': {e}")
|
||||||
|
success = False
|
||||||
|
return success
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Process a single provider block
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def process_provider(provider, current_ip, force=False):
|
||||||
|
description = provider["description"]
|
||||||
|
|
||||||
|
if not provider.get("enabled") is True:
|
||||||
|
log.info(f"Provider '{description}' is disabled, skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cached_ip = get_cached_ip(description)
|
||||||
|
|
||||||
|
if not force and current_ip == cached_ip:
|
||||||
|
log.info(f"[{description}] IP unchanged ({current_ip}), skipping update.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if force:
|
||||||
|
log.info(f"[{description}] Force update requested. Updating with {current_ip}...")
|
||||||
|
elif cached_ip:
|
||||||
|
log.info(f"[{description}] IP changed: {cached_ip} -> {current_ip}. Updating...")
|
||||||
|
else:
|
||||||
|
log.info(f"[{description}] No cached IP found. Updating with {current_ip}...")
|
||||||
|
|
||||||
|
ptype = provider["provider"].lower()
|
||||||
|
|
||||||
|
if ptype == "noip":
|
||||||
|
hostnames = ",".join(provider["hostnames"])
|
||||||
|
response = update_noip(provider, current_ip)
|
||||||
|
success = interpret_noip_response(response, hostnames, current_ip)
|
||||||
|
elif ptype == "duckdns":
|
||||||
|
success = update_duckdns(provider, current_ip)
|
||||||
|
elif ptype == "cloudflare":
|
||||||
|
success = update_cloudflare(provider, current_ip)
|
||||||
|
else:
|
||||||
|
log.error(f"[{description}] Unknown provider type: '{ptype}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
if success:
|
||||||
|
save_cached_ip(description, current_ip)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# FreeRADIUS log rotation
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def rotate_radius_log(radius_cfg):
|
||||||
|
"""Truncate the FreeRADIUS log if it exceeds radius.general.log_max_kb."""
|
||||||
|
max_kb = radius_cfg.get("general", {}).get("log_max_kb", 1024)
|
||||||
|
max_bytes = int(max_kb * 1024)
|
||||||
|
if not RADIUS_LOG_FILE.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if RADIUS_LOG_FILE.stat().st_size > max_bytes:
|
||||||
|
RADIUS_LOG_FILE.write_text("")
|
||||||
|
print(f"FreeRADIUS log cleared (exceeded {max_kb} KB).")
|
||||||
|
except PermissionError:
|
||||||
|
print(f"WARNING: Cannot write to {RADIUS_LOG_FILE} (permission denied).")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"WARNING: Error checking FreeRADIUS log: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Main
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def run_update(cfg, force=False, getip_only=False):
|
||||||
|
"""Perform a single DDNS update pass.
|
||||||
|
If force=True, bypasses the cached IP check and always updates.
|
||||||
|
If getip_only=True, prints the detected public IP and returns without updating providers."""
|
||||||
|
current_ip = get_public_ip(cfg["ip_check_services"])
|
||||||
|
|
||||||
|
if getip_only:
|
||||||
|
print(current_ip)
|
||||||
|
return
|
||||||
|
|
||||||
|
enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
log.error("No enabled providers found in config.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
for provider in enabled:
|
||||||
|
process_provider(provider, current_ip, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Routlin periodic maintenance (DDNS update + log rotation)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"examples:\n"
|
||||||
|
" python3 maintenance.py --update Run all tasks once (used by timer)\n"
|
||||||
|
" python3 maintenance.py --force Force DDNS update regardless of cached IP\n"
|
||||||
|
" python3 maintenance.py --getip Print current public IP and exit\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("--update", action="store_true", help="Run all tasks once (used by timer)")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Force DDNS update regardless of cached IP")
|
||||||
|
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not any([args.update, args.force, args.getip]):
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.getip:
|
||||||
|
global log
|
||||||
|
log = logging.getLogger("ddns_quiet")
|
||||||
|
log.addHandler(logging.NullHandler())
|
||||||
|
log.propagate = False
|
||||||
|
cfg = load_config()
|
||||||
|
run_update(cfg, getip_only=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
general = cfg["general"]
|
||||||
|
setup_logging(general["log_max_kb"], general["log_errors_only"])
|
||||||
|
|
||||||
|
if args.update or args.force:
|
||||||
|
run_update(cfg, force=args.force)
|
||||||
|
|
||||||
|
rotate_radius_log(cfg.get("_radius", {}))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue