Compare commits
4
Commits
v1.1.7
...
c33457fff2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33457fff2 | ||
|
|
34cfba4282 | ||
|
|
2d777450e1 | ||
|
|
726fec1990 |
No files matched your search
@@ -202,7 +202,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
||||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
||||||
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
|
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services. **Makes your home IP address public:** [read this first](#server--desktop-and-your-home-ip-address) |
|
||||||
|
|
||||||
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
||||||
edition. It is a complete, private everyday computer with a clean GNOME
|
edition. It is a complete, private everyday computer with a clean GNOME
|
||||||
@@ -237,6 +237,66 @@ communications, identity, and services.
|
|||||||
> provider allows port forwarding. Most home routers and providers already
|
> provider allows port forwarding. Most home routers and providers already
|
||||||
> support this. If you are unsure, a quick search for your router model and
|
> support this. If you are unsure, a quick search for your router model and
|
||||||
> "port forwarding" will usually turn up a step-by-step guide.
|
> "port forwarding" will usually turn up a step-by-step guide.
|
||||||
|
>
|
||||||
|
> **This mode also makes your home IP address public.** Read
|
||||||
|
> [what that means](#server--desktop-and-your-home-ip-address) before you
|
||||||
|
> choose it.
|
||||||
|
|
||||||
|
### Server + Desktop and your home IP address
|
||||||
|
|
||||||
|
> **⚠️ Server + Desktop makes your home IP address public.**
|
||||||
|
> Public services need a domain name that points at your home internet
|
||||||
|
> connection. When you finish the guided domain setup, Sovran_SystemsOS puts
|
||||||
|
> your home's public IP address in a Dynamic DNS record at
|
||||||
|
> [Njal.la](https://njal.la) and keeps it up to date, and you forward ports 80
|
||||||
|
> and 443 on your router to this computer. From then on:
|
||||||
|
>
|
||||||
|
> - **Anyone can look up your domain and see your home IP address.** An IP
|
||||||
|
> address typically reveals your internet provider and your approximate
|
||||||
|
> location, and it ties everything you publish on that domain to your home
|
||||||
|
> connection.
|
||||||
|
> - **Domain privacy does not hide it.** Registrar privacy protects the
|
||||||
|
> registrant's identity, not the IP address in your DNS records.
|
||||||
|
> - **Your connection is open to the whole internet on those ports.** Scanners
|
||||||
|
> and bots constantly probe public IP addresses, so expect automated probing
|
||||||
|
> and login attempts against every service you publish.
|
||||||
|
> - **Your service names are discoverable.** Public HTTPS certificates are
|
||||||
|
> listed in public Certificate Transparency logs, so hostnames such as
|
||||||
|
> `vault.yourdomain.com` can be found, and then resolved to your IP address,
|
||||||
|
> even if you never share them.
|
||||||
|
|
||||||
|
Nothing is published until you finish domain setup and port forwarding, but that
|
||||||
|
setup is the point of this mode, so assume your IP address will be public.
|
||||||
|
**Desktop** publishes nothing. **Node** publishes nothing unless you turn on a
|
||||||
|
feature that needs a domain: *Put BTCPay Server Online* or *Lightning Wallet
|
||||||
|
Connections*.
|
||||||
|
|
||||||
|
**If you do not want your home IP address to be public,** choose Desktop or
|
||||||
|
Node. Advanced users can put a VPS, reverse proxy, or tunnel in front of their
|
||||||
|
services so DNS points there instead of at their home. Sovran_SystemsOS does not
|
||||||
|
set this up for you, and the Hub's domain checks currently expect DNS to point
|
||||||
|
at your home IP address.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>What happens technically</strong></summary>
|
||||||
|
|
||||||
|
- You create a **Dynamic** DNS record at Njal.la and paste its update command
|
||||||
|
into the Hub. The Hub only accepts `njal.la` update URLs.
|
||||||
|
- The `sovran-ddns-update` timer asks Njal.la to point your record at the
|
||||||
|
address the request came from. It does this right after you save a domain,
|
||||||
|
two minutes after boot, and then every 15 minutes.
|
||||||
|
- Njal.la reports that address back, and Sovran_SystemsOS keeps it for Element
|
||||||
|
calling and the Hub. Nothing else looks up your public IP address: no STUN
|
||||||
|
server, public DNS resolver, or "what is my IP" service is involved. See
|
||||||
|
`modules/core/njalla.nix`.
|
||||||
|
- Once a service that needs a domain is turned on, the firewall opens TCP and
|
||||||
|
UDP ports 80 and 443 for Caddy, which requests public HTTPS certificates for
|
||||||
|
the domains you configure. See `modules/core/caddy.nix`.
|
||||||
|
- Optional features can need more ports. Element calling, for example, needs
|
||||||
|
TCP 7881 and UDP 3478, 7882, and 40000–40099. The Hub lists the ports each
|
||||||
|
feature needs.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -730,7 +790,9 @@ Sovran_SystemsOS uses layered controls:
|
|||||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||||
- Tor enforcement for supported Bitcoin services
|
- Tor enforcement for supported Bitcoin services
|
||||||
- Restricted, time-limited support access with scoped `sudo`
|
- Restricted, time-limited support access with scoped `sudo`
|
||||||
- Operator-controlled public service exposure
|
- Operator-controlled public service exposure (Server + Desktop
|
||||||
|
[makes your home IP address public](#server--desktop-and-your-home-ip-address)
|
||||||
|
once you set up a domain)
|
||||||
|
|
||||||
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||||
and operator guidance. No operating system can protect funds after recovery
|
and operator guidance. No operating system can protect funds after recovery
|
||||||
|
|||||||
+26
-2
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| Release | Supported |
|
| Release | Supported |
|
||||||
|---|:---:|
|
|---|:---:|
|
||||||
| Latest `1.0.x` stable release | Yes |
|
| Latest stable release | Yes |
|
||||||
| `main` / `staging-dev` | Development only |
|
| `main` / `staging-dev` | Development only |
|
||||||
| Older than `1.0.0` | No |
|
| Older than `1.0.0` | No |
|
||||||
|
|
||||||
@@ -35,6 +35,29 @@ external networks and are outside a “fully offline” model.
|
|||||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||||
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
|
|
||||||
|
Caddy serves the Hub (`sovransystemsos.local`), Ride The Lightning (port 3051),
|
||||||
|
and Mempool (port 60847) only to this computer and to clients on your local
|
||||||
|
network (private, link-local, and VPN addresses), even when ports 80 and 443 are
|
||||||
|
forwarded to this computer for public services. Other IPv4 clients get the
|
||||||
|
connection closed. IPv6 global addresses are not filtered.
|
||||||
|
|
||||||
|
### Public services and your home IP address
|
||||||
|
|
||||||
|
Server + Desktop publishes services under your own domain. The Dynamic DNS
|
||||||
|
record at Njal.la then points at your home's public IP address, which anyone
|
||||||
|
can look up (domain privacy does not hide it), and ports 80 and 443 are open
|
||||||
|
to the whole internet. Public HTTPS certificates also list your service
|
||||||
|
hostnames in Certificate Transparency logs. Desktop publishes nothing. Node
|
||||||
|
publishes nothing unless *Put BTCPay Server Online* or *Lightning Wallet
|
||||||
|
Connections* is on. See
|
||||||
|
[Server + Desktop and your home IP address](README.md#server--desktop-and-your-home-ip-address)
|
||||||
|
for what this means and the alternatives.
|
||||||
|
|
||||||
|
Sovran_SystemsOS does not ask a STUN server, public DNS resolver, or “what is
|
||||||
|
my IP” service for your address. The DDNS update asks Njal.la to use the
|
||||||
|
address the request came from, and the address Njal.la reports back is the one
|
||||||
|
Element calling and the Hub use.
|
||||||
|
|
||||||
### Bitcoin stack
|
### Bitcoin stack
|
||||||
|
|
||||||
Bitcoin and Lightning modules are maintained in the standalone
|
Bitcoin and Lightning modules are maintained in the standalone
|
||||||
@@ -64,7 +87,8 @@ change both the system and local configuration.
|
|||||||
- Separate service users and systemd sandboxing where supported
|
- Separate service users and systemd sandboxing where supported
|
||||||
- Administrative service ports bound to loopback where practical
|
- Administrative service ports bound to loopback where practical
|
||||||
- Tor enforced for supported Bitcoin traffic and onion services
|
- Tor enforced for supported Bitcoin traffic and onion services
|
||||||
- Public web services exposed only when enabled by the operator
|
- Public web services exposed only when enabled by the operator (this makes
|
||||||
|
your home IP address public)
|
||||||
|
|
||||||
Tor reduces network exposure for configured Bitcoin services. It is not a
|
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||||
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sovran DDNS update runner (sovran-ddns-update.service).
|
||||||
|
|
||||||
|
For every stored Njal.la update URL this asks Njal.la to point the record at
|
||||||
|
the address the request came from ("&auto"), then reads back the address
|
||||||
|
Njal.la says it recorded. That address is saved to /var/lib/secrets/external-ip,
|
||||||
|
where livekit-turn-setup and the Hub read it.
|
||||||
|
|
||||||
|
Njal.la is the only party involved. It has to learn the address to publish
|
||||||
|
it, so nothing else -- no STUN server, no public resolver, no "what is my IP"
|
||||||
|
service -- is ever asked for it.
|
||||||
|
|
||||||
|
Kept from the previous runner:
|
||||||
|
* every URL goes through _validate_ddns_url() (https, njal.la only, /update/)
|
||||||
|
* curl is run directly: no shell, no redirects
|
||||||
|
* the update key is never printed or logged
|
||||||
|
|
||||||
|
The module is installed next to security_helpers.py (/etc/sovran/) and run as a
|
||||||
|
script by the service; it is also importable as
|
||||||
|
sovran_systemsos_web.ddns_update so the tests can exercise it.
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .security_helpers import _validate_ddns_url # imported as part of the Hub package
|
||||||
|
except ImportError: # run as a script from /etc/sovran, next to security_helpers.py
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from security_helpers import _validate_ddns_url
|
||||||
|
|
||||||
|
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||||
|
IP_FILE = "/var/lib/secrets/external-ip"
|
||||||
|
|
||||||
|
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_ipv4(value) -> bool:
|
||||||
|
"""True for a globally routable IPv4 literal (not private, loopback, CGNAT ...)."""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(str(value).strip())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if ip.version != 4 or ip in _CGNAT:
|
||||||
|
return False
|
||||||
|
# is_global alone is not enough: CPython reports multicast as global.
|
||||||
|
return ip.is_global and not (
|
||||||
|
ip.is_multicast or ip.is_reserved or ip.is_loopback
|
||||||
|
or ip.is_link_local or ip.is_unspecified or ip.is_private
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_url(raw: str) -> str:
|
||||||
|
"""Return the URL to call for a stored (or freshly pasted) update URL.
|
||||||
|
|
||||||
|
* Older Hubs stored "...&a=${IP}": the address was looked up locally and
|
||||||
|
substituted. Njal.la can use the address the request came from, so that
|
||||||
|
placeholder becomes "&auto".
|
||||||
|
* "&quiet" is dropped: the reply is how we learn the address Njal.la recorded.
|
||||||
|
"""
|
||||||
|
return raw.replace("&a=${IP}", "&auto").replace("&quiet", "")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reply(body: str):
|
||||||
|
"""Return the public IPv4 address Njal.la says it recorded, or None.
|
||||||
|
|
||||||
|
A successful update replies with JSON of the form
|
||||||
|
{"status": 200, "message": "record updated", "value": {"A": "203.0.113.7", ...}}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict) or str(data.get("status")) != "200":
|
||||||
|
return None
|
||||||
|
value = data.get("value")
|
||||||
|
ip = value.get("A") if isinstance(value, dict) else None
|
||||||
|
return str(ip).strip() if is_public_ipv4(ip) else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_ip_file(path: str = None):
|
||||||
|
"""The address recorded by the last successful update, or None."""
|
||||||
|
try:
|
||||||
|
with open(path or IP_FILE) as f:
|
||||||
|
return f.read().strip() or None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_ip_file(ip: str, path: str = None) -> None:
|
||||||
|
"""Replace the file atomically so a path watcher never sees a partial write."""
|
||||||
|
path = path or IP_FILE
|
||||||
|
directory = os.path.dirname(path)
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".external-ip-")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(ip)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def update_all(urls, *, run=None, validate=_validate_ddns_url):
|
||||||
|
"""Call every update URL once; return the address Njal.la reported, or None.
|
||||||
|
|
||||||
|
Nothing secret is printed: URLs are only ever referred to by position.
|
||||||
|
"""
|
||||||
|
run = run or subprocess.run # resolved per call so tests can substitute it
|
||||||
|
reported = None
|
||||||
|
seen = set()
|
||||||
|
todo = []
|
||||||
|
for raw in urls:
|
||||||
|
url = normalise_url(raw)
|
||||||
|
if url not in seen: # an old "&a=${IP}" entry and its "&auto" twin are one record
|
||||||
|
seen.add(url)
|
||||||
|
todo.append(url)
|
||||||
|
for number, url in enumerate(todo, 1):
|
||||||
|
try:
|
||||||
|
validate(url)
|
||||||
|
proc = run(
|
||||||
|
["curl", "--silent", "--ipv4", "--max-time", "15", "--fail", "--no-location", url],
|
||||||
|
capture_output=True, text=True, timeout=20, check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: skipped (invalid URL or curl unavailable)")
|
||||||
|
continue
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: failed (curl exit {proc.returncode})")
|
||||||
|
continue
|
||||||
|
ip = parse_reply(proc.stdout)
|
||||||
|
if ip is None:
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: Njal.la did not report a public IPv4 address")
|
||||||
|
continue
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: ok")
|
||||||
|
if reported is None:
|
||||||
|
reported = ip
|
||||||
|
elif ip != reported:
|
||||||
|
print("DDNS: Njal.la reported different addresses for different records; using the first")
|
||||||
|
return reported
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
with open(URLS_FILE) as f:
|
||||||
|
urls = json.load(f)
|
||||||
|
if not isinstance(urls, list):
|
||||||
|
raise ValueError("not a list")
|
||||||
|
except Exception:
|
||||||
|
return 0 # no URLs configured -- nothing to do
|
||||||
|
urls = [u for u in urls if isinstance(u, str)]
|
||||||
|
if not urls:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ip = update_all(urls)
|
||||||
|
if ip is None:
|
||||||
|
print("DDNS: no address reported by Njal.la; keeping the last known one")
|
||||||
|
return 0
|
||||||
|
previous = read_ip_file()
|
||||||
|
if ip == previous:
|
||||||
|
print(f"DDNS: public IP unchanged ({ip})")
|
||||||
|
return 0
|
||||||
|
write_ip_file(ip)
|
||||||
|
print(f"DDNS: public IP is now {ip} (was {previous or 'unknown'})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -21,7 +21,6 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import sys
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -41,6 +40,7 @@ from .config import load_config, load_versions
|
|||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||||
from . import support_ops as _support_ops
|
from . import support_ops as _support_ops
|
||||||
|
from .ddns_update import normalise_url as _normalise_ddns_url
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
NPUB_RE,
|
NPUB_RE,
|
||||||
@@ -1008,43 +1008,20 @@ def _save_internal_ip(ip: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _save_external_ip(ip: str):
|
|
||||||
"""Write the external IP to a file so other services (e.g. LiveKit) can
|
|
||||||
reference it without running their own detection."""
|
|
||||||
if ip and ip != "unavailable":
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True)
|
|
||||||
with open(EXTERNAL_IP_FILE, "w") as f:
|
|
||||||
f.write(ip)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _get_external_ip() -> str:
|
def _get_external_ip() -> str:
|
||||||
"""Public IP via the shared detector (/var/lib/sovran/public-ip.py).
|
"""Public IP as recorded by the Njal.la DDNS runner (ddns_update.py).
|
||||||
|
|
||||||
The detector owns discovery (STUN -> DNS -> opt-in HTTPS echo), caches the
|
Nothing here looks the address up or contacts anyone. The DDNS update asks
|
||||||
result in /var/lib/secrets/external-ip, and contacts at most one third
|
Njal.la to use the address the request came from, Njal.la reports it back,
|
||||||
party per refresh interval. This function only reads the cache and asks
|
and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
|
||||||
the detector to refresh when it is missing or stale — it performs no
|
the first successful update (and on machines with no DDNS URL, e.g. Desktop).
|
||||||
per-call external queries of its own.
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
|
||||||
capture_output=True, text=True, timeout=20,
|
|
||||||
)
|
|
||||||
if r.returncode == 0 and r.stdout.strip():
|
|
||||||
return r.stdout.strip().splitlines()[0]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
with open(EXTERNAL_IP_FILE) as f:
|
with open(EXTERNAL_IP_FILE) as f:
|
||||||
ip = f.read().strip()
|
ip = f.read().strip()
|
||||||
if ip:
|
ipaddress.ip_address(ip)
|
||||||
return ip
|
return ip
|
||||||
except OSError:
|
except (OSError, ValueError):
|
||||||
pass
|
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
||||||
|
|
||||||
@@ -3796,9 +3773,6 @@ async def api_network():
|
|||||||
# Keep the internal-ip file in sync for credential lookups
|
# Keep the internal-ip file in sync for credential lookups
|
||||||
_save_internal_ip(internal)
|
_save_internal_ip(internal)
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
# Persist the external IP so other services (e.g. LiveKit) can reuse the
|
|
||||||
# Hub's detection instead of running their own.
|
|
||||||
_save_external_ip(external)
|
|
||||||
return {"internal_ip": internal, "external_ip": external}
|
return {"internal_ip": internal, "external_ip": external}
|
||||||
|
|
||||||
|
|
||||||
@@ -4723,48 +4697,23 @@ def _save_ddns_urls(urls: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _run_njalla_ddns() -> None:
|
def _run_njalla_ddns() -> None:
|
||||||
"""Update Njal.la DDNS records immediately (best-effort).
|
"""Ask the DDNS runner to update Njal.la right away (best-effort, non-blocking).
|
||||||
|
|
||||||
Resolves the current public IP once, then invokes ``curl`` directly as a
|
The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that
|
||||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
talks to Njal.la. It validates every stored URL, calls it with "&auto" so
|
||||||
performed and no user-controlled value is interpreted as shell syntax.
|
Njal.la uses the address the request came from, and records the address
|
||||||
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
|
Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE).
|
||||||
substitution; URLs that fail validation are silently skipped.
|
|
||||||
|
|
||||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
|
Called when a domain/DDNS entry is saved and when a DDNS-backed feature is
|
||||||
is enabled, so DNS is refreshed right away instead of waiting for the
|
enabled, so DNS is refreshed right away instead of waiting for the
|
||||||
15-minute timer tick (see modules/core/njalla.nix).
|
15-minute timer tick.
|
||||||
"""
|
"""
|
||||||
urls = _load_ddns_urls()
|
if not _load_ddns_urls():
|
||||||
if not urls:
|
|
||||||
return
|
return
|
||||||
# Resolve current public IP (best-effort; skip if unavailable)
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
try:
|
||||||
ip_result = subprocess.run(
|
|
||||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
|
||||||
capture_output=True, text=True, timeout=10, check=False,
|
|
||||||
)
|
|
||||||
raw_ip = ip_result.stdout.strip().splitlines()[0] if ip_result.stdout.strip() else ""
|
|
||||||
# Validate strictly as a proper IPv4/IPv6 address before substitution
|
|
||||||
ipaddress.ip_address(raw_ip)
|
|
||||||
public_ip = raw_ip
|
|
||||||
except Exception:
|
|
||||||
public_ip = ""
|
|
||||||
|
|
||||||
if not public_ip:
|
|
||||||
return # skip to avoid sending bare ${IP} to curl
|
|
||||||
|
|
||||||
for raw_url in urls:
|
|
||||||
try:
|
|
||||||
# Replace the placeholder with the validated IP (safe string replacement)
|
|
||||||
url = raw_url.replace("${IP}", public_ip)
|
|
||||||
# Revalidate after substitution — enforces /update/ path, no $, etc.
|
|
||||||
_validate_ddns_url(url)
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
["systemctl", "start", "--no-block", "sovran-ddns-update.service"],
|
||||||
timeout=20, check=False,
|
capture_output=True, timeout=10, check=False,
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -4906,18 +4855,17 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
# Strip surrounding quotes
|
# Strip surrounding quotes
|
||||||
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||||
ddns_url = ddns_url[1:-1]
|
ddns_url = ddns_url[1:-1]
|
||||||
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns
|
# Keep Njal.la's "&auto": Njal.la then uses the address the request comes
|
||||||
if ddns_url.endswith("&auto"):
|
# from, so nothing on this machine has to look the address up. Old
|
||||||
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
# "&a=${IP}" pastes and "&quiet" are normalised exactly as the runner does.
|
||||||
|
ddns_url = _normalise_ddns_url(ddns_url)
|
||||||
# Validate URL strictly — reject injection attempts before persisting.
|
# Validate URL strictly — reject injection attempts before persisting.
|
||||||
# The placeholder ${IP} is replaced temporarily so the validator sees a
|
|
||||||
# real address; the original URL (with the placeholder) is kept for storage.
|
|
||||||
try:
|
try:
|
||||||
_validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1"))
|
_validate_ddns_url(ddns_url)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
||||||
# Persist the URL in the JSON store (never in executable shell source)
|
# Persist the URL in the JSON store (never in executable shell source)
|
||||||
existing_urls = _load_ddns_urls()
|
existing_urls = list(dict.fromkeys(_normalise_ddns_url(u) for u in _load_ddns_urls()))
|
||||||
if ddns_url not in existing_urls:
|
if ddns_url not in existing_urls:
|
||||||
existing_urls.append(ddns_url)
|
existing_urls.append(ddns_url)
|
||||||
try:
|
try:
|
||||||
@@ -6308,13 +6256,12 @@ async def _background_domain_reachability_checker():
|
|||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Keep the persisted external IP fresh (dynamic WAN IPs), so
|
# Pick up the address the Njal.la DDNS runner last recorded (a plain
|
||||||
# services like LiveKit can read /var/lib/secrets/external-ip.
|
# file read; nothing is looked up from here).
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
external = await loop.run_in_executor(None, _get_external_ip)
|
external = await loop.run_in_executor(None, _get_external_ip)
|
||||||
if external != "unavailable":
|
if external != "unavailable":
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
_save_external_ip(external)
|
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
services = cfg.get("services", [])
|
services = cfg.get("services", [])
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ function renderDomainNeedsHtml(opts) {
|
|||||||
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ " and connecting your services. Just follow the steps below.</p>";
|
+ " and connecting your services. Just follow the steps below.</p>";
|
||||||
}
|
}
|
||||||
|
// Every variant above ends with a domain that points at the user's home
|
||||||
|
// connection. Say what that publishes (README: "Server + Desktop and your
|
||||||
|
// home IP address").
|
||||||
|
html += "<p>⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection, "
|
||||||
|
+ "so anyone can look up your home IP address. Domain privacy does not hide it.</p>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -476,6 +476,10 @@
|
|||||||
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="support-desc">
|
||||||
|
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
|
||||||
|
so anyone can look up your home IP address. Domain privacy does not hide it.
|
||||||
|
</p>
|
||||||
<p class="support-desc">
|
<p class="support-desc">
|
||||||
The Hub guides you through every step.
|
The Hub guides you through every step.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Generated
+22
-22
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789651044,
|
"lastModified": 1790862821,
|
||||||
"narHash": "sha256-4VAbiqMJXW/fgdMqDN4+03PkCa3xRnWi4zEX9J6/QRk=",
|
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "bc71039666a17100c49e67a93e1d7a15f31dee47",
|
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789563672,
|
"lastModified": 1790861130,
|
||||||
"narHash": "sha256-05W7P4/pqgpliOA/HyZM6IkKd6ojI8L5X/x9tgthckI=",
|
"narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "88142f2dc8f2727eec89047694ec8000f92ce542",
|
"rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -56,11 +56,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789885152,
|
"lastModified": 1790750587,
|
||||||
"narHash": "sha256-mDNo95riUw8k0MA2Btzj/OuiMcfLKofqIliLYsLbrSY=",
|
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6d663c0533ff269008fb84e45930151e37c99db9",
|
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -72,11 +72,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789921291,
|
"lastModified": 1790822859,
|
||||||
"narHash": "sha256-Ft/BRnIqw1MywFoXydKobjjWmDFgDdYtSpJliE8+yUw=",
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "44a91898084f46797b5fac650c7e8c9ac38c43d4",
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -104,11 +104,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_4": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789921291,
|
"lastModified": 1790822859,
|
||||||
"narHash": "sha256-Ft/BRnIqw1MywFoXydKobjjWmDFgDdYtSpJliE8+yUw=",
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "44a91898084f46797b5fac650c7e8c9ac38c43d4",
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -125,11 +125,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1789961561,
|
"lastModified": 1790541651,
|
||||||
"narHash": "sha256-2lRgE8XsmXIVmiujmHVaCy5TrBUSblILNT5vYOj/Ed0=",
|
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "bcb5f577a36565bda07d7b81346b73cad8618d16",
|
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -152,11 +152,11 @@
|
|||||||
"nixpkgs": "nixpkgs_4"
|
"nixpkgs": "nixpkgs_4"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1790031910,
|
"lastModified": 1790877969,
|
||||||
"narHash": "sha256-aqwWD6FfKxhF7edo/ryn2Um8yMCr/MN5HXNIot4KB2k=",
|
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
|
||||||
"owner": "naturallaw777",
|
"owner": "naturallaw777",
|
||||||
"repo": "Sovran_Bitcoin",
|
"repo": "Sovran_Bitcoin",
|
||||||
"rev": "4acd272bec53cb88300535d6238e72ad20bd90f1",
|
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+1
-1
@@ -471,7 +471,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# Role cards
|
# Role cards
|
||||||
roles = [
|
roles = [
|
||||||
("Server + Desktop",
|
("Server + Desktop",
|
||||||
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer.",
|
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer. Heads-up: this makes your home IP address public.",
|
||||||
"Server+Desktop"),
|
"Server+Desktop"),
|
||||||
("Desktop Only",
|
("Desktop Only",
|
||||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ EOF
|
|||||||
EOF
|
EOF
|
||||||
''}
|
''}
|
||||||
|
|
||||||
|
# ── LAN-only guard ──────────────────────────────
|
||||||
|
# The Hub, RTL and Mempool sites below are meant for this home network
|
||||||
|
# only. Forwarding ports 80/443 on the router also lets other clients
|
||||||
|
# reach Caddy, so these sites check where a request comes from, not just
|
||||||
|
# which Host it asks for. Anyone else gets the connection closed.
|
||||||
|
# private_ranges 10/8, 172.16/12, 192.168/16, 127/8, fd00::/8, ::1
|
||||||
|
# 100.64.0.0/10 Tailscale and other VPN addresses
|
||||||
|
# 169.254.0.0/16, fe80::/10, fc00::/7 link-local and unique-local
|
||||||
|
# 2000::/3 IPv6 global addresses. Computers on this network
|
||||||
|
# often connect over their own global address, which
|
||||||
|
# looks the same as one from the internet, so IPv6
|
||||||
|
# global addresses are not filtered.
|
||||||
|
cat >> /run/caddy/Caddyfile <<'EOF'
|
||||||
|
|
||||||
|
(sovran_lan_only) {
|
||||||
|
@outside not remote_ip private_ranges 100.64.0.0/10 169.254.0.0/16 fe80::/10 fc00::/7 2000::/3
|
||||||
|
abort @outside
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
# ── Matrix ──────────────────────────────────────
|
# ── Matrix ──────────────────────────────────────
|
||||||
if [ -n "$MATRIX" ]; then
|
if [ -n "$MATRIX" ]; then
|
||||||
if [ -f /run/caddy/element-calling.snippet ]; then
|
if [ -f /run/caddy/element-calling.snippet ]; then
|
||||||
@@ -206,6 +226,7 @@ EOF
|
|||||||
cat >> /run/caddy/Caddyfile <<EOF
|
cat >> /run/caddy/Caddyfile <<EOF
|
||||||
|
|
||||||
http://sovransystemsos.local {
|
http://sovransystemsos.local {
|
||||||
|
import sovran_lan_only
|
||||||
reverse_proxy localhost:8937
|
reverse_proxy localhost:8937
|
||||||
header {
|
header {
|
||||||
Clear-Site-Data "\"cache\""
|
Clear-Site-Data "\"cache\""
|
||||||
@@ -220,6 +241,7 @@ EOF
|
|||||||
cat >> /run/caddy/Caddyfile <<EOF
|
cat >> /run/caddy/Caddyfile <<EOF
|
||||||
|
|
||||||
:3051 {
|
:3051 {
|
||||||
|
import sovran_lan_only
|
||||||
reverse_proxy :3050
|
reverse_proxy :3050
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
}
|
}
|
||||||
@@ -229,6 +251,7 @@ EOF
|
|||||||
cat >> /run/caddy/Caddyfile <<EOF
|
cat >> /run/caddy/Caddyfile <<EOF
|
||||||
|
|
||||||
:60847 {
|
:60847 {
|
||||||
|
import sovran_lan_only
|
||||||
reverse_proxy :60845
|
reverse_proxy :60845
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-89
@@ -1,43 +1,64 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
|
# The public-IP detector (STUN / OpenDNS / HTTPS echo) is gone: the public
|
||||||
|
# address is whatever Njal.la reports back for the DDNS update below, and
|
||||||
|
# nothing else on the system looks it up. Fail with a pointer, instead of
|
||||||
|
# silently ignoring them, if a custom.nix still sets one of its old options.
|
||||||
|
imports = map (opt:
|
||||||
|
lib.mkRemovedOptionModule [ "sovran_systemsOS" "publicIP" opt ]
|
||||||
|
"Sovran no longer looks up the public IP: the Njal.la DDNS update reports it (modules/core/njalla.nix). To force an address for Element Calling, set sovran_systemsOS.elementCalling.externalIP."
|
||||||
|
) [ "stunServer" "stunPort" "dnsResolver" "httpsEcho" "cacheTTL" ];
|
||||||
|
|
||||||
# ── Ensure njalla directory exists on every build ────────────────────────
|
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/njalla 0750 root root -"
|
"d /var/lib/njalla 0750 root root -"
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
# ── Install the DDNS runner and the validator it shares with the Hub ─────
|
||||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
# Both files come straight from the Hub's source tree and are installed side
|
||||||
# is installed here as a read-only system file. The DDNS runner imports it
|
# by side as read-only system files. The runner imports the exact same
|
||||||
# directly so the two code paths share one validator — no weaker inline copy.
|
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
|
||||||
environment.etc."sovran/security_helpers.py" = {
|
environment.etc."sovran/security_helpers.py" = {
|
||||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||||
mode = "0444";
|
mode = "0444";
|
||||||
user = "root";
|
user = "root";
|
||||||
group = "root";
|
group = "root";
|
||||||
};
|
};
|
||||||
|
environment.etc."sovran/ddns-update.py" = {
|
||||||
|
source = ../../app/sovran_systemsos_web/ddns_update.py;
|
||||||
|
mode = "0444";
|
||||||
|
user = "root";
|
||||||
|
group = "root";
|
||||||
|
};
|
||||||
|
|
||||||
# ── Safe DDNS update service ─────────────────────────────────────────────
|
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||||
# invokes curl directly — no shell interpolation, no script execution.
|
# invokes curl directly — no shell interpolation, no script execution.
|
||||||
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
# Njal.la is asked to use the address the request came from ("&auto") and
|
||||||
|
# reports it back; the runner saves it to /var/lib/secrets/external-ip,
|
||||||
|
# where LiveKit and the Hub read it. See app/sovran_systemsos_web/ddns_update.py.
|
||||||
systemd.services.sovran-ddns-update = {
|
systemd.services.sovran-ddns-update = {
|
||||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
|
# curl is not in a NixOS unit's default PATH (coreutils, findutils, grep,
|
||||||
|
# sed, systemd): without this the runner cannot start it.
|
||||||
|
path = [ pkgs.curl ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = "root";
|
User = "root";
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
|
||||||
# Harden the service — it only needs network access and read access to
|
# Harden the service — it needs network access, the URL store, and the
|
||||||
# /var/lib/njalla/ddns_urls.json.
|
# file that receives the reported address.
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
# AF_UNIX: name lookups can go through nscd / systemd-resolved sockets.
|
||||||
|
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,86 +73,9 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Install the Python runner script at build time so the service can find it.
|
# The runner used to be written to /var/lib/sovran by this activation script,
|
||||||
# The script is owned by root and not world-writable.
|
# next to the old public-ip.py detector. Remove those stale copies.
|
||||||
# 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 = ''
|
system.activationScripts.sovran-ddns-update-script = ''
|
||||||
install -d -m 0755 /var/lib/sovran
|
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
|
||||||
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(1) # validator missing — fail so systemd logs the misconfiguration
|
|
||||||
|
|
||||||
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 via the shared detector — one script, one cache
|
|
||||||
# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py).
|
|
||||||
# The detector refreshes /var/lib/secrets/external-ip, which the Hub and
|
|
||||||
# LiveKit read as well, so the whole system shares a single detected value.
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
|
||||||
capture_output=True, text=True, timeout=20,
|
|
||||||
)
|
|
||||||
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:
|
|
||||||
# Last resort: the shared cache file, if the detector is unavailable.
|
|
||||||
try:
|
|
||||||
with open("/var/lib/secrets/external-ip") as f:
|
|
||||||
raw = f.read().strip()
|
|
||||||
ipaddress.ip_address(raw)
|
|
||||||
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 Exception:
|
|
||||||
pass
|
|
||||||
PYEOF
|
|
||||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
# ── Unified public-IP detection (privacy-first) ─────────────────────────────
|
|
||||||
#
|
|
||||||
# One script, one cache file, every consumer on the system reads the same
|
|
||||||
# value. Previously the public IP was detected independently in three places,
|
|
||||||
# each phoning home to a different third party:
|
|
||||||
# * the Hub (server.py _get_external_ip) → api.ipify.org / ifconfig.me /
|
|
||||||
# icanhazip.com over HTTPS on every /api/network call and every
|
|
||||||
# background-loop tick
|
|
||||||
# * DDNS (ddns-update.py) → myip.opendns.com via OpenDNS
|
|
||||||
# * LiveKit → STUN (its own embedded detection)
|
|
||||||
#
|
|
||||||
# This module replaces all of that with a single script
|
|
||||||
# (/var/lib/sovran/public-ip.py) that detects the IP once per TTL using the
|
|
||||||
# least-exposing mechanism available, and caches it in
|
|
||||||
# /var/lib/secrets/external-ip. Consumers (Hub, DDNS, LiveKit) read the cache
|
|
||||||
# and only invoke the script when it is missing or stale.
|
|
||||||
#
|
|
||||||
# Detection chain (first success wins, stops immediately):
|
|
||||||
# 1. pin — sovran_systemsOS.elementCalling.externalIP (baked in)
|
|
||||||
# 2. cache — /var/lib/secrets/external-ip if newer than cacheTTL
|
|
||||||
# 3. STUN — UDP binding request (one packet, no application data,
|
|
||||||
# no HTTP metadata; the same protocol every WebRTC client
|
|
||||||
# uses). Server configurable via publicIP.stunServer.
|
|
||||||
# 4. DNS — "myip.opendns.com" A query via publicIP.dnsResolver
|
|
||||||
# (single DNS query, no HTTP headers)
|
|
||||||
# 5. HTTPS echo — ONLY endpoints listed in publicIP.httpsEcho (empty by
|
|
||||||
# default → never contacted)
|
|
||||||
#
|
|
||||||
# Privacy property: while the cache is fresh, zero third parties are
|
|
||||||
# contacted. When detection runs, at most ONE party learns the IP per
|
|
||||||
# refresh interval (default 5 minutes), and the STUN/DNS mechanisms expose
|
|
||||||
# nothing beyond the bare address.
|
|
||||||
{
|
|
||||||
config,
|
|
||||||
pkgs,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
|
|
||||||
let
|
|
||||||
stunServer = config.sovran_systemsOS.publicIP.stunServer;
|
|
||||||
stunPort = config.sovran_systemsOS.publicIP.stunPort;
|
|
||||||
dnsResolver = config.sovran_systemsOS.publicIP.dnsResolver;
|
|
||||||
httpsEcho = config.sovran_systemsOS.publicIP.httpsEcho;
|
|
||||||
cacheTTL = config.sovran_systemsOS.publicIP.cacheTTL;
|
|
||||||
|
|
||||||
# Optional pin shared with element-calling (baked in at build time).
|
|
||||||
pin = if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else "";
|
|
||||||
|
|
||||||
echoList = lib.concatStringsSep "," (map (u: "'${u}'") httpsEcho);
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.sovran_systemsOS.publicIP = {
|
|
||||||
stunServer = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "stun.l.google.com";
|
|
||||||
description = ''
|
|
||||||
STUN server used to discover the public IP over UDP. STUN is the most
|
|
||||||
privacy-preserving detection mechanism: a single stateless packet,
|
|
||||||
no HTTP metadata. Only used when the cache is stale.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
stunPort = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 19302;
|
|
||||||
};
|
|
||||||
dnsResolver = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "resolver4.opendns.com";
|
|
||||||
description = ''
|
|
||||||
DNS resolver used as fallback (myip.opendns.com trick) when STUN is
|
|
||||||
unavailable (e.g. ISP blocks UDP egress). A single DNS query, no
|
|
||||||
HTTP headers.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
httpsEcho = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ ];
|
|
||||||
example = [ "https://api.ipify.org" ];
|
|
||||||
description = ''
|
|
||||||
OPT-IN HTTPS endpoints that return the caller's public IP as a bare
|
|
||||||
IPv4 literal. Each listed endpoint observes this server's public IP
|
|
||||||
and HTTP metadata every time detection runs. Empty by default — no
|
|
||||||
HTTPS echo service is ever contacted unless you add one here. This is
|
|
||||||
the last-resort fallback after STUN and DNS.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
cacheTTL = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 300;
|
|
||||||
description = "Seconds the detected public IP is cached before re-detection.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# ── Install the unified detector ──────────────────────────────────────────
|
|
||||||
# This module declares `options` above, so ALL configuration must go under
|
|
||||||
# the `config` attribute: NixOS forbids mixing bare top-level settings
|
|
||||||
# (like `system.*`) with the `options`/`config` keyword attributes in the
|
|
||||||
# same module. (Fixes: "Module ... has an unsupported attribute `system'".)
|
|
||||||
config.system.activationScripts.sovranPublicIpInstall = lib.stringAfter [ "users" ] ''
|
|
||||||
install -d -m 0755 /var/lib/sovran
|
|
||||||
cat > /var/lib/sovran/public-ip.py <<'PYEOF'
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""sovran-public-ip — one detector, one cache, every consumer reads the same IP.
|
|
||||||
|
|
||||||
Privacy-first detection chain (first success wins):
|
|
||||||
1. pin — baked in from sovran_systemsOS.elementCalling.externalIP
|
|
||||||
2. cache — /var/lib/secrets/external-ip if newer than CACHE_TTL seconds
|
|
||||||
3. STUN — UDP binding request (one packet, no application data)
|
|
||||||
4. DNS — myip.opendns.com A query via the configured resolver
|
|
||||||
5. HTTPS — ONLY endpoints baked in from publicIP.httpsEcho (opt-in)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
public-ip.py check print current public IP (cache first; refresh if stale)
|
|
||||||
public-ip.py refresh force re-detection, update the cache file, print IP
|
|
||||||
|
|
||||||
Exit status: 0 with the IP on stdout on success; 1 if no IP is available
|
|
||||||
(cached value, if any, is still printed to stdout with a warning on stderr).
|
|
||||||
"""
|
|
||||||
import ipaddress
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
CACHE_FILE = "/var/lib/secrets/external-ip"
|
|
||||||
PIN = "${pin}"
|
|
||||||
STUN_SERVER = "${stunServer}"
|
|
||||||
STUN_PORT = ${toString stunPort}
|
|
||||||
DNS_RESOLVER = "${dnsResolver}"
|
|
||||||
DNS_HOST = "myip.opendns.com"
|
|
||||||
ECHO_URLS = [ ${echoList} ]
|
|
||||||
CACHE_TTL = ${toString cacheTTL}
|
|
||||||
TIMEOUT = 3.0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Detection primitives
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_usable_ip(text: str) -> bool:
|
|
||||||
"""True if text is a globally routable IPv4 that LiveKit may advertise."""
|
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(text)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
if ip.version != 4:
|
|
||||||
return False
|
|
||||||
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast
|
|
||||||
or ip.is_reserved or ip.is_unspecified or not ip.is_global):
|
|
||||||
return False
|
|
||||||
# RFC 6598 shared (CGNAT) space — not reachable from the internet.
|
|
||||||
if ip in ipaddress.ip_network("100.64.0.0/10"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def stun_public_ip() -> str | None:
|
|
||||||
"""RFC 5389 Binding request over UDP; returns the mapped (public) IPv4."""
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.settimeout(TIMEOUT)
|
|
||||||
try:
|
|
||||||
txid = random.randbytes(12)
|
|
||||||
req = struct.pack("!HHI", 0x0001, 0, 0) + txid # Binding request
|
|
||||||
sock.sendto(req, (STUN_SERVER, STUN_PORT))
|
|
||||||
data, _ = sock.recvfrom(2048)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
if len(data) < 20:
|
|
||||||
return None
|
|
||||||
mtype, _mlen = struct.unpack("!HH", data[:4])
|
|
||||||
if mtype != 0x0101: # Binding success response
|
|
||||||
return None
|
|
||||||
|
|
||||||
cookie = data[4:8]
|
|
||||||
i = 20
|
|
||||||
while i + 4 <= len(data):
|
|
||||||
atype, alen = struct.unpack("!HH", data[i : i + 4])
|
|
||||||
aval = data[i + 4 : i + 4 + alen]
|
|
||||||
if atype in (0x0001, 0x0020) and len(aval) >= 8: # MAPPED / XOR-MAPPED
|
|
||||||
family = aval[1]
|
|
||||||
if family == 0x01: # IPv4
|
|
||||||
raw = aval[4:8]
|
|
||||||
if atype == 0x0020: # XOR with magic cookie + txid prefix
|
|
||||||
raw = bytes(b ^ c for b, c in zip(raw, cookie + txid[:4]))
|
|
||||||
return socket.inet_ntop(socket.AF_INET, raw)
|
|
||||||
i += 4 + ((alen + 3) // 4) * 4
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def dns_public_ip() -> str | None:
|
|
||||||
"""Minimal DNS A query for myip.opendns.com against the given resolver."""
|
|
||||||
qid = random.randint(0, 0xFFFF)
|
|
||||||
qname = b"".join(bytes([len(p)]) + p.encode() for p in DNS_HOST.split(".")) + b"\x00"
|
|
||||||
query = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + qname + struct.pack("!HH", 1, 1)
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.settimeout(TIMEOUT)
|
|
||||||
try:
|
|
||||||
sock.sendto(query, (DNS_RESOLVER, 53))
|
|
||||||
data, _ = sock.recvfrom(4096)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if len(data) < 12:
|
|
||||||
return None
|
|
||||||
rid, _flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", data[:12])
|
|
||||||
if rid != qid or an == 0:
|
|
||||||
return None
|
|
||||||
i = 12
|
|
||||||
for _ in range(_qd): # skip question
|
|
||||||
while data[i] != 0:
|
|
||||||
i += 1 + data[i]
|
|
||||||
i += 5
|
|
||||||
for _ in range(an):
|
|
||||||
if data[i] & 0xC0 == 0xC0:
|
|
||||||
i += 2
|
|
||||||
else:
|
|
||||||
while data[i] != 0:
|
|
||||||
i += 1 + data[i]
|
|
||||||
i += 1
|
|
||||||
rtype, _rclass, _ttl, rdlen = struct.unpack("!HHIH", data[i : i + 10])
|
|
||||||
i += 10
|
|
||||||
if rtype == 1 and rdlen == 4:
|
|
||||||
return socket.inet_ntop(socket.AF_INET, data[i : i + 4])
|
|
||||||
i += rdlen
|
|
||||||
except (IndexError, struct.error):
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def echo_public_ip() -> str | None:
|
|
||||||
"""Opt-in HTTPS echo endpoints (baked in at build time; empty by default)."""
|
|
||||||
for url in ECHO_URLS:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "sovran-public-ip"})
|
|
||||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
||||||
text = resp.read().decode().strip()
|
|
||||||
if is_usable_ip(text):
|
|
||||||
return text
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cache handling
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def read_cache() -> str:
|
|
||||||
try:
|
|
||||||
with open(CACHE_FILE) as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def write_cache(ip: str) -> None:
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
|
||||||
tmp = f"{CACHE_FILE}.tmp"
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
f.write(ip + "\n")
|
|
||||||
os.replace(tmp, CACHE_FILE)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def cache_fresh() -> bool:
|
|
||||||
try:
|
|
||||||
return time.time() - os.path.getmtime(CACHE_FILE) < CACHE_TTL
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def detect() -> str:
|
|
||||||
"""Run the chain; returns usable IP or an empty string."""
|
|
||||||
if PIN and is_usable_ip(PIN):
|
|
||||||
return PIN
|
|
||||||
for fn in (stun_public_ip, dns_public_ip, echo_public_ip):
|
|
||||||
try:
|
|
||||||
cand = fn()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if cand and is_usable_ip(cand):
|
|
||||||
return cand
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
force = len(sys.argv) > 1 and sys.argv[1] == "refresh"
|
|
||||||
ip = ""
|
|
||||||
if not force and cache_fresh():
|
|
||||||
ip = read_cache()
|
|
||||||
if not ip:
|
|
||||||
ip = detect()
|
|
||||||
if ip:
|
|
||||||
write_cache(ip)
|
|
||||||
else:
|
|
||||||
stale = read_cache()
|
|
||||||
if stale:
|
|
||||||
print(stale)
|
|
||||||
print("WARNING: detection failed; using last known public IP", file=sys.stderr)
|
|
||||||
return 0
|
|
||||||
print("ERROR: could not determine a public IP (STUN/DNS unreachable)", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(ip)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
PYEOF
|
|
||||||
chmod 0555 /var/lib/sovran/public-ip.py
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -107,9 +107,11 @@
|
|||||||
description = ''
|
description = ''
|
||||||
Optional pin: force LiveKit to advertise this public IPv4 in its
|
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||||
host/TURN ICE candidates. Not required in normal operation — the
|
host/TURN ICE candidates. Not required in normal operation — the
|
||||||
module auto-detects the public IP at runtime (HTTPS egress
|
address is the one Njal.la reports for the DDNS update (set up in
|
||||||
detection, falling back to STUN). Set it only to override a
|
the Hub's Domains page), and nothing on this system looks it up
|
||||||
mis-detected address (e.g. multi-WAN/VPN setups).
|
anywhere else. Set it for a fixed public address with no Njal.la
|
||||||
|
DDNS entry, or to override the reported one (e.g. multi-WAN/VPN
|
||||||
|
setups).
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+41
-37
@@ -204,34 +204,36 @@ EOF
|
|||||||
# NAT with port-forwarding. It does not need to be assigned to this box,
|
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||||
# and it may be dynamic.
|
# and it may be dynamic.
|
||||||
#
|
#
|
||||||
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
# Nothing here looks the address up. Priority:
|
||||||
# modules/core/public-ip.nix) instead of running our own: one script,
|
|
||||||
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
|
||||||
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||||
# 2. /var/lib/secrets/external-ip (the shared cache)
|
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
|
||||||
# 3. run the detector now (it refreshes the cache)
|
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
|
||||||
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
# file only when the address changes, and livekit-external-ip.path
|
||||||
# warning — this is where broken installs used to silently end up
|
# then re-runs this script.
|
||||||
# advertising a private IP, causing "call connects but no video".
|
# With neither, or with an address that is not public, this unit fails with
|
||||||
|
# a clear message instead of guessing: advertising a wrong or private
|
||||||
|
# address is what produces "call connects but no video".
|
||||||
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
||||||
|
|
||||||
PUBLIC_IP="$EXTERNAL_IP"
|
PUBLIC_IP="$EXTERNAL_IP"
|
||||||
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||||
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||||
fi
|
fi
|
||||||
if [ -z "$PUBLIC_IP" ] && [ -x /var/lib/sovran/public-ip.py ]; then
|
|
||||||
PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1)
|
if [ -z "$PUBLIC_IP" ]; then
|
||||||
|
echo "ERROR: no public IP is known for LiveKit yet." >&2
|
||||||
|
echo "ERROR: It is recorded after the first successful Njal.la DDNS update (Hub, Domains)." >&2
|
||||||
|
echo "ERROR: To use a fixed address instead, set sovran_systemsOS.elementCalling.externalIP." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||||
# A detected/pinned address like this must never be advertised.
|
if printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||||
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
|
'^(0\.|127\.|10\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||||
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
|
||||||
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
exit 1
|
||||||
PUBLIC_IP=""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$PUBLIC_IP" ]; then
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
@@ -245,21 +247,6 @@ rtc:
|
|||||||
- $IFACE
|
- $IFACE
|
||||||
EOF
|
EOF
|
||||||
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||||
else
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
|
||||||
port: 7880
|
|
||||||
rtc:
|
|
||||||
use_external_ip: true
|
|
||||||
skip_external_ip_validation: true
|
|
||||||
advertise_internal_ip: true
|
|
||||||
tcp_port: 7881
|
|
||||||
udp_port: 7882
|
|
||||||
interfaces:
|
|
||||||
includes:
|
|
||||||
- $IFACE
|
|
||||||
EOF
|
|
||||||
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||||
# signature against the same key file it issues tokens with, and uses
|
# signature against the same key file it issues tokens with, and uses
|
||||||
@@ -414,15 +401,32 @@ EOF
|
|||||||
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
||||||
# configs (new domains, externalIP, full-access list), mirroring the domain
|
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||||
# change flow.
|
# change flow.
|
||||||
# Re-run the config generator and restart LiveKit when a rebuild regenerates
|
|
||||||
# the runtime config, or when the Hub persists a new external IP (dynamic
|
|
||||||
# WAN IPs), so the advertised ICE candidate stays current without a manual
|
|
||||||
# restart. The trigger chain: external-ip change → livekit-turn-setup
|
|
||||||
# re-runs → rewrites livekit.yaml → livekit restarts with the new config.
|
|
||||||
systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ];
|
|
||||||
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
||||||
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
||||||
|
|
||||||
|
# Follow a changing public IP. ddns-update.py rewrites
|
||||||
|
# /var/lib/secrets/external-ip only when Njal.la reports a different address;
|
||||||
|
# this path unit then re-runs livekit-turn-setup (new node_ip and TURN
|
||||||
|
# address) and starts LiveKit if it is not running, e.g. because no address
|
||||||
|
# was known yet at boot. restartTriggers cannot do this: it is evaluated when
|
||||||
|
# the system is built, so it cannot watch a file that changes at runtime.
|
||||||
|
systemd.paths.livekit-external-ip = {
|
||||||
|
description = "Watch the public IP recorded by the Njal.la DDNS runner";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
pathConfig.PathChanged = "/var/lib/secrets/external-ip";
|
||||||
|
};
|
||||||
|
systemd.services.livekit-external-ip = {
|
||||||
|
description = "Re-run LiveKit setup for a changed public IP";
|
||||||
|
serviceConfig.Type = "oneshot";
|
||||||
|
unitConfig.ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
|
script = ''
|
||||||
|
# livekit.service requires livekit-turn-setup, so it restarts with it.
|
||||||
|
systemctl restart livekit-turn-setup.service
|
||||||
|
# No-op if LiveKit is already running; starts it after an earlier failure.
|
||||||
|
systemctl start livekit.service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
####### PUBLIC REACHABILITY SELF-CHECK #######
|
####### PUBLIC REACHABILITY SELF-CHECK #######
|
||||||
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||||
# classic "call connects but no media" setup errors at boot instead of at
|
# classic "call connects but no media" setup errors at boot instead of at
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
./core/no-sleep.nix
|
./core/no-sleep.nix
|
||||||
./core/cpu-performance.nix
|
./core/cpu-performance.nix
|
||||||
./core/local-domain-loopback.nix
|
./core/local-domain-loopback.nix
|
||||||
./core/public-ip.nix
|
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Guards for the LAN-only Caddy sites.
|
||||||
|
|
||||||
|
The Hub (http://sovransystemsos.local), Ride The Lightning (:3051) and Mempool
|
||||||
|
(:60847) sites are for the home network. Caddy has to check where a request comes
|
||||||
|
from because forwarding ports 80/443 on the router lets other clients reach it too.
|
||||||
|
The domain sites for the operator's own public services must stay public.
|
||||||
|
|
||||||
|
These read modules/core/caddy.nix like the nix-file checks in test_security.py:
|
||||||
|
nothing is run and nothing touches the network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
# What Caddy's "private_ranges" shortcut expands to (see the remote_ip matcher docs).
|
||||||
|
_PRIVATE_RANGES = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8",
|
||||||
|
"127.0.0.1/8", "fd00::/8", "::1"]
|
||||||
|
|
||||||
|
_LAN_SITES = ("http://sovransystemsos.local", ":3051", ":60847")
|
||||||
|
|
||||||
|
|
||||||
|
def _read(*parts):
|
||||||
|
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _site(src, address):
|
||||||
|
m = re.search(r"^" + re.escape(address) + r" \{\n(.*?)^\}$", src, re.S | re.M)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _snippet(src):
|
||||||
|
m = re.search(r"^\(sovran_lan_only\) \{\n(.*?)^\}$", src, re.S | re.M)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_networks(snippet):
|
||||||
|
m = re.search(r"^\s*@outside not remote_ip (.+)$", snippet, re.M)
|
||||||
|
assert m, "the @outside matcher is missing"
|
||||||
|
nets = []
|
||||||
|
for token in m.group(1).split():
|
||||||
|
for cidr in (_PRIVATE_RANGES if token == "private_ranges" else [token]):
|
||||||
|
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||||
|
return nets
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed(nets, address):
|
||||||
|
ip = ipaddress.ip_address(address)
|
||||||
|
return any(ip.version == n.version and ip in n for n in nets)
|
||||||
|
|
||||||
|
|
||||||
|
class LanOnlySites(unittest.TestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.src = _read("modules", "core", "caddy.nix")
|
||||||
|
|
||||||
|
def test_lan_sites_use_the_guard_before_they_proxy(self):
|
||||||
|
for address in _LAN_SITES:
|
||||||
|
with self.subTest(site=address):
|
||||||
|
body = _site(self.src, address)
|
||||||
|
self.assertIsNotNone(body, f"{address} site not found")
|
||||||
|
self.assertIn("import sovran_lan_only", body)
|
||||||
|
self.assertLess(body.index("import sovran_lan_only"),
|
||||||
|
body.index("reverse_proxy"))
|
||||||
|
|
||||||
|
def test_only_the_lan_sites_use_the_guard(self):
|
||||||
|
self.assertEqual(self.src.count("import sovran_lan_only"), len(_LAN_SITES))
|
||||||
|
public = re.findall(r"^\$[A-Z]+ \{\n(.*?)^\}$", self.src, re.S | re.M)
|
||||||
|
self.assertGreaterEqual(len(public), 6, "domain sites not found")
|
||||||
|
for body in public:
|
||||||
|
self.assertNotIn("sovran_lan_only", body)
|
||||||
|
# the Matrix site that Element calling writes instead of the plain one
|
||||||
|
self.assertNotIn("sovran_lan_only", _read("modules", "element-calling.nix"))
|
||||||
|
|
||||||
|
def test_guard_closes_the_connection_for_everyone_else(self):
|
||||||
|
snippet = _snippet(self.src)
|
||||||
|
self.assertIsNotNone(snippet, "(sovran_lan_only) snippet not found")
|
||||||
|
self.assertRegex(snippet, r"(?m)^\s*abort @outside\s*$")
|
||||||
|
# defined before the first site that imports it
|
||||||
|
self.assertLess(self.src.index("(sovran_lan_only) {"),
|
||||||
|
self.src.index("import sovran_lan_only"))
|
||||||
|
|
||||||
|
def test_guard_ranges(self):
|
||||||
|
nets = _allowed_networks(_snippet(self.src))
|
||||||
|
for address in ("127.0.0.1", "::1", "10.0.0.1", "172.16.0.1", "172.31.255.254",
|
||||||
|
"192.168.1.10", "100.64.0.1", "100.127.255.254", "169.254.1.1",
|
||||||
|
"fd12:3456::1", "fe80::1", "fc00::1"):
|
||||||
|
with self.subTest(allowed=address):
|
||||||
|
self.assertTrue(_is_allowed(nets, address))
|
||||||
|
for address in ("8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255", "11.0.0.1",
|
||||||
|
"172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255",
|
||||||
|
"100.128.0.1", "169.253.255.255"):
|
||||||
|
with self.subTest(refused=address):
|
||||||
|
self.assertFalse(_is_allowed(nets, address))
|
||||||
|
|
||||||
|
def test_ipv6_global_addresses_are_not_filtered(self):
|
||||||
|
# Computers on the home network often connect over their own global IPv6
|
||||||
|
# address, which cannot be told apart from the internet's by address alone.
|
||||||
|
# If this is ever tightened, LAN clients on IPv6 networks lose the Hub.
|
||||||
|
nets = _allowed_networks(_snippet(self.src))
|
||||||
|
self.assertTrue(_is_allowed(nets, "2001:db8::5"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Tests for the DDNS runner (sovran_systemsos_web.ddns_update).
|
||||||
|
|
||||||
|
The runner asks Njal.la to use the address the request came from ("&auto"),
|
||||||
|
reads back the address Njal.la recorded and saves it for LiveKit and the Hub.
|
||||||
|
|
||||||
|
Tests must never:
|
||||||
|
- access the network (curl is replaced by a fake ``run``)
|
||||||
|
- write to system paths (the URL and IP files live in a temp dir)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||||
|
if _APP_PARENT not in sys.path:
|
||||||
|
sys.path.insert(0, _APP_PARENT)
|
||||||
|
|
||||||
|
from sovran_systemsos_web import ddns_update as d # noqa: E402
|
||||||
|
|
||||||
|
KEY = "SECRETKEY123"
|
||||||
|
AUTO_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&auto"
|
||||||
|
LEGACY_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&a=${{IP}}"
|
||||||
|
PUBLIC_IP = "93.184.216.34"
|
||||||
|
OTHER_IP = "8.8.4.4"
|
||||||
|
|
||||||
|
|
||||||
|
def reply(ip=PUBLIC_IP, status=200):
|
||||||
|
return json.dumps({"status": status, "message": "record updated", "value": {"A": ip}})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRun:
|
||||||
|
"""Stands in for subprocess.run; records every command it is given."""
|
||||||
|
|
||||||
|
def __init__(self, stdout=None, returncode=0, raises=None):
|
||||||
|
self.stdout = reply() if stdout is None else stdout
|
||||||
|
self.returncode = returncode
|
||||||
|
self.raises = raises
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def __call__(self, cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if self.raises:
|
||||||
|
raise self.raises
|
||||||
|
return subprocess.CompletedProcess(cmd, self.returncode, stdout=self.stdout, stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
class NormaliseUrlTests(unittest.TestCase):
|
||||||
|
def test_legacy_placeholder_becomes_auto(self):
|
||||||
|
self.assertEqual(d.normalise_url(LEGACY_URL), AUTO_URL)
|
||||||
|
|
||||||
|
def test_quiet_is_dropped_so_the_reply_can_be_read(self):
|
||||||
|
self.assertEqual(d.normalise_url(AUTO_URL + "&quiet"), AUTO_URL)
|
||||||
|
|
||||||
|
def test_plain_auto_is_unchanged(self):
|
||||||
|
self.assertEqual(d.normalise_url(AUTO_URL), AUTO_URL)
|
||||||
|
|
||||||
|
def test_explicit_address_is_unchanged(self):
|
||||||
|
url = "https://njal.la/update/?h=a.example.com&k=K&a=93.184.216.34"
|
||||||
|
self.assertEqual(d.normalise_url(url), url)
|
||||||
|
|
||||||
|
|
||||||
|
class IsPublicIpv4Tests(unittest.TestCase):
|
||||||
|
def test_public_addresses(self):
|
||||||
|
for ip in ("93.184.216.34", "8.8.8.8", " 1.1.1.1\n"):
|
||||||
|
self.assertTrue(d.is_public_ipv4(ip), ip)
|
||||||
|
|
||||||
|
def test_everything_else_is_rejected(self):
|
||||||
|
for ip in ("10.0.0.1", "192.168.1.5", "172.16.0.9", "127.0.0.1", "169.254.1.1",
|
||||||
|
"100.64.0.1", "0.0.0.0", "224.0.0.1", "::1", "2001:4860:4860::8888",
|
||||||
|
"not-an-ip", "", None):
|
||||||
|
self.assertFalse(d.is_public_ipv4(ip), ip)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseReplyTests(unittest.TestCase):
|
||||||
|
def test_success_returns_the_recorded_address(self):
|
||||||
|
self.assertEqual(d.parse_reply(reply()), PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_status_may_be_a_string(self):
|
||||||
|
self.assertEqual(d.parse_reply(reply(status="200")), PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_error_status_is_rejected(self):
|
||||||
|
body = json.dumps({"status": 401, "message": "invalid host or key"})
|
||||||
|
self.assertIsNone(d.parse_reply(body))
|
||||||
|
|
||||||
|
def test_garbage_is_rejected(self):
|
||||||
|
for body in ("", "not json", "[]", "null", "{}", json.dumps({"status": 200})):
|
||||||
|
self.assertIsNone(d.parse_reply(body), body)
|
||||||
|
|
||||||
|
def test_non_public_or_non_ipv4_address_is_rejected(self):
|
||||||
|
for ip in ("10.1.2.3", "100.64.9.9", "127.0.0.1", "::1", "2001:4860:4860::8888", "x"):
|
||||||
|
self.assertIsNone(d.parse_reply(reply(ip)), ip)
|
||||||
|
|
||||||
|
|
||||||
|
class IpFileTests(unittest.TestCase):
|
||||||
|
def test_write_then_read(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "secrets", "external-ip")
|
||||||
|
d.write_ip_file(PUBLIC_IP, path)
|
||||||
|
self.assertEqual(d.read_ip_file(path), PUBLIC_IP)
|
||||||
|
self.assertEqual(open(path).read(), PUBLIC_IP) # no trailing newline
|
||||||
|
self.assertEqual(os.stat(path).st_mode & 0o777, 0o644)
|
||||||
|
|
||||||
|
def test_replace_is_atomic_and_leaves_no_temp_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "external-ip")
|
||||||
|
d.write_ip_file(PUBLIC_IP, path)
|
||||||
|
d.write_ip_file(OTHER_IP, path)
|
||||||
|
self.assertEqual(d.read_ip_file(path), OTHER_IP)
|
||||||
|
self.assertEqual(os.listdir(tmp), ["external-ip"])
|
||||||
|
|
||||||
|
def test_missing_file_reads_as_none(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self.assertIsNone(d.read_ip_file(os.path.join(tmp, "nope")))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAllTests(unittest.TestCase):
|
||||||
|
def run_update(self, urls, fake):
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
result = d.update_all(urls, run=fake)
|
||||||
|
return result, out.getvalue()
|
||||||
|
|
||||||
|
def test_curl_is_called_directly_with_ipv4_and_no_redirects(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
result, _ = self.run_update([AUTO_URL], fake)
|
||||||
|
self.assertEqual(result, PUBLIC_IP)
|
||||||
|
self.assertEqual(len(fake.calls), 1)
|
||||||
|
cmd = fake.calls[0]
|
||||||
|
self.assertEqual(cmd[0], "curl")
|
||||||
|
for flag in ("--ipv4", "--no-location", "--fail", "--silent"):
|
||||||
|
self.assertIn(flag, cmd)
|
||||||
|
self.assertEqual(cmd[-1], AUTO_URL)
|
||||||
|
|
||||||
|
def test_legacy_entry_and_its_auto_twin_are_one_call(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
self.run_update([LEGACY_URL, AUTO_URL], fake)
|
||||||
|
self.assertEqual(fake.calls[0][-1], AUTO_URL)
|
||||||
|
self.assertEqual(len(fake.calls), 1)
|
||||||
|
|
||||||
|
def test_url_for_another_host_is_never_called(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
result, _ = self.run_update([f"https://evil.example/update/?h=x&k={KEY}&auto"], fake)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
self.assertEqual(fake.calls, [])
|
||||||
|
|
||||||
|
def test_failed_curl_yields_nothing(self):
|
||||||
|
result, _ = self.run_update([AUTO_URL], FakeRun(returncode=22))
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_reply_without_an_address_yields_nothing(self):
|
||||||
|
result, _ = self.run_update([AUTO_URL], FakeRun(stdout=json.dumps({"status": 200})))
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_missing_curl_is_survived(self):
|
||||||
|
result, out = self.run_update([AUTO_URL], FakeRun(raises=FileNotFoundError("curl")))
|
||||||
|
self.assertIsNone(result)
|
||||||
|
self.assertIn("skipped", out)
|
||||||
|
|
||||||
|
def test_first_reported_address_wins(self):
|
||||||
|
calls = iter([reply(PUBLIC_IP), reply(OTHER_IP)])
|
||||||
|
|
||||||
|
def fake(cmd, **kwargs):
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, stdout=next(calls), stderr="")
|
||||||
|
|
||||||
|
other = f"https://njal.la/update/?h=other.example.com&k={KEY}&auto"
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
result = d.update_all([AUTO_URL, other], run=fake)
|
||||||
|
self.assertEqual(result, PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_the_key_is_never_printed(self):
|
||||||
|
for fake in (FakeRun(), FakeRun(returncode=22), FakeRun(stdout="junk"),
|
||||||
|
FakeRun(raises=FileNotFoundError("curl"))):
|
||||||
|
_, out = self.run_update([AUTO_URL, LEGACY_URL], fake)
|
||||||
|
self.assertNotIn(KEY, out)
|
||||||
|
|
||||||
|
|
||||||
|
class MainTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.urls_file = os.path.join(self.tmp.name, "ddns_urls.json")
|
||||||
|
self.ip_file = os.path.join(self.tmp.name, "secrets", "external-ip")
|
||||||
|
for patch in (mock.patch.object(d, "URLS_FILE", self.urls_file),
|
||||||
|
mock.patch.object(d, "IP_FILE", self.ip_file)):
|
||||||
|
patch.start()
|
||||||
|
self.addCleanup(patch.stop)
|
||||||
|
|
||||||
|
def store(self, urls):
|
||||||
|
with open(self.urls_file, "w") as f:
|
||||||
|
json.dump(urls, f)
|
||||||
|
|
||||||
|
def main(self, fake):
|
||||||
|
out = io.StringIO()
|
||||||
|
with mock.patch.object(d.subprocess, "run", fake), contextlib.redirect_stdout(out):
|
||||||
|
code = d.main()
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
def test_first_update_records_the_address(self):
|
||||||
|
self.store([LEGACY_URL])
|
||||||
|
out = self.main(FakeRun())
|
||||||
|
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
|
||||||
|
self.assertIn("now " + PUBLIC_IP, out)
|
||||||
|
self.assertNotIn(KEY, out)
|
||||||
|
|
||||||
|
def test_unchanged_address_does_not_touch_the_file(self):
|
||||||
|
# A path unit restarts LiveKit whenever the file is written, so an
|
||||||
|
# unchanged address must not rewrite it.
|
||||||
|
self.store([AUTO_URL])
|
||||||
|
self.main(FakeRun())
|
||||||
|
before = os.stat(self.ip_file)
|
||||||
|
out = self.main(FakeRun())
|
||||||
|
after = os.stat(self.ip_file)
|
||||||
|
self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns))
|
||||||
|
self.assertIn("unchanged", out)
|
||||||
|
|
||||||
|
def test_changed_address_is_recorded(self):
|
||||||
|
self.store([AUTO_URL])
|
||||||
|
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
|
||||||
|
out = self.main(FakeRun(stdout=reply(OTHER_IP)))
|
||||||
|
self.assertEqual(d.read_ip_file(self.ip_file), OTHER_IP)
|
||||||
|
self.assertIn(f"now {OTHER_IP} (was {PUBLIC_IP})", out)
|
||||||
|
|
||||||
|
def test_failed_update_keeps_the_last_known_address(self):
|
||||||
|
self.store([AUTO_URL])
|
||||||
|
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
|
||||||
|
self.main(FakeRun(returncode=7))
|
||||||
|
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_nothing_configured_does_nothing(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
self.main(fake) # no URL file at all
|
||||||
|
self.store([])
|
||||||
|
self.main(fake) # empty list
|
||||||
|
self.assertEqual(fake.calls, [])
|
||||||
|
self.assertFalse(os.path.exists(self.ip_file))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Guards for the home-IP warnings.
|
||||||
|
|
||||||
|
Server + Desktop publishes the home IP address (the domain points at it), so every
|
||||||
|
place that offers Server + Desktop must say so, and the README section they point
|
||||||
|
at must exist.
|
||||||
|
|
||||||
|
These read the shipped source files like the nix-file checks in test_security.py:
|
||||||
|
nothing is run and nothing touches the network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
_PHRASE = "home ip address"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(*parts):
|
||||||
|
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _github_slug(heading):
|
||||||
|
slug = re.sub(r"[^\w\- ]", "", heading.strip().lower())
|
||||||
|
return slug.replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeIpWarnings(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_installer_role_card(self):
|
||||||
|
src = _read("iso", "installer.py")
|
||||||
|
card = re.search(r'\("Server \+ Desktop",\s*"((?:[^"\\]|\\.)*)"', src, re.S)
|
||||||
|
self.assertIsNotNone(card, "Server + Desktop role card not found")
|
||||||
|
self.assertIn(_PHRASE, card.group(1).lower())
|
||||||
|
|
||||||
|
def test_hub_domain_setup_text(self):
|
||||||
|
# domain-prereqs.js is the single source for onboarding, feature setup
|
||||||
|
# and domain reconfiguration; the notice must follow every variant.
|
||||||
|
js = _read("app", "sovran_systemsos_web", "static", "js", "domain-prereqs.js")
|
||||||
|
body = js[js.index("function renderDomainNeedsHtml"):]
|
||||||
|
body = body[:body.index("\n}\n")]
|
||||||
|
self.assertIn(_PHRASE, body.lower())
|
||||||
|
self.assertGreater(body.lower().index(_PHRASE), body.rindex("} else {"),
|
||||||
|
"the notice must come after the last variant, not inside one")
|
||||||
|
|
||||||
|
def test_hub_upgrade_dialog(self):
|
||||||
|
html = _read("app", "sovran_systemsos_web", "templates", "index.html")
|
||||||
|
dialog = html[html.index('id="upgrade-modal"'):html.index("Security Reset overlay")]
|
||||||
|
self.assertIn(_PHRASE, " ".join(dialog.lower().split()))
|
||||||
|
|
||||||
|
def test_readme_and_security_policy(self):
|
||||||
|
self.assertIn(_PHRASE, _read("README.md").lower())
|
||||||
|
self.assertIn(_PHRASE, " ".join(_read("SECURITY.md").lower().split()))
|
||||||
|
|
||||||
|
def test_links_to_the_readme_section_resolve(self):
|
||||||
|
readme = _read("README.md")
|
||||||
|
slugs = {_github_slug(m.group(2))
|
||||||
|
for m in re.finditer(r"^(#{1,6})\s+(.+?)\s*$", readme, re.M)}
|
||||||
|
links = re.findall(r"\]\(#(server--desktop[^)]*)\)", readme)
|
||||||
|
links += re.findall(r"README\.md#(server--desktop[^)\s]*)", _read("SECURITY.md"))
|
||||||
|
self.assertTrue(links, "expected links to the home-IP section")
|
||||||
|
for anchor in links:
|
||||||
|
self.assertIn(anchor, slugs)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user