Files
stackpilot/backend/tests/test_port_link_host.py
T
menzeljandClaude Opus 5.5 ba788e8b03
CI / check (push) Successful in 8m20s
CI / build-and-push (push) Successful in 4m19s
Link port chips to the Docker host, not the proxy's domain (0.67.3)
Ports published on 0.0.0.0 were linked at window.location.hostname. Opened
through a reverse proxy such as Nginx Proxy Manager that is the proxy's
domain, and domain:8080 ended up at the proxy instead of the container.

The backend now reads the host's address from the host's routing table
(${HOST_PROC_PATH}/1/net/route + fib_trie, the /proc mount the compose file
already has): the IPv4 address on the interface with the best default
route. There is no fallback to /proc/net, which in a bridged container is
the container's own namespace with an unreachable 172.x address.

The links use, in order: the new "Host address for port links" setting
(Settings -> General), the page's hostname when it is an IP or localhost,
the detected address, and the page's hostname as a last resort. Ports
bound to one specific address still link there; IPv6 hosts are bracketed.
The setting is validated before anything is written, so a bad value no
longer leaves the interval half-saved in the same request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:19:09 +02:00

121 lines
4.1 KiB
Python

"""Port links behind a reverse proxy: the Docker host's address, not the domain."""
from __future__ import annotations
import pytest
ROUTE = """Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
eth0\t00000000\t01060A0A\t0003\t0\t0\t0\t00000000\t0\t0\t0
eth0\t00060A0A\t00000000\t0001\t0\t0\t0\tE0FFFFFF\t0\t0\t0
docker0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0
"""
# Trimmed from a real host: lo, eth0 10.10.6.4/27, docker0 172.17.0.1/16.
FIB_TRIE = """Main:
+-- 0.0.0.0/0 3 0 5
|-- 0.0.0.0
/0 universe UNICAST
+-- 10.10.6.0/27 2 1 2
|-- 10.10.6.0
/27 link UNICAST
|-- 10.10.6.4
/32 host LOCAL
|-- 10.10.6.31
/32 link BROADCAST
+-- 127.0.0.0/8 2 0 2
|-- 127.0.0.1
/32 host LOCAL
+-- 172.17.0.0/16 2 0 2
|-- 172.17.0.1
/32 host LOCAL
"""
@pytest.fixture
def host_proc(tmp_path, monkeypatch):
from config import settings
net = tmp_path / "1" / "net"
net.mkdir(parents=True)
monkeypatch.setattr(settings, "HOST_PROC_PATH", str(tmp_path))
return net
def test_the_default_route_interface_address_is_detected(host_proc):
from services import host_address_service
(host_proc / "route").write_text(ROUTE)
(host_proc / "fib_trie").write_text(FIB_TRIE)
assert host_address_service.detect_host_ip() == "10.10.6.4"
def test_the_lowest_metric_default_route_wins(host_proc):
from services import host_address_service
# A second default via docker0 with a worse metric must not take over.
route = ROUTE + "docker0\t00000000\t010011AC\t0003\t0\t0\t500\t00000000\t0\t0\t0\n"
(host_proc / "route").write_text(route)
(host_proc / "fib_trie").write_text(FIB_TRIE)
assert host_address_service.detect_host_ip() == "10.10.6.4"
def test_no_host_proc_means_no_guess(tmp_path, monkeypatch):
"""Without the host's /proc, /proc/net is the container's own namespace;
its bridge address is unreachable, so nothing is better than that."""
from config import settings
from services import host_address_service
monkeypatch.setattr(settings, "HOST_PROC_PATH", str(tmp_path))
assert host_address_service.detect_host_ip() is None
def test_no_default_route_means_no_guess(host_proc):
from services import host_address_service
(host_proc / "route").write_text(ROUTE.splitlines()[0] + "\n" + ROUTE.splitlines()[2] + "\n")
(host_proc / "fib_trie").write_text(FIB_TRIE)
assert host_address_service.detect_host_ip() is None
def test_settings_report_the_detected_address(as_user, host_proc):
(host_proc / "route").write_text(ROUTE)
(host_proc / "fib_trie").write_text(FIB_TRIE)
r = as_user.get("/api/settings")
assert r.status_code == 200
assert r.json()["detected_host_ip"] == "10.10.6.4"
@pytest.mark.parametrize(
"raw, stored",
[
(" 10.10.6.4 ", "10.10.6.4"),
("Docker-SRV001.lan", "docker-srv001.lan"),
("[fd00::1]", "fd00::1"),
("", ""),
],
)
def test_port_link_host_is_stored_bare(as_admin, raw, stored):
r = as_admin.put("/api/settings", json={"port_link_host": raw})
assert r.status_code == 200, r.text
assert r.json()["port_link_host"] == stored
assert as_admin.get("/api/settings").json()["port_link_host"] == stored
@pytest.mark.parametrize(
"raw", ["http://10.10.6.4", "10.10.6.4:8080", "host/path", "two words", "-bad.lan"]
)
def test_port_link_host_rejects_urls_and_junk(as_admin, raw):
before = as_admin.get("/api/settings").json()
r = as_admin.put(
"/api/settings",
json={"port_link_host": raw, "update_check_interval_minutes": before["update_check_interval_minutes"] + 7},
)
assert r.status_code == 400
after = as_admin.get("/api/settings").json()
# Nothing of a rejected request is written, the interval included.
assert after["update_check_interval_minutes"] == before["update_check_interval_minutes"]
assert after["port_link_host"] == before["port_link_host"]
def test_only_admins_set_the_port_link_host(as_user):
assert as_user.put("/api/settings", json={"port_link_host": "10.0.0.1"}).status_code == 403