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
@@ -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