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
@@ -13,6 +13,19 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
|
||||
> complete.
|
||||
|
||||
## Upgrading to 0.67.3 — nothing to do
|
||||
|
||||
**Port links work behind a reverse proxy.** The port chips on a stack linked to
|
||||
`<the address in the browser>:<port>`. Opened through a proxy such as Nginx
|
||||
Proxy Manager that is the proxy's domain, and `stackpilot.example.com:8080`
|
||||
ended up at the proxy instead of the container. They now link to the Docker
|
||||
host's own address: the backend reads it from the host's routing table (the
|
||||
`/proc:/host_proc:ro` mount the compose file already has — the address on the
|
||||
interface with the default route). Opened via an IP, the links keep using that
|
||||
IP. When the detected address is the wrong one (several NICs, a VPN as default
|
||||
route), set **Settings → General → Host address for port links**; it wins
|
||||
everywhere. Ports bound to one specific address still link to that address.
|
||||
|
||||
## Upgrading to 0.67.2 — StackPilot and the terminal agree
|
||||
|
||||
StackPilot now runs `docker compose` the way it runs when typed in the stack's
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.67.2"
|
||||
APP_VERSION = "0.67.3"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.67.2",
|
||||
"version": "0.67.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { User } from "@/types";
|
||||
|
||||
export interface AppSettings {
|
||||
update_check_interval_minutes: number;
|
||||
/** Where links to published ports point; "" = use detected_host_ip. */
|
||||
port_link_host: string;
|
||||
/** The Docker host's LAN address, null when it could not be read. */
|
||||
detected_host_ip: string | null;
|
||||
env_webhook_count: number;
|
||||
available_events: string[];
|
||||
webhook_types: string[];
|
||||
@@ -28,7 +32,7 @@ export interface WebhookInput {
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => api.get<AppSettings>("/api/settings").then((r) => r.data),
|
||||
update: (body: { update_check_interval_minutes?: number }) =>
|
||||
update: (body: { update_check_interval_minutes?: number; port_link_host?: string }) =>
|
||||
api.put<AppSettings>("/api/settings", body).then((r) => r.data),
|
||||
|
||||
listWebhooks: () =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { settingsApi } from "@/api/settings";
|
||||
import { defaultLinkHost, linkHost, urlHost } from "@/lib/portLinks";
|
||||
|
||||
export type ContainerPort = {
|
||||
container: string;
|
||||
@@ -6,14 +9,13 @@ export type ContainerPort = {
|
||||
host_port?: string | null;
|
||||
};
|
||||
|
||||
const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]);
|
||||
|
||||
/** Pick the host to link to: an explicit override, the bound
|
||||
* host IP if it's a concrete address, otherwise the host we're viewing from. */
|
||||
function linkHost(hostIp: string | undefined, override?: string): string {
|
||||
if (override) return override;
|
||||
if (hostIp && !WILDCARD_IPS.has(hostIp)) return hostIp;
|
||||
return window.location.hostname;
|
||||
function useDefaultLinkHost(): string {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: settingsApi.get,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return defaultLinkHost(window.location.hostname, data);
|
||||
}
|
||||
|
||||
/** Best-effort scheme guess — most homelab services are http; common TLS
|
||||
@@ -33,6 +35,7 @@ export function ContainerPorts({
|
||||
ports: ContainerPort[];
|
||||
host?: string;
|
||||
}) {
|
||||
const fallback = useDefaultLinkHost();
|
||||
const seen = new Set<string>();
|
||||
const published = ports.filter((p) => {
|
||||
if (!p.host_port || seen.has(p.host_port)) return false;
|
||||
@@ -44,9 +47,8 @@ export function ContainerPorts({
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{published.map((p) => {
|
||||
const url = `${scheme(p.host_port!, p.container)}://${linkHost(
|
||||
p.host_ip,
|
||||
host
|
||||
const url = `${scheme(p.host_port!, p.container)}://${urlHost(
|
||||
linkHost(p.host_ip, fallback, host)
|
||||
)}:${p.host_port}`;
|
||||
return (
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultLinkHost, linkHost, urlHost } from "./portLinks";
|
||||
|
||||
const detected = { port_link_host: "", detected_host_ip: "10.10.6.4" };
|
||||
|
||||
describe("defaultLinkHost", () => {
|
||||
it("uses the detected host address when opened via a proxy's domain", () => {
|
||||
expect(defaultLinkHost("stackpilot.example.com", detected)).toBe("10.10.6.4");
|
||||
});
|
||||
|
||||
it("keeps the page's address when that already is an IP or localhost", () => {
|
||||
expect(defaultLinkHost("192.168.1.5", detected)).toBe("192.168.1.5");
|
||||
expect(defaultLinkHost("[fd00::5]", detected)).toBe("[fd00::5]");
|
||||
expect(defaultLinkHost("localhost", detected)).toBe("localhost");
|
||||
});
|
||||
|
||||
it("prefers the configured host over everything", () => {
|
||||
const configured = { port_link_host: "docker-srv001.lan", detected_host_ip: "10.10.6.4" };
|
||||
expect(defaultLinkHost("stackpilot.example.com", configured)).toBe("docker-srv001.lan");
|
||||
expect(defaultLinkHost("192.168.1.5", configured)).toBe("docker-srv001.lan");
|
||||
});
|
||||
|
||||
it("falls back to the page's hostname without settings or detection", () => {
|
||||
expect(defaultLinkHost("stackpilot.example.com")).toBe("stackpilot.example.com");
|
||||
expect(
|
||||
defaultLinkHost("stackpilot.example.com", { port_link_host: "", detected_host_ip: null })
|
||||
).toBe("stackpilot.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkHost", () => {
|
||||
it("links a port bound to one address at that address", () => {
|
||||
expect(linkHost("10.0.0.9", "10.10.6.4")).toBe("10.0.0.9");
|
||||
});
|
||||
|
||||
it("uses the fallback for wildcard bindings", () => {
|
||||
for (const ip of [undefined, "", "0.0.0.0", "::"]) {
|
||||
expect(linkHost(ip, "10.10.6.4")).toBe("10.10.6.4");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an explicit override win", () => {
|
||||
expect(linkHost("10.0.0.9", "10.10.6.4", "nas.lan")).toBe("nas.lan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("urlHost", () => {
|
||||
it("brackets bare IPv6 addresses only", () => {
|
||||
expect(urlHost("fd00::1")).toBe("[fd00::1]");
|
||||
expect(urlHost("[fd00::1]")).toBe("[fd00::1]");
|
||||
expect(urlHost("10.10.6.4")).toBe("10.10.6.4");
|
||||
expect(urlHost("example.com")).toBe("example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]);
|
||||
|
||||
/** IPv6 literals need brackets in a URL; location.hostname already has them. */
|
||||
export function urlHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isIpLiteral(host: string): boolean {
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith("[");
|
||||
}
|
||||
|
||||
/** Where a port bound to all interfaces is reached. The page's own hostname
|
||||
* only works when it *is* the Docker host — behind a reverse proxy it is the
|
||||
* proxy's domain, and domain:8080 lands on the proxy. So: the configured
|
||||
* host, else the page's hostname when that is an IP (or localhost), else the
|
||||
* host address the backend detected, else the page's hostname after all. */
|
||||
export function defaultLinkHost(
|
||||
page: string,
|
||||
settings?: { port_link_host: string; detected_host_ip: string | null }
|
||||
): string {
|
||||
if (settings?.port_link_host) return settings.port_link_host;
|
||||
if (isIpLiteral(page) || page === "localhost") return page;
|
||||
return settings?.detected_host_ip || page;
|
||||
}
|
||||
|
||||
/** Pick the host to link to: an explicit override, the bound
|
||||
* host IP if it's a concrete address, otherwise the default above. */
|
||||
export function linkHost(hostIp: string | undefined, fallback: string, override?: string): string {
|
||||
if (override) return override;
|
||||
if (hostIp && !WILDCARD_IPS.has(hostIp)) return hostIp;
|
||||
return fallback;
|
||||
}
|
||||
@@ -973,14 +973,18 @@ function GeneralSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get });
|
||||
const [interval, setInterval] = useState("");
|
||||
const [linkHost, setLinkHost] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setInterval(String(data.update_check_interval_minutes));
|
||||
if (data) {
|
||||
setInterval(String(data.update_check_interval_minutes));
|
||||
setLinkHost(data.port_link_host);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
settingsApi.update({ update_check_interval_minutes: Number(interval) }),
|
||||
mutationFn: (body: { update_check_interval_minutes?: number; port_link_host?: string }) =>
|
||||
settingsApi.update(body),
|
||||
onSuccess: () => {
|
||||
toast.success("Settings saved");
|
||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
||||
@@ -1006,7 +1010,10 @@ function GeneralSection() {
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="max-w-[140px]"
|
||||
/>
|
||||
<Button onClick={() => save.mutate()} loading={save.isPending}>
|
||||
<Button
|
||||
onClick={() => save.mutate({ update_check_interval_minutes: Number(interval) })}
|
||||
loading={save.isPending && save.variables?.update_check_interval_minutes !== undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1014,6 +1021,36 @@ function GeneralSection() {
|
||||
Minimum 5 minutes. Applies on the next check cycle.
|
||||
</span>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium">Host address for port links</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={linkHost}
|
||||
onChange={(e) => setLinkHost(e.target.value)}
|
||||
placeholder={data?.detected_host_ip ?? "e.g. 192.168.1.10"}
|
||||
className="max-w-[260px] font-mono"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => save.mutate({ port_link_host: linkHost })}
|
||||
loading={save.isPending && save.variables?.port_link_host !== undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
IP or hostname the port chips on a stack open (<code>http://<host>:<port></code>).
|
||||
Leave empty to use{" "}
|
||||
{data?.detected_host_ip ? (
|
||||
<>
|
||||
the detected host address <code>{data.detected_host_ip}</code> when StackPilot is
|
||||
opened via a domain (e.g. behind a reverse proxy)
|
||||
</>
|
||||
) : (
|
||||
<>the address StackPilot is opened at (the host address could not be detected)</>
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{data && data.env_webhook_count > 0 && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{data.env_webhook_count} generic webhook(s) configured via the{" "}
|
||||
|
||||
Reference in new issue
Block a user