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>
100 lines
3.6 KiB
Python
100 lines
3.6 KiB
Python
"""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
|