Development
This commit is contained in:
parent
b99ea35f79
commit
5149e5a035
10 changed files with 197 additions and 213 deletions
|
|
@ -682,5 +682,61 @@
|
|||
],
|
||||
"is_vpn": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ddns": {
|
||||
"general": {
|
||||
"log_max_kb": 1024,
|
||||
"log_errors_only": false,
|
||||
"ip_check_services": [
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
"https://api4.my-ip.io/ip",
|
||||
"https://ipv4.icanhazip.com",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://1.1.1.1/cdn-cgi/trace",
|
||||
"cf-dns:myip.cloudflare",
|
||||
"https://ipinfo.io/ip",
|
||||
"https://ipecho.net/plain",
|
||||
"https://ident.me",
|
||||
"https://myip.dnsomatic.com",
|
||||
"https://wtfismyip.com/text"
|
||||
],
|
||||
"timer_interval": "10m"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"description": "No-IP Account",
|
||||
"provider": "noip",
|
||||
"enabled": true,
|
||||
"username": "your-username",
|
||||
"password": "your-password",
|
||||
"hostnames": [
|
||||
"yoursubdomain.ddns.net",
|
||||
"yourothersubdomain.ddns.net"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Cloudflare Account",
|
||||
"provider": "cloudflare",
|
||||
"enabled": true,
|
||||
"api_token": "your-cloudflare-api-token",
|
||||
"hostnames": [
|
||||
"yourdomain.com",
|
||||
"yoursubdomain.yourdomain.com",
|
||||
"yourothersubdomain.yourdomain.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "DuckDNS Account",
|
||||
"provider": "duckdns",
|
||||
"enabled": false,
|
||||
"api_token": "your-duckdns-api-token",
|
||||
"hostnames": [
|
||||
"yoursubdomain.duckdns.org",
|
||||
"yourothersubdomain.duckdns.org"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -120,6 +120,9 @@ SYSTEMD_DIR = Path("/etc/systemd/system")
|
|||
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
|
||||
BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
|
||||
BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"
|
||||
DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update"
|
||||
DDNS_TIMER_FILE = SYSTEMD_DIR / f"{DDNS_TIMER_NAME}.timer"
|
||||
DDNS_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DDNS_TIMER_NAME}.service"
|
||||
DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
|
||||
DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer"
|
||||
DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service"
|
||||
|
|
@ -1300,6 +1303,69 @@ def _remove_timers(names, timer_files, svc_files, daemon_reload=False):
|
|||
if daemon_reload:
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
|
||||
|
||||
def _parse_ddns_interval(interval_str):
|
||||
"""Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value."""
|
||||
s = interval_str.strip()
|
||||
if s.endswith("m"): return f"{s[:-1]}min"
|
||||
if s.endswith("h"): return f"{s[:-1]}h"
|
||||
if s.endswith("d"): return f"{s[:-1]}day"
|
||||
raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.")
|
||||
|
||||
|
||||
def install_ddns_timer(data):
|
||||
ddns = data.get("ddns", {})
|
||||
interval = ddns.get("general", {}).get("timer_interval", "10m")
|
||||
script_path = SCRIPT_DIR / "ddns.py"
|
||||
try:
|
||||
systemd_unit = _parse_ddns_interval(interval)
|
||||
except ValueError as e:
|
||||
print(f"DDNS timer: {e}")
|
||||
return
|
||||
|
||||
service_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=DDNS IP update",
|
||||
"After=network-online.target",
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
f"ExecStart=/usr/bin/python3 {script_path} --apply",
|
||||
"",
|
||||
])
|
||||
timer_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=DDNS IP update timer",
|
||||
"",
|
||||
"[Timer]",
|
||||
f"OnActiveSec={systemd_unit}",
|
||||
f"OnUnitActiveSec={systemd_unit}",
|
||||
"OnBootSec=1min",
|
||||
"AccuracySec=10s",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=timers.target",
|
||||
"",
|
||||
])
|
||||
for path, content in ((DDNS_TIMER_SVC_FILE, service_content), (DDNS_TIMER_FILE, timer_content)):
|
||||
if not path.exists() or path.read_text() != content:
|
||||
path.write_text(content)
|
||||
print(f"Written: {path}")
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
active = subprocess.run(
|
||||
["systemctl", "is-active", f"{DDNS_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip() == "active"
|
||||
verb = "restart" if active else "enable --now"
|
||||
subprocess.run(["systemctl"] + verb.split() + [f"{DDNS_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
print(f"Timer {DDNS_TIMER_NAME}.timer enabled (runs every {interval}).")
|
||||
|
||||
# ===================================================================
|
||||
# banned_ips expansion
|
||||
# ===================================================================
|
||||
|
|
@ -2484,9 +2550,9 @@ def show_metrics(data):
|
|||
def stop_instances(data):
|
||||
"""Remove timers and stop all per-VLAN instances (config files preserved)."""
|
||||
_remove_timers(
|
||||
names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, STATUS_TIMER_NAME],
|
||||
timer_files=[BLIST_TIMER_FILE, DASHB_TIMER_FILE, STATUS_TIMER_FILE],
|
||||
svc_files=[BLIST_TIMER_SVC_FILE, DASHB_TIMER_SVC_FILE, STATUS_TIMER_SVC_FILE],
|
||||
names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, STATUS_TIMER_NAME, DDNS_TIMER_NAME],
|
||||
timer_files=[BLIST_TIMER_FILE, DASHB_TIMER_FILE, STATUS_TIMER_FILE, DDNS_TIMER_FILE],
|
||||
svc_files=[BLIST_TIMER_SVC_FILE, DASHB_TIMER_SVC_FILE, STATUS_TIMER_SVC_FILE, DDNS_TIMER_SVC_FILE],
|
||||
daemon_reload=True,
|
||||
)
|
||||
print()
|
||||
|
|
@ -3097,6 +3163,15 @@ def cmd_apply(data, dry_run=False):
|
|||
_install_interval_timers(t_names, t_files, s_files, t_descs, t_execs, t_intervals)
|
||||
print()
|
||||
|
||||
print("DDNS timer ==========================================================")
|
||||
enabled_ddns = [p for p in data.get("ddns", {}).get("providers", []) if p.get("enabled")]
|
||||
if enabled_ddns:
|
||||
install_ddns_timer(data)
|
||||
else:
|
||||
_remove_timers([DDNS_TIMER_NAME], [DDNS_TIMER_FILE], [DDNS_TIMER_SVC_FILE])
|
||||
print("No enabled DDNS providers — timer not installed.")
|
||||
print()
|
||||
|
||||
print("Boot service ========================================================")
|
||||
install_nat_service()
|
||||
print()
|
||||
|
|
|
|||
147
routlin/ddns.py
147
routlin/ddns.py
|
|
@ -2,10 +2,10 @@
|
|||
"""
|
||||
ddns.py -- Update DDNS provider(s) with current public IP.
|
||||
|
||||
Reads ddns.json, fetches the current public IP, and updates
|
||||
each enabled provider block only if the IP has changed since the
|
||||
last successful update for that provider.
|
||||
Designed to be run on a systemd timer.
|
||||
Reads the ddns block from core.json, fetches the current public IP,
|
||||
and updates each enabled provider block only if the IP has changed
|
||||
since the last successful update for that provider.
|
||||
Designed to be run on a systemd timer managed by core.py --apply.
|
||||
|
||||
IP check services are rotated each run using .ddns-last-service so
|
||||
no single provider is spammed. If the selected service fails, the
|
||||
|
|
@ -16,11 +16,8 @@ Logs to ddns.log in the same directory as this script.
|
|||
Log is cleared when it exceeds general.log_max_kb from config.
|
||||
|
||||
Usage:
|
||||
sudo python3 ddns.py --start Run update and install systemd timer
|
||||
sudo python3 ddns.py --disable Stop updates and remove systemd timer
|
||||
python3 ddns.py --apply Run update once (used by timer)
|
||||
python3 ddns.py --force Force update regardless of cached IP
|
||||
python3 ddns.py --status Show timer/service status
|
||||
python3 ddns.py --getip Print current public IP and exit
|
||||
"""
|
||||
|
||||
|
|
@ -35,12 +32,9 @@ import logging
|
|||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
CONFIG_FILE = SCRIPT_DIR / "ddns.json"
|
||||
CONFIG_FILE = SCRIPT_DIR / "core.json"
|
||||
CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service"
|
||||
LOG_FILE = SCRIPT_DIR / "ddns.log"
|
||||
TIMER_NAME = "ddns-update"
|
||||
SERVICE_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.service")
|
||||
TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer")
|
||||
|
||||
# log is assigned in setup_logging() after config is loaded
|
||||
log = None
|
||||
|
|
@ -54,21 +48,21 @@ def load_config():
|
|||
print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_FILE) as f:
|
||||
data = json.load(f)
|
||||
data = json.load(f).get("ddns", {})
|
||||
|
||||
# Validate general block
|
||||
required_general = {"log_max_kb", "log_errors_only", "ip_check_services"}
|
||||
missing = required_general - set(data.get("general", {}).keys())
|
||||
if missing:
|
||||
print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr)
|
||||
print(f"ERROR: Missing keys in ddns.general block: {missing}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not data["general"]["ip_check_services"]:
|
||||
print("ERROR: ip_check_services list is empty.", file=sys.stderr)
|
||||
print("ERROR: ddns.ip_check_services list is empty.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate providers block
|
||||
if not data.get("providers"):
|
||||
print("ERROR: No providers defined in config.", file=sys.stderr)
|
||||
print("ERROR: No DDNS providers defined in config.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for p in data["providers"]:
|
||||
base_required = {"description", "provider", "enabled"}
|
||||
|
|
@ -473,98 +467,12 @@ def process_provider(provider, current_ip, force=False):
|
|||
save_cached_ip(description, current_ip)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Timer management
|
||||
# ===================================================================
|
||||
|
||||
def parse_interval(interval_str):
|
||||
"""
|
||||
Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value.
|
||||
Supported units: m (minutes), h (hours), d (days).
|
||||
"""
|
||||
interval_str = interval_str.strip()
|
||||
if interval_str.endswith("m"):
|
||||
return f"{interval_str[:-1]}min"
|
||||
elif interval_str.endswith("h"):
|
||||
return f"{interval_str[:-1]}h"
|
||||
elif interval_str.endswith("d"):
|
||||
return f"{interval_str[:-1]}day"
|
||||
else:
|
||||
print(f"ERROR: Invalid timer_interval format: '{interval_str}'. Use e.g. 5m, 2h, 1d.")
|
||||
sys.exit(1)
|
||||
|
||||
def get_current_timer_interval():
|
||||
"""Read the current OnUnitActiveSec value from the timer file, or None if not present."""
|
||||
if not TIMER_FILE.exists():
|
||||
return None
|
||||
for line in TIMER_FILE.read_text().splitlines():
|
||||
if line.strip().startswith("OnUnitActiveSec="):
|
||||
return line.strip().split("=", 1)[1]
|
||||
return None
|
||||
|
||||
def install_timer(cfg):
|
||||
interval = cfg["general"].get("timer_interval", "5m")
|
||||
systemd_unit = parse_interval(interval)
|
||||
script_path = Path(__file__).resolve()
|
||||
current_interval = get_current_timer_interval()
|
||||
|
||||
if current_interval == systemd_unit:
|
||||
log.info(f"Timer already set to {interval}, no update needed.")
|
||||
return
|
||||
|
||||
service_content = f"""[Unit]
|
||||
Description=DDNS update service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 {script_path} --apply
|
||||
"""
|
||||
|
||||
timer_content = f"""[Unit]
|
||||
Description=DDNS update timer
|
||||
|
||||
[Timer]
|
||||
OnUnitActiveSec={systemd_unit}
|
||||
OnBootSec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
"""
|
||||
|
||||
SERVICE_FILE.write_text(service_content)
|
||||
TIMER_FILE.write_text(timer_content)
|
||||
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=True)
|
||||
subprocess.run(["systemctl", "enable", "--now", f"{TIMER_NAME}.timer"], check=True)
|
||||
|
||||
if current_interval is None:
|
||||
log.info(f"Timer installed: runs every {interval}.")
|
||||
else:
|
||||
log.info(f"Timer updated: was {current_interval}, now runs every {interval}.")
|
||||
|
||||
def remove_timer():
|
||||
if TIMER_FILE.exists() or SERVICE_FILE.exists():
|
||||
subprocess.run(
|
||||
["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"],
|
||||
capture_output=True
|
||||
)
|
||||
for f in (TIMER_FILE, SERVICE_FILE):
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
print(f"Removed: {f}")
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True)
|
||||
print("Timer removed.")
|
||||
else:
|
||||
print("No timer found, nothing to remove.")
|
||||
|
||||
# ===================================================================
|
||||
# Main
|
||||
# ===================================================================
|
||||
|
||||
def run_update(cfg, force=False, getip_only=False):
|
||||
"""Perform a single DDNS update pass. Called by both timer and --start.
|
||||
"""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."""
|
||||
general = cfg["general"]
|
||||
|
|
@ -583,14 +491,6 @@ def run_update(cfg, force=False, getip_only=False):
|
|||
for provider in enabled:
|
||||
process_provider(provider, current_ip, force=force)
|
||||
|
||||
def show_status():
|
||||
"""Show status of managed timer."""
|
||||
result = subprocess.run(
|
||||
["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -600,31 +500,21 @@ def main():
|
|||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"examples:\n"
|
||||
" sudo python3 ddns.py --start Run update and install systemd timer\n"
|
||||
" sudo python3 ddns.py --disable Stop updates and remove systemd timer\n"
|
||||
" python3 ddns.py --apply Run update once (used by timer)\n"
|
||||
" python3 ddns.py --force Force update regardless of cached IP\n"
|
||||
" python3 ddns.py --status Show timer/service status\n"
|
||||
" python3 ddns.py --getip Print current public IP and exit\n"
|
||||
)
|
||||
)
|
||||
parser.add_argument("--start", action="store_true", help="Run update and install systemd timer")
|
||||
parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer")
|
||||
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
|
||||
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
|
||||
parser.add_argument("--status", action="store_true", help="Show timer/service status")
|
||||
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
|
||||
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
|
||||
parser.add_argument("--force", action="store_true", help="Force 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.start, args.disable, args.apply, args.force, args.status, args.getip]):
|
||||
if not any([args.apply, args.force, args.getip]):
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
if args.status:
|
||||
show_status()
|
||||
return
|
||||
|
||||
if args.getip:
|
||||
global log
|
||||
log = logging.getLogger("ddns_quiet")
|
||||
|
|
@ -638,15 +528,6 @@ def main():
|
|||
general = cfg["general"]
|
||||
setup_logging(general["log_max_kb"], general["log_errors_only"])
|
||||
|
||||
if args.disable:
|
||||
remove_timer()
|
||||
return
|
||||
|
||||
if args.start:
|
||||
run_update(cfg)
|
||||
install_timer(cfg)
|
||||
return
|
||||
|
||||
if args.apply or args.force:
|
||||
run_update(cfg, force=args.force)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
|
|||
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
|
||||
DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
|
||||
STATUS_TIMER_NAME = f"{PRODUCT_NAME}-status-check"
|
||||
DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update"
|
||||
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
|
||||
NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat"
|
||||
BLOCKLIST_STALE_SECS = 36 * 3600
|
||||
|
|
@ -170,6 +171,12 @@ def check_services(data):
|
|||
"name": f"{DASHB_TIMER_NAME}.timer",
|
||||
"expected_active": "active", "expected_enabled": "enabled"})
|
||||
|
||||
enabled_ddns = [p for p in data.get("ddns", {}).get("providers", []) if p.get("enabled")]
|
||||
if enabled_ddns:
|
||||
units.append({"id": f"{DDNS_TIMER_NAME}.timer",
|
||||
"name": f"{DDNS_TIMER_NAME}.timer",
|
||||
"expected_active": "active", "expected_enabled": "enabled"})
|
||||
|
||||
exp_fr_active = "active" if _radius_enabled(data) else "inactive"
|
||||
exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled"
|
||||
units.append({"id": "freeradius", "name": "freeradius",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue