125 lines
4.7 KiB
Nix
125 lines
4.7 KiB
Nix
{ config, pkgs, lib, ... }:
|
|
|
|
{
|
|
# ── Ensure njalla directory exists on every build ────────────────────────
|
|
systemd.tmpfiles.rules = [
|
|
"d /var/lib/njalla 0750 root root -"
|
|
];
|
|
|
|
# ── Install the shared validation helper so the DDNS runner can import it ─
|
|
# The exact same _validate_ddns_url() function used by the Hub web application
|
|
# is installed here as a read-only system file. The DDNS runner imports it
|
|
# directly so the two code paths share one validator — no weaker inline copy.
|
|
environment.etc."sovran/security_helpers.py" = {
|
|
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
|
mode = "0444";
|
|
user = "root";
|
|
group = "root";
|
|
};
|
|
|
|
# ── Safe DDNS update service ─────────────────────────────────────────────
|
|
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
|
# invokes curl directly — no shell interpolation, no script execution.
|
|
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
|
systemd.services.sovran-ddns-update = {
|
|
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
User = "root";
|
|
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
|
# Harden the service — it only needs network access and read access to
|
|
# /var/lib/njalla/ddns_urls.json.
|
|
NoNewPrivileges = true;
|
|
ProtectSystem = "strict";
|
|
ReadWritePaths = [ "/var/lib/njalla" ];
|
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
|
ProtectHome = true;
|
|
PrivateTmp = true;
|
|
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
|
};
|
|
};
|
|
|
|
# Run the update every 15 minutes
|
|
systemd.timers.sovran-ddns-update = {
|
|
description = "Sovran Njal.la DDNS update timer";
|
|
wantedBy = [ "timers.target" ];
|
|
timerConfig = {
|
|
OnBootSec = "2min";
|
|
OnUnitActiveSec = "15min";
|
|
Persistent = true;
|
|
};
|
|
};
|
|
|
|
# Install the Python runner script at build time so the service can find it.
|
|
# The script is owned by root and not world-writable.
|
|
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
|
|
# production validator used by the Hub API — before executing any curl call.
|
|
# No shell is used; no redirects; no script execution.
|
|
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
|
|
# the URL is validated after substitution so any remaining $ is rejected.
|
|
system.activationScripts.sovran-ddns-update-script = ''
|
|
install -d -m 0755 /var/lib/sovran
|
|
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
|
#!/usr/bin/env python3
|
|
"""Sovran safe DDNS update runner.
|
|
|
|
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
|
|
validates each URL using the production _validate_ddns_url() from
|
|
/etc/sovran/security_helpers.py, then calls curl per URL.
|
|
No shell interpolation. No redirects. No script execution.
|
|
"""
|
|
import ipaddress, json, os, subprocess, sys
|
|
|
|
sys.path.insert(0, '/etc/sovran')
|
|
try:
|
|
from security_helpers import _validate_ddns_url
|
|
except ImportError:
|
|
sys.exit(0) # validator not available — skip silently
|
|
|
|
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
|
|
|
try:
|
|
with open(URLS_FILE) as f:
|
|
urls = json.load(f)
|
|
if not isinstance(urls, list):
|
|
raise ValueError("not a list")
|
|
except Exception:
|
|
sys.exit(0) # no URLs configured — nothing to do
|
|
|
|
# Resolve current public IP once
|
|
public_ip = ""
|
|
try:
|
|
r = subprocess.run(
|
|
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
|
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
|
public_ip = raw
|
|
except Exception:
|
|
pass
|
|
|
|
if not public_ip:
|
|
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
|
|
|
for raw_url in urls:
|
|
try:
|
|
# Substitute ''${IP} placeholder then validate through production validator.
|
|
# After substitution there must be no $ left; _validate_ddns_url rejects
|
|
# any remaining $ expression.
|
|
url = raw_url.replace("''${IP}", public_ip)
|
|
_validate_ddns_url(url)
|
|
subprocess.run(
|
|
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
|
timeout=20, check=False,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
except (ValueError, Exception):
|
|
pass
|
|
PYEOF
|
|
chmod 0500 /var/lib/sovran/ddns-update.py
|
|
'';
|
|
}
|