Files
stackpilot/backend/services/host_address_service.py
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

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