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>
This commit is contained in:
1 parent
06234c1067
commit
ba788e8b03
13 files changed
+432
-22
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user