Flask app progress

This commit is contained in:
Matthew Grotke 2026-05-17 03:26:01 -04:00
parent c4fe022d42
commit b0994069ad
38 changed files with 6631 additions and 220 deletions

View file

@ -18,9 +18,10 @@ 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
sudo python3 ddns.py --apply Run update once (used by timer)
sudo python3 ddns.py --force Force update regardless of cached IP
sudo python3 ddns.py --status Show timer/service status
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
"""
import json
@ -44,13 +45,13 @@ TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer")
# 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}")
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)
@ -59,47 +60,47 @@ def load_config():
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}")
print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr)
sys.exit(1)
if not data["general"]["ip_check_services"]:
print("ERROR: ip_check_services list is empty.")
print("ERROR: 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.")
print("ERROR: No 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}")
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 = {"token", "subdomains"}
extra = {"api_token", "hostnames"}
elif ptype == "cloudflare":
extra = {"api_token", "hostnames"}
else:
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'")
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}")
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr)
sys.exit(1)
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.
or as a normal user - the script directory owner is always the right target.
"""
try:
stat = SCRIPT_DIR.stat()
@ -107,9 +108,9 @@ def chown_to_script_dir_owner(path):
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."""
@ -138,9 +139,9 @@ def setup_logging(max_kb, errors_only):
)
log = logging.getLogger("ddns")
# ------------------------------------------------------------------------------
# ===================================================================
# Per-provider IP cache
# ------------------------------------------------------------------------------
# ===================================================================
def cache_file_for(description):
"""Return the cache file path for a given provider description."""
@ -158,9 +159,9 @@ def save_cached_ip(description, ip):
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."""
@ -177,8 +178,9 @@ 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):
"""
@ -225,7 +227,7 @@ def _get_ip_via_cf_dns(spec):
return None
# ------------------------------------------------------------------------------
# ===================================================================
def get_public_ip(services):
"""
@ -258,9 +260,9 @@ def get_public_ip(services):
log.error("Could not determine public IP from any configured service.")
sys.exit(1)
# ------------------------------------------------------------------------------
# ===================================================================
# No-IP update
# ------------------------------------------------------------------------------
# ===================================================================
def update_noip(provider, ip):
"""
@ -325,9 +327,9 @@ def interpret_noip_response(response, hostnames, ip):
return False
# ------------------------------------------------------------------------------
# ===================================================================
# DuckDNS update
# ------------------------------------------------------------------------------
# ===================================================================
def update_duckdns(provider, ip):
"""
@ -338,8 +340,8 @@ def update_duckdns(provider, ip):
as a comma-separated list.
Returns True on success, False on failure.
"""
token = provider["token"]
subdomains = ",".join(provider["subdomains"])
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}"
@ -358,9 +360,9 @@ def update_duckdns(provider, ip):
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)
@ -429,9 +431,9 @@ def update_cloudflare(provider, ip):
success = False
return success
# ------------------------------------------------------------------------------
# ===================================================================
# Process a single provider block
# ------------------------------------------------------------------------------
# ===================================================================
def process_provider(provider, current_ip, force=False):
description = provider["description"]
@ -471,9 +473,9 @@ def process_provider(provider, current_ip, force=False):
save_cached_ip(description, current_ip)
# ------------------------------------------------------------------------------
# ===================================================================
# Timer management
# ------------------------------------------------------------------------------
# ===================================================================
def parse_interval(interval_str):
"""
@ -557,16 +559,22 @@ def remove_timer():
else:
print("No timer found, nothing to remove.")
# ------------------------------------------------------------------------------
# ===================================================================
# Main
# ------------------------------------------------------------------------------
# ===================================================================
def run_update(cfg, force=False):
def run_update(cfg, force=False, getip_only=False):
"""Perform a single DDNS update pass. Called by both timer and --start.
If force=True, bypasses the cached IP check and always updates."""
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"]
current_ip = get_public_ip(general["ip_check_services"])
enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
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.")
@ -594,20 +602,22 @@ def main():
"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"
" sudo python3 ddns.py --apply Run update once (used by timer)\n"
" sudo python3 ddns.py --force Force update regardless of cached IP\n"
" sudo python3 ddns.py --status Show timer/service status\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("--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("--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")
args = parser.parse_args()
if not any([args.start, args.disable, args.apply, args.force, args.status]):
if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]):
parser.print_help()
return
@ -615,6 +625,15 @@ def main():
show_status()
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"])