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

+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)