Link port chips to the Docker host, not the proxy's domain (0.67.3)
CI / check (push) Successful in 8m20s
CI / build-and-push (push) Successful in 4m19s

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>
This commit is contained in:
menzeljandClaude Opus 5.5 committed 2026-10-24 23:19:09 +02:00
1 parent 06234c1067
commit ba788e8b03
13 files changed
+432 -22

No files matched your search

+5
View File
@@ -80,6 +80,9 @@ class WebhookRead(SQLModel):
class SettingsRead(SQLModel):
update_check_interval_minutes: int
# Where links to published ports point. "" = not set, use detected_host_ip.
port_link_host: str
detected_host_ip: Optional[str]
env_webhook_count: int
available_events: list[str]
webhook_types: list[str]
@@ -87,3 +90,5 @@ class SettingsRead(SQLModel):
class SettingsUpdate(SQLModel):
update_check_interval_minutes: Optional[int] = None
# "" clears it back to the detected address.
port_link_host: Optional[str] = None
+41 -4
View File
@@ -1,6 +1,9 @@
"""Application settings: update interval + notification webhooks."""
"""Application settings: update interval, port links + notification webhooks."""
from __future__ import annotations
import ipaddress
import re
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
@@ -18,7 +21,7 @@ from models.setting import (
WebhookUpdate,
)
from models.user import User
from services import audit_service, notify_service, settings_service
from services import audit_service, host_address_service, notify_service, settings_service
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -43,6 +46,27 @@ def _to_read(wh: Webhook) -> WebhookRead:
# General settings
# --------------------------------------------------------------------------- #
_HOSTNAME = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")
def _clean_link_host(raw: str) -> str:
"""A bare IP or hostname — no scheme, port or path, since the UI appends
``:<port>`` itself. IPv6 is stored without brackets."""
value = raw.strip()
if not value:
return ""
unbracketed = value[1:-1] if value.startswith("[") and value.endswith("]") else value
try:
return str(ipaddress.ip_address(unbracketed))
except ValueError:
pass
if _HOSTNAME.match(value):
return value.lower()
raise HTTPException(
status_code=400,
detail="Port link host must be a bare IP address or hostname (no http://, port or path)",
)
@router.get("", response_model=SettingsRead)
def get_settings(
@@ -51,6 +75,8 @@ def get_settings(
) -> SettingsRead:
return SettingsRead(
update_check_interval_minutes=settings_service.get_update_interval(session),
port_link_host=settings_service.get_port_link_host(session),
detected_host_ip=host_address_service.detect_host_ip(),
env_webhook_count=len(env_settings.NOTIFY_WEBHOOKS),
available_events=ALL_EVENTS,
webhook_types=WEBHOOK_TYPES,
@@ -64,9 +90,13 @@ def update_settings(
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> SettingsRead:
# Validate everything before writing anything: a bad host must not leave
# the interval half-saved.
if body.update_check_interval_minutes is not None and body.update_check_interval_minutes < 5:
raise HTTPException(status_code=400, detail="Interval must be at least 5 minutes")
link_host = _clean_link_host(body.port_link_host) if body.port_link_host is not None else None
if body.update_check_interval_minutes is not None:
if body.update_check_interval_minutes < 5:
raise HTTPException(status_code=400, detail="Interval must be at least 5 minutes")
settings_service.set_value(
session,
settings_service.KEY_UPDATE_INTERVAL,
@@ -77,6 +107,13 @@ def update_settings(
target="update_interval", detail=str(body.update_check_interval_minutes),
ip=_ip(request),
)
if link_host is not None:
settings_service.set_value(session, settings_service.KEY_PORT_LINK_HOST, link_host)
audit_service.record(
session, user=user.username, action="settings.update",
target="port_link_host", detail=link_host or "(detect)",
ip=_ip(request),
)
return get_settings(session, user)
+99
View File
@@ -0,0 +1,99 @@
"""The Docker host's own LAN address, for links to published ports.
A port published on 0.0.0.0 is reachable at the host's address, but the
browser only knows the address it reached StackPilot at. Behind a reverse
proxy that is the proxy's domain, and ``domain:8080`` lands on the proxy, not
on the container. So the host's address is read here instead: the IPv4
address on the interface that carries the host's default route.
"""
from __future__ import annotations
import ipaddress
import os
import socket
import struct
from typing import Optional
from config import settings
_RTF_UP = 0x1
def _host_net_file(name: str) -> Optional[str]:
"""``${HOST_PROC_PATH}/1/net/<name>`` — the host's namespace, see
``port_service._proc_net_path``. There is deliberately no fallback to
``/proc/net``: in a bridged container that is the container's own
namespace, whose 172.x address no browser can reach."""
path = os.path.join(settings.HOST_PROC_PATH, "1", "net", name)
return path if os.path.isfile(path) else None
def _hex_ip(value: str) -> ipaddress.IPv4Address:
"""/proc/net/route prints addresses as little-endian hex."""
return ipaddress.IPv4Address(socket.inet_ntoa(struct.pack("<I", int(value, 16))))
def _default_route_networks(route_text: str) -> list[ipaddress.IPv4Network]:
"""The on-link networks of the interface with the best default route."""
defaults: list[tuple[int, str]] = []
links: list[tuple[str, ipaddress.IPv4Network]] = []
for line in route_text.splitlines()[1:]:
cols = line.split()
if len(cols) < 8:
continue
iface, dest, _gw, flags, _ref, _use, metric, mask = cols[:8]
try:
if not int(flags, 16) & _RTF_UP:
continue
dest_ip, mask_ip = _hex_ip(dest), _hex_ip(mask)
metric_n = int(metric)
except (ValueError, struct.error, OSError):
continue
if int(dest_ip) == 0 and int(mask_ip) == 0:
defaults.append((metric_n, iface))
else:
try:
links.append((iface, ipaddress.IPv4Network(f"{dest_ip}/{mask_ip}")))
except ValueError:
continue
if not defaults:
return []
iface = min(defaults)[1]
return [net for name, net in links if name == iface]
def _local_addresses(fib_text: str) -> list[ipaddress.IPv4Address]:
"""Addresses the kernel lists as ``/32 host LOCAL`` in fib_trie."""
found: list[ipaddress.IPv4Address] = []
last: Optional[str] = None
for line in fib_text.splitlines():
stripped = line.strip()
if stripped.startswith("|--"):
last = stripped[3:].strip()
elif last and stripped.startswith("/32 host LOCAL"):
try:
addr = ipaddress.IPv4Address(last)
except ValueError:
continue
if not addr.is_loopback and addr not in found:
found.append(addr)
return found
def detect_host_ip() -> Optional[str]:
"""The host's address on its default-route interface, or None when the
host's /proc is not mounted or the host has no IPv4 default route."""
route_path, fib_path = _host_net_file("route"), _host_net_file("fib_trie")
if not route_path or not fib_path:
return None
try:
with open(route_path, "r", encoding="utf-8") as fh:
networks = _default_route_networks(fh.read())
with open(fib_path, "r", encoding="utf-8") as fh:
addresses = _local_addresses(fh.read())
except OSError:
return None
for addr in addresses:
if any(addr in net for net in networks):
return str(addr)
return None
+7
View File
@@ -11,6 +11,7 @@ from database import engine
from models.setting import Setting
KEY_UPDATE_INTERVAL = "update_check_interval_minutes"
KEY_PORT_LINK_HOST = "port_link_host"
def get(session: Session, key: str, default: Any = None) -> Any:
@@ -43,3 +44,9 @@ def get_update_interval(session: Optional[Session] = None) -> int:
if isinstance(val, int) and val > 0:
return val
return env_settings.UPDATE_CHECK_INTERVAL_MINUTES
def get_port_link_host(session: Session) -> str:
"""Host that port links point at, as configured; "" means detect it."""
val = get(session, KEY_PORT_LINK_HOST)
return val if isinstance(val, str) else ""
+120
View File
@@ -0,0 +1,120 @@
"""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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.67.2"
APP_VERSION = "0.67.3"