All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m10s
De knop Volgende deed niets. Alpine haalt een boolean-attribuut alleen weg bij null, undefined of false; het getal 0 zet het juist AAN. Mijn :disabled="stepMissing().length" gaf bij een ingevuld formulier 0 en zette de knop dus permanent uit. De logica klopte, de binding niet — met losse logica niet te vinden, dus er draait nu een test die de echte modal met Alpine in een DOM rendert en erop klikt. Tijdzones: - Nieuwe instelling TIMEZONE, standaard die van de server (TZ, /etc/timezone, /etc/localtime). In alle 77 sjablonen stond Europe/Amsterdam hard ingevuld. - Apps zonder tijdzoneveld krijgen TZ er automatisch bij; die draaiden in UTC. Poorten: - _bezette_poorten() telt nu ook mee waar de host zelf op luistert, gelezen uit /proc/net/tcp in een hulpcontainer met --network host. Een nginx buiten Docker zag Server Up eerder niet. - GET /api/ports/check geeft vrij/bezet plus een alternatief, en meldt of de host echt gecontroleerd kon worden. Netwerken: - APP_NETWORKS: meerdere gedeelde bridge-netwerken in plaats van één waar élke app aan hing. Per app kies je welke; aanmaken kan vanuit het invulmenu, met eigen naam of een voorstel (su-<appnaam>). - add_shared_network() accepteert een lijst; één naam blijft werken. - GET /api/networks/<n>/ip-check controleert subnet, bereik, Docker-toewijzing en de ARP-tabel van de host (vangt een fysiek apparaat met DHCP-adres) en stelt een vrij adres voor. Pangolin (nieuw, core/pangolin.py): - Publieke URL per app via een tunnel, zonder poorten open te zetten. - De API veranderde in 1.9 (resource hing onder een site, staat nu los); beide routes worden geprobeerd en bij een fout krijg je beide meldingen. - API-sleutel is write-only. Getest tegen een nagebouwde API, niet tegen een echte server. Nieuw: docs/pangolin.md; docs/netwerken.md uitgebreid. 2369 tests groen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""Tijdzone, vrije poorten, vrije IP-adressen en gedeelde netwerken.
|
|
|
|
Allemaal dingen waar Server Up de gebruiker eerder mee liet gokken: de tijdzone
|
|
stond in elk sjabloon hard op Europe/Amsterdam, poortcontrole keek alleen naar
|
|
Docker, en er was één gedeeld netwerk waar élke app aan hing.
|
|
"""
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
|
|
|
from conftest import login
|
|
|
|
LAN = {"name": "lan", "driver": "macvlan", "parent": "eth0",
|
|
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1",
|
|
"ip_range": "192.168.1.240/28"}
|
|
|
|
|
|
# ── Tijdzone ─────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("tz,geldig", [
|
|
("Europe/Amsterdam", True), ("UTC", True), ("America/Argentina/Salta", True),
|
|
("Etc/GMT+2", True),
|
|
("", False), ("geen; tijdzone", False), ("../../etc/passwd", False),
|
|
("a" * 80, False), ("Europe/Amsterdam; rm -rf /", False),
|
|
])
|
|
def test_tijdzonevorm(env, tz, geldig):
|
|
assert env["core"].geldige_tijdzone(tz) is geldig
|
|
|
|
|
|
def test_host_tijdzone_uit_de_omgeving(env, monkeypatch):
|
|
monkeypatch.setenv("TZ", "Asia/Tokyo")
|
|
assert env["core"].host_timezone() == "Asia/Tokyo"
|
|
|
|
|
|
def test_host_tijdzone_negeert_onzin(env, monkeypatch):
|
|
"""Een kapotte TZ mag niet doorsijpelen naar elk compose-bestand."""
|
|
monkeypatch.setenv("TZ", "niet; goed")
|
|
assert env["core"].host_timezone() != "niet; goed"
|
|
|
|
|
|
def test_ingestelde_tijdzone_wint(env, monkeypatch):
|
|
monkeypatch.setenv("TZ", "Asia/Tokyo")
|
|
env["core"].patch({"TIMEZONE": "Europe/Berlin"})
|
|
assert env["core"].tijdzone() == "Europe/Berlin"
|
|
|
|
|
|
def test_zonder_instelling_die_van_de_server(env, monkeypatch):
|
|
monkeypatch.setenv("TZ", "Asia/Tokyo")
|
|
env["core"].patch({"TIMEZONE": ""})
|
|
assert env["core"].tijdzone() == "Asia/Tokyo"
|
|
|
|
|
|
def test_ongeldige_tijdzone_wordt_geweigerd(client):
|
|
csrf = login(client)
|
|
r = client.put("/api/settings", json={"TIMEZONE": "geen; tijdzone"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
assert "tijdzone" in r.get_json()["msg"].lower()
|
|
|
|
|
|
def test_instellingen_tonen_de_tijdzone_van_de_server(client, env):
|
|
login(client)
|
|
d = client.get("/api/settings").get_json()
|
|
assert d["host_timezone"]
|
|
|
|
|
|
def test_tijdzone_wordt_toegevoegd_waar_die_ontbreekt(env):
|
|
"""Negentien sjablonen hebben geen tijdzoneveld; die containers draaiden in
|
|
UTC en al hun tijdstempels stonden er een paar uur naast."""
|
|
from core import compose_transform as ct
|
|
compose = ("services:\n a:\n image: nginx\n"
|
|
" b:\n image: redis\n environment:\n - FOO=bar\n")
|
|
uit = ct.zorg_voor_tijdzone(compose, "Europe/Amsterdam")
|
|
import yaml
|
|
doc = yaml.safe_load(uit)
|
|
assert "TZ=Europe/Amsterdam" in doc["services"]["a"]["environment"]
|
|
assert "TZ=Europe/Amsterdam" in doc["services"]["b"]["environment"]
|
|
assert "FOO=bar" in doc["services"]["b"]["environment"]
|
|
|
|
|
|
def test_bestaande_tijdzone_blijft_staan(env):
|
|
"""Heeft de app zelf een tijdzoneveld, dan is dat de keuze van de gebruiker."""
|
|
from core import compose_transform as ct
|
|
compose = ("services:\n a:\n image: nginx\n"
|
|
" environment:\n - TZ=Asia/Tokyo\n")
|
|
assert ct.zorg_voor_tijdzone(compose, "Europe/Amsterdam") == compose
|
|
|
|
|
|
def test_tijdzone_als_mapping(env):
|
|
from core import compose_transform as ct
|
|
import yaml
|
|
uit = ct.zorg_voor_tijdzone(
|
|
"services:\n a:\n image: nginx\n environment:\n FOO: bar\n",
|
|
"Europe/Berlin")
|
|
assert yaml.safe_load(uit)["services"]["a"]["environment"]["TZ"] == "Europe/Berlin"
|
|
|
|
|
|
# ── Poorten ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_poortcontrole_meldt_vrij(client, env):
|
|
login(client)
|
|
d = client.get("/api/ports/check?port=54321").get_json()
|
|
assert d["ok"] is True
|
|
assert d["free"] is True
|
|
assert d["suggested"] == 54321
|
|
|
|
|
|
def test_poortcontrole_stelt_een_alternatief_voor(client, env, monkeypatch):
|
|
import app as app_module
|
|
monkeypatch.setattr(app_module, "_bezette_poorten", lambda: {8080, 8081})
|
|
login(client)
|
|
d = client.get("/api/ports/check?port=8080").get_json()
|
|
assert d["free"] is False
|
|
assert d["suggested"] == 8082
|
|
|
|
|
|
@pytest.mark.parametrize("waarde", ["0", "70000", "abc", "-1"])
|
|
def test_ongeldige_poort(client, env, waarde):
|
|
login(client)
|
|
assert client.get(f"/api/ports/check?port={waarde}").status_code == 400
|
|
|
|
|
|
def test_luisterpoorten_uit_proc(env):
|
|
"""/proc/net/tcp: kolom 4 is de status, 0A betekent LISTEN."""
|
|
from core import networks
|
|
tekst = (" sl local_address rem_address st tx_queue\n"
|
|
" 0: 0100007F:1F90 00000000:0000 0A 00000000\n" # 8080, LISTEN
|
|
" 1: 0100007F:0050 0100007F:C001 01 00000000\n" # 80, ESTABLISHED
|
|
" 2: 00000000:0035 00000000:0000 0A 00000000\n") # 53, LISTEN
|
|
assert networks._poorten_uit_proc(tekst) == {8080, 53}
|
|
|
|
|
|
def test_poorten_van_de_host_tellen_mee(env, monkeypatch):
|
|
"""Een nginx die rechtstreeks op de host draait ziet `docker ps` niet, maar
|
|
je botst er wel mee: 'port is already allocated'."""
|
|
import app as app_module
|
|
from core import docker, networks
|
|
monkeypatch.setattr(docker, "used_ports", lambda: {8080})
|
|
monkeypatch.setattr(networks, "host_luisterpoorten", lambda *a, **k: {443})
|
|
assert app_module._bezette_poorten() == {8080, 443}
|
|
|
|
|
|
# ── IP-adressen ──────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def lan(client, env):
|
|
env["core"].patch({"NETWORKS": [dict(LAN)]})
|
|
return client
|
|
|
|
|
|
def test_ip_buiten_het_subnet(lan, env):
|
|
login(lan)
|
|
d = lan.get("/api/networks/lan/ip-check?ip=10.9.9.9").get_json()
|
|
assert d["free"] is False
|
|
assert d["in_subnet"] is False
|
|
assert "buiten het subnet" in d["reason"]
|
|
|
|
|
|
def test_gateway_is_geen_vrij_adres(lan, env):
|
|
login(lan)
|
|
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.1").get_json()
|
|
assert d["free"] is False
|
|
assert "gateway" in d["reason"]
|
|
|
|
|
|
def test_buiten_het_ingestelde_bereik(lan, env):
|
|
"""Binnen het subnet maar buiten je range: dan zit je in het gebied waar je
|
|
router DHCP uitdeelt, en botst het vroeg of laat."""
|
|
login(lan)
|
|
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.50").get_json()
|
|
assert d["free"] is False
|
|
assert "bereik" in d["reason"]
|
|
|
|
|
|
def test_vrij_adres(lan, env):
|
|
login(lan)
|
|
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.242").get_json()
|
|
assert d["free"] is True
|
|
assert d["reason"] == ""
|
|
|
|
|
|
def test_advies_zonder_adres(lan, env):
|
|
login(lan)
|
|
d = lan.get("/api/networks/lan/ip-check").get_json()
|
|
assert d["suggested"].startswith("192.168.1.24")
|
|
|
|
|
|
def test_apparaat_op_het_lan_blokkeert(lan, env, monkeypatch):
|
|
"""Docker kent alleen zijn eigen containers; een fysiek apparaat met een
|
|
DHCP-adres is precies de botsing die pas weken later opvalt."""
|
|
from core import networks
|
|
monkeypatch.setattr(networks, "buren_op_het_lan", lambda: {"192.168.1.242"})
|
|
login(lan)
|
|
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.242").get_json()
|
|
assert d["free"] is False
|
|
assert "apparaat" in d["reason"]
|
|
|
|
|
|
def test_arp_tabel_negeert_onvolledige_regels(env):
|
|
from core import networks
|
|
tekst = ("IP address HW type Flags HW address Mask Device\n"
|
|
"192.168.1.5 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0\n"
|
|
"192.168.1.9 0x1 0x0 00:00:00:00:00:00 * eth0\n")
|
|
assert networks._arp_tabel(tekst) == {"192.168.1.5"}
|
|
|
|
|
|
def test_onbekend_netwerk(client, env):
|
|
login(client)
|
|
assert client.get("/api/networks/bestaatniet/ip-check?ip=1.2.3.4").status_code == 404
|
|
|
|
|
|
# ── Gedeelde netwerken ───────────────────────────────────────────────────────
|
|
|
|
def test_standaardnetwerk_staat_in_de_lijst(client, env):
|
|
login(client)
|
|
d = client.get("/api/appnetworks").get_json()
|
|
namen = [n["name"] for n in d["networks"]]
|
|
assert "serverup" in namen
|
|
assert next(n for n in d["networks"] if n["name"] == "serverup")["default"]
|
|
|
|
|
|
def test_voorstel_voor_een_nieuwe_naam(client, env):
|
|
login(client)
|
|
d = client.get("/api/appnetworks?for=jellyfin").get_json()
|
|
assert d["suggested"] == "su-jellyfin"
|
|
|
|
|
|
def test_voorstel_wijkt_uit_bij_een_bestaande_naam(env):
|
|
from core import networks
|
|
env["core"].patch({"APP_NETWORKS": ["su-media"]})
|
|
assert networks.voorstel_netwerknaam("media") == "su-media-2"
|
|
|
|
|
|
def test_netwerk_toevoegen_onthoudt_het(env, monkeypatch):
|
|
from core import networks
|
|
monkeypatch.setattr(networks, "ensure_shared", lambda n: (True, ""))
|
|
ok, _ = networks.app_netwerk_toevoegen("media")
|
|
assert ok is True
|
|
assert "media" in env["core"].load()["APP_NETWORKS"]
|
|
|
|
|
|
@pytest.mark.parametrize("naam", ["../weg", "met spatie", "", "-begint-fout"])
|
|
def test_ongeldige_netwerknaam(env, naam):
|
|
from core import networks
|
|
ok, msg = networks.app_netwerk_toevoegen(naam)
|
|
assert ok is False
|
|
assert "naam" in msg.lower()
|
|
|
|
|
|
def test_standaardnetwerk_kan_niet_weg(env):
|
|
from core import networks
|
|
ok, msg = networks.app_netwerk_verwijderen("serverup")
|
|
assert ok is False
|
|
assert "standaard" in msg
|
|
|
|
|
|
def test_netwerk_met_containers_kan_niet_weg(env, monkeypatch):
|
|
from core import networks
|
|
env["core"].patch({"APP_NETWORKS": ["media"]})
|
|
monkeypatch.setattr(networks, "_containers_in", lambda n: {"x": {}})
|
|
ok, msg = networks.app_netwerk_verwijderen("media")
|
|
assert ok is False
|
|
assert "containers" in msg
|
|
|
|
|
|
def test_netwerkbeheer_is_voor_beheerders(client, env):
|
|
csrf = login(client)
|
|
client.post("/api/auth/users",
|
|
json={"username": "op", "password": "eenlangwachtwoord",
|
|
"role": "operator"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
op = client.post("/api/auth/login",
|
|
json={"username": "op", "password": "eenlangwachtwoord"}
|
|
).get_json()["csrf_token"]
|
|
r = client.post("/api/appnetworks", json={"name": "media"},
|
|
headers={"X-CSRF-Token": op})
|
|
assert r.status_code == 403
|
|
|
|
|
|
# ── Meerdere netwerken in compose ────────────────────────────────────────────
|
|
|
|
def test_stack_kan_aan_meerdere_netwerken(env):
|
|
from core import compose_transform as ct
|
|
import yaml
|
|
uit = ct.add_shared_network("services:\n a:\n image: nginx\n",
|
|
["serverup", "media"])
|
|
doc = yaml.safe_load(uit)
|
|
assert doc["services"]["a"]["networks"] == ["serverup", "media"]
|
|
assert set(doc["networks"]) == {"serverup", "media"}
|
|
|
|
|
|
def test_een_naam_blijft_werken(env):
|
|
"""De oude aanroep met één string mag niet stukgaan."""
|
|
from core import compose_transform as ct
|
|
import yaml
|
|
doc = yaml.safe_load(ct.add_shared_network(
|
|
"services:\n a:\n image: nginx\n", "serverup"))
|
|
assert doc["services"]["a"]["networks"] == ["serverup"]
|
|
|
|
|
|
def test_lege_lijst_verandert_niets(env):
|
|
from core import compose_transform as ct
|
|
compose = "services:\n a:\n image: nginx\n"
|
|
assert ct.add_shared_network(compose, []) == compose
|
|
|
|
|
|
def test_network_mode_wordt_nog_steeds_overgeslagen(env):
|
|
"""Compose weigert network_mode én networks in dezelfde service."""
|
|
from core import compose_transform as ct
|
|
import yaml
|
|
doc = yaml.safe_load(ct.add_shared_network(
|
|
"services:\n a:\n image: nginx\n network_mode: host\n",
|
|
["serverup", "media"]))
|
|
assert "networks" not in doc["services"]["a"]
|