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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.67.2",
"version": "0.67.3",
"type": "module",
"scripts": {
"dev": "vite",
+5 -1
View File
@@ -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
+54
View File
@@ -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");
});
});
+32
View File
@@ -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;
}
+41 -4
View File
@@ -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://&lt;host&gt;:&lt;port&gt;</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{" "}