feat: add Bind Host IP and Allowed Hosts / Reverse Proxy Domain settings

This commit is contained in:
Schwitzz
2026-10-30 14:59:09 -05:00
parent 29dbc27c41
commit 723fafdef8
5 changed files with 253 additions and 6 deletions
+24
View File
@@ -370,6 +370,9 @@ export const api = {
getServerConfig: async (portHint?: number): Promise<{ getServerConfig: async (portHint?: number): Promise<{
online: boolean; online: boolean;
enabled: boolean; enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number; port: number;
activePort?: number; activePort?: number;
webPort?: number; webPort?: number;
@@ -388,6 +391,9 @@ export const api = {
if (res.ok && ct.includes("application/json")) { if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as { const data = (await res.json()) as {
enabled: boolean; enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number; port: number;
activePort?: number; activePort?: number;
webPort?: number; webPort?: number;
@@ -398,6 +404,9 @@ export const api = {
return { return {
online: true, online: true,
enabled: data.enabled, enabled: data.enabled,
...(data.host ? { host: data.host } : {}),
...(data.activeHost ? { activeHost: data.activeHost } : {}),
...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}),
port: data.port, port: data.port,
...(data.activePort !== undefined ? { activePort: data.activePort } : {}), ...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
...(data.webPort !== undefined ? { webPort: data.webPort } : {}), ...(data.webPort !== undefined ? { webPort: data.webPort } : {}),
@@ -413,6 +422,8 @@ export const api = {
return { return {
online: false, online: false,
enabled: true, enabled: true,
host: "127.0.0.1",
allowedHosts: "localhost, 127.0.0.1",
port: portHint ?? 8081, port: portHint ?? 8081,
webPort: 5173, webPort: 5173,
startOnBoot: false, startOnBoot: false,
@@ -422,6 +433,8 @@ export const api = {
updateServerConfig: async ( updateServerConfig: async (
patch: { patch: {
enabled?: boolean; enabled?: boolean;
host?: string;
allowedHosts?: string;
port?: number; port?: number;
webPort?: number; webPort?: number;
startOnBoot?: boolean; startOnBoot?: boolean;
@@ -431,6 +444,9 @@ export const api = {
): Promise<{ ): Promise<{
online: boolean; online: boolean;
enabled: boolean; enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number; port: number;
activePort?: number; activePort?: number;
webPort?: number; webPort?: number;
@@ -454,6 +470,9 @@ export const api = {
if (res.ok && ct.includes("application/json")) { if (res.ok && ct.includes("application/json")) {
const data = (await res.json()) as { const data = (await res.json()) as {
enabled: boolean; enabled: boolean;
host?: string;
activeHost?: string;
allowedHosts?: string;
port: number; port: number;
activePort?: number; activePort?: number;
webPort?: number; webPort?: number;
@@ -464,6 +483,9 @@ export const api = {
return { return {
online: true, online: true,
enabled: data.enabled, enabled: data.enabled,
...(data.host ? { host: data.host } : {}),
...(data.activeHost ? { activeHost: data.activeHost } : {}),
...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}),
port: data.port, port: data.port,
...(data.activePort !== undefined ? { activePort: data.activePort } : {}), ...(data.activePort !== undefined ? { activePort: data.activePort } : {}),
...(data.webPort !== undefined ? { webPort: data.webPort } : {}), ...(data.webPort !== undefined ? { webPort: data.webPort } : {}),
@@ -479,6 +501,8 @@ export const api = {
return { return {
online: false, online: false,
enabled: patch.enabled ?? true, enabled: patch.enabled ?? true,
...(patch.host ? { host: patch.host } : {}),
...(patch.allowedHosts ? { allowedHosts: patch.allowedHosts } : {}),
port: patch.port ?? portHint ?? 8081, port: patch.port ?? portHint ?? 8081,
...(patch.webPort !== undefined ? { webPort: patch.webPort } : {}), ...(patch.webPort !== undefined ? { webPort: patch.webPort } : {}),
startOnBoot: patch.startOnBoot ?? false, startOnBoot: patch.startOnBoot ?? false,
+132 -1
View File
@@ -67,6 +67,10 @@ export function Settings() {
const [probing, setProbing] = useState(false); const [probing, setProbing] = useState(false);
// Server & Project Directory state // Server & Project Directory state
const [hostDraft, setHostDraft] = useState<string>(s.serverHost || "127.0.0.1");
const [allowedHostsDraft, setAllowedHostsDraft] = useState<string>(
s.allowedHosts || "localhost, 127.0.0.1",
);
const [webPortDraft, setWebPortDraft] = useState<string>(String(s.webPort || 5173)); const [webPortDraft, setWebPortDraft] = useState<string>(String(s.webPort || 5173));
const [portDraft, setPortDraft] = useState<string>(String(s.serverPort)); const [portDraft, setPortDraft] = useState<string>(String(s.serverPort));
const [projectDirDraft, setProjectDirDraft] = useState<string>( const [projectDirDraft, setProjectDirDraft] = useState<string>(
@@ -74,6 +78,7 @@ export function Settings() {
); );
const [browseProjectDirOpen, setBrowseProjectDirOpen] = useState<boolean>(false); const [browseProjectDirOpen, setBrowseProjectDirOpen] = useState<boolean>(false);
const [serverOnline, setServerOnline] = useState<boolean>(false); const [serverOnline, setServerOnline] = useState<boolean>(false);
const [activeRunningHost, setActiveRunningHost] = useState<string>(s.serverHost || "127.0.0.1");
const [activeRunningPort, setActiveRunningPort] = useState<number | null>(null); const [activeRunningPort, setActiveRunningPort] = useState<number | null>(null);
const [activeRunningWebPort, setActiveRunningWebPort] = useState<number | null>(null); const [activeRunningWebPort, setActiveRunningWebPort] = useState<number | null>(null);
const [savingServerCfg, setSavingServerCfg] = useState<boolean>(false); const [savingServerCfg, setSavingServerCfg] = useState<boolean>(false);
@@ -83,9 +88,12 @@ export function Settings() {
void api.getServerConfig(s.serverPort).then((res) => { void api.getServerConfig(s.serverPort).then((res) => {
if (cancelled) return; if (cancelled) return;
setServerOnline(res.online); setServerOnline(res.online);
setActiveRunningHost(res.activeHost ?? res.host ?? s.serverHost);
setActiveRunningPort(res.activePort ?? res.port); setActiveRunningPort(res.activePort ?? res.port);
setActiveRunningWebPort(res.activeWebPort ?? res.webPort ?? s.webPort); setActiveRunningWebPort(res.activeWebPort ?? res.webPort ?? s.webPort);
if (res.online) { if (res.online) {
if (res.host) setHostDraft(res.host);
if (res.allowedHosts) setAllowedHostsDraft(res.allowedHosts);
setPortDraft(String(res.port)); setPortDraft(String(res.port));
if (res.webPort) { if (res.webPort) {
setWebPortDraft(String(res.webPort)); setWebPortDraft(String(res.webPort));
@@ -95,6 +103,8 @@ export function Settings() {
} }
set({ set({
serverEnabled: res.enabled, serverEnabled: res.enabled,
...(res.host ? { serverHost: res.host } : {}),
...(res.allowedHosts ? { allowedHosts: res.allowedHosts } : {}),
serverPort: res.port, serverPort: res.port,
...(res.webPort ? { webPort: res.webPort } : {}), ...(res.webPort ? { webPort: res.webPort } : {}),
serverStartOnBoot: res.startOnBoot, serverStartOnBoot: res.startOnBoot,
@@ -146,6 +156,26 @@ export function Settings() {
} }
}; };
const handleApplyHostAndAllowed = async (overrideHost?: string, overrideAllowed?: string) => {
const nextHost = (overrideHost ?? hostDraft).trim() || "127.0.0.1";
const nextAllowed = (overrideAllowed ?? allowedHostsDraft).trim() || "localhost, 127.0.0.1";
setHostDraft(nextHost);
setAllowedHostsDraft(nextAllowed);
set({ serverHost: nextHost, allowedHosts: nextAllowed });
setSavingServerCfg(true);
try {
const res = await api.updateServerConfig(
{ host: nextHost, allowedHosts: nextAllowed },
s.serverPort,
);
setServerOnline(res.online);
setActiveRunningHost(res.activeHost ?? nextHost);
toast.success(`Host (${nextHost}) & Allowed Hosts updated`);
} finally {
setSavingServerCfg(false);
}
};
const handleApplyWebPort = async () => { const handleApplyWebPort = async () => {
const parsed = parseInt(webPortDraft.trim(), 10); const parsed = parseInt(webPortDraft.trim(), 10);
if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) { if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) {
@@ -1027,12 +1057,113 @@ export function Settings() {
</label> </label>
</div> </div>
<div className="ps-settings-row" style={{ flexDirection: "column", alignItems: "stretch", gap: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<div>
<div className="ps-settings-label" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span>Bind Host IP</span>
<Badge variant={!s.serverEnabled ? "neutral" : "accent"}>
{`${activeRunningHost || s.serverHost}:${activeRunningWebPort ?? s.webPort}`}
</Badge>
</div>
<div className="ps-settings-desc">
IP address the server listens on. Default is <code>127.0.0.1</code> (localhost only). Set to <code>0.0.0.0</code> to expose on all network interfaces for a reverse proxy or LAN.
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
<Button
variant={hostDraft === "127.0.0.1" ? "primary" : "secondary"}
size="sm"
onClick={() => void handleApplyHostAndAllowed("127.0.0.1", allowedHostsDraft)}
>
127.0.0.1 (Localhost)
</Button>
<Button
variant={hostDraft === "0.0.0.0" ? "primary" : "secondary"}
size="sm"
onClick={() =>
void handleApplyHostAndAllowed(
"0.0.0.0",
allowedHostsDraft.includes("0.0.0.0")
? allowedHostsDraft
: `${allowedHostsDraft}, 0.0.0.0`,
)
}
>
0.0.0.0 (All Interfaces)
</Button>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<input
type="text"
className="ps-input"
style={{ width: 200, fontFamily: "var(--ps-font-mono)", fontSize: 12.5 }}
value={hostDraft}
onChange={(e) => setHostDraft(e.target.value)}
placeholder="127.0.0.1 or 0.0.0.0"
aria-label="Bind host IP"
/>
<Button
variant="secondary"
size="sm"
disabled={savingServerCfg}
onClick={() => void handleApplyHostAndAllowed()}
>
Apply Host IP
</Button>
</div>
</div>
<div className="ps-settings-row" style={{ flexDirection: "column", alignItems: "stretch", gap: 10 }}>
<div>
<div className="ps-settings-label">Allowed Hosts / Reverse Proxy Domain URLs</div>
<div className="ps-settings-desc">
Comma-separated list of allowable hostnames, IPs, or domain URLs (e.g. <code>localhost, 127.0.0.1, https://pubscript.mydomain.com, *.mydomain.com, 0.0.0.0</code>).
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<input
type="text"
className="ps-input"
style={{ flex: 1, minWidth: 260, fontFamily: "var(--ps-font-mono)", fontSize: 12.5 }}
value={allowedHostsDraft}
onChange={(e) => setAllowedHostsDraft(e.target.value)}
placeholder="localhost, 127.0.0.1, https://write.example.com"
aria-label="Allowed hosts and domains"
/>
<Button
variant="secondary"
size="sm"
disabled={savingServerCfg}
onClick={() =>
void handleApplyHostAndAllowed(
hostDraft,
allowedHostsDraft.includes("*")
? allowedHostsDraft
: `${allowedHostsDraft}, 0.0.0.0, *`,
)
}
>
+ Allow All (0.0.0.0 / *)
</Button>
<Button
variant="primary"
size="sm"
disabled={savingServerCfg}
onClick={() => void handleApplyHostAndAllowed()}
>
Save Allowed Hosts
</Button>
</div>
</div>
<div className="ps-settings-row"> <div className="ps-settings-row">
<div> <div>
<div className="ps-settings-label" style={{ display: "flex", alignItems: "center", gap: 8 }}> <div className="ps-settings-label" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span>Web App Port</span> <span>Web App Port</span>
<Badge variant={!s.serverEnabled ? "neutral" : "accent"}> <Badge variant={!s.serverEnabled ? "neutral" : "accent"}>
{`http://localhost:${activeRunningWebPort ?? s.webPort}`} {`http://${activeRunningHost || s.serverHost}:${activeRunningWebPort ?? s.webPort}`}
</Badge> </Badge>
</div> </div>
<div className="ps-settings-desc"> <div className="ps-settings-desc">
+4
View File
@@ -274,6 +274,8 @@ export interface AppSettings {
activeAgentId: string; activeAgentId: string;
// Local / Self-Hosted Server & Startup Settings // Local / Self-Hosted Server & Startup Settings
serverEnabled: boolean; serverEnabled: boolean;
serverHost: string;
allowedHosts: string;
webPort: number; webPort: number;
serverPort: number; serverPort: number;
serverStartOnBoot: boolean; serverStartOnBoot: boolean;
@@ -310,6 +312,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
activeAgentId: DEFAULT_AI_AGENTS[0]!.id, activeAgentId: DEFAULT_AI_AGENTS[0]!.id,
// Local / Self-Hosted Server & Startup Settings // Local / Self-Hosted Server & Startup Settings
serverEnabled: true, serverEnabled: true,
serverHost: "127.0.0.1",
allowedHosts: "localhost, 127.0.0.1",
webPort: 5173, webPort: 5173,
serverPort: 8081, serverPort: 8081,
serverStartOnBoot: false, serverStartOnBoot: false,
+2
View File
@@ -16,6 +16,7 @@ export default defineConfig({
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5173,
strictPort: false, strictPort: false,
allowedHosts: true,
proxy: { proxy: {
"/api": { "/api": {
target: process.env.VITE_API_ORIGIN ?? "http://127.0.0.1:8081", target: process.env.VITE_API_ORIGIN ?? "http://127.0.0.1:8081",
@@ -30,5 +31,6 @@ export default defineConfig({
preview: { preview: {
host: "0.0.0.0", host: "0.0.0.0",
port: 4173, port: 4173,
allowedHosts: true,
}, },
}); });
+91 -5
View File
@@ -46,7 +46,7 @@ function makeHttpError(status: number, payload: object): HttpError {
} }
export async function buildApp() { export async function buildApp() {
const app = Fastify({ logger: true }); const app = Fastify({ logger: true, trustProxy: true });
await app.register(cors, { origin: true, credentials: true }); await app.register(cors, { origin: true, credentials: true });
await app.register(cookie); await app.register(cookie);
await app.register(websocket); await app.register(websocket);
@@ -101,6 +101,8 @@ export async function buildApp() {
interface PersistedServerSysConfig { interface PersistedServerSysConfig {
enabled: boolean; enabled: boolean;
host: string;
allowedHosts: string;
port: number; port: number;
webPort: number; webPort: number;
startOnBoot: boolean; startOnBoot: boolean;
@@ -109,6 +111,48 @@ export async function buildApp() {
let secondaryWebServer: HttpServer | null = null; let secondaryWebServer: HttpServer | null = null;
let activeWebPort: number = 5173; let activeWebPort: number = 5173;
let activeBindHost: string = "127.0.0.1";
function extractHostname(entry: string): string {
let s = entry.trim().toLowerCase();
if (!s) return "";
s = s.replace(/^[a-z]+:\/\//, "");
s = s.split("/")[0] ?? "";
if (!s.startsWith("[")) {
s = s.split(":")[0] ?? "";
}
return s.trim();
}
function isRequestHostAllowed(rawHostHeader: string | undefined, allowedHostsRaw: string): boolean {
if (!rawHostHeader) return true;
const firstForwarded = rawHostHeader.split(",")[0]?.trim() ?? "";
const reqHost = extractHostname(firstForwarded);
if (!reqHost || reqHost === "localhost" || reqHost === "127.0.0.1" || reqHost === "::1" || reqHost === "[::1]") {
return true;
}
const tokens = allowedHostsRaw
.split(/[,\s]+/)
.map((t) => extractHostname(t))
.filter(Boolean);
if (tokens.length === 0) return true;
for (const token of tokens) {
if (token === "*" || token === "0.0.0.0" || token === reqHost) {
return true;
}
if (token.startsWith("*.")) {
const suffix = token.slice(1); // ".domain.com"
if (reqHost === token.slice(2) || reqHost.endsWith(suffix)) {
return true;
}
} else if (token.startsWith(".")) {
if (reqHost === token.slice(1) || reqHost.endsWith(token)) {
return true;
}
}
}
return false;
}
function loadPersistedServerSysConfig(): PersistedServerSysConfig { function loadPersistedServerSysConfig(): PersistedServerSysConfig {
try { try {
@@ -116,6 +160,14 @@ export async function buildApp() {
const raw = JSON.parse(readFileSync(sysConfigPath, "utf8")) as Partial<PersistedServerSysConfig>; const raw = JSON.parse(readFileSync(sysConfigPath, "utf8")) as Partial<PersistedServerSysConfig>;
return { return {
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true, enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
host:
typeof raw.host === "string" && raw.host.trim()
? raw.host.trim()
: "127.0.0.1",
allowedHosts:
typeof raw.allowedHosts === "string" && raw.allowedHosts.trim()
? raw.allowedHosts.trim()
: "localhost, 127.0.0.1",
port: port:
typeof raw.port === "number" && raw.port >= 1024 && raw.port <= 65535 typeof raw.port === "number" && raw.port >= 1024 && raw.port <= 65535
? raw.port ? raw.port
@@ -139,6 +191,8 @@ export async function buildApp() {
} }
return { return {
enabled: true, enabled: true,
host: "127.0.0.1",
allowedHosts: "localhost, 127.0.0.1",
port: config.port, port: config.port,
webPort: 5173, webPort: 5173,
startOnBoot: existsSync(autostartDesktopPath) || existsSync(systemdServicePath), startOnBoot: existsSync(autostartDesktopPath) || existsSync(systemdServicePath),
@@ -146,8 +200,24 @@ export async function buildApp() {
}; };
} }
async function bindSecondaryWebPort(targetWebPort: number, enabled: boolean): Promise<number> { app.addHook("onRequest", async (req, reply) => {
const cfg = loadPersistedServerSysConfig();
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
return reply.code(403).send({
error: "host_not_allowed",
message: `Host "${forwardedHost ?? ""}" is not in Allowed Hosts. Add your domain or 0.0.0.0 in Settings → Server & System.`,
});
}
});
async function bindSecondaryWebPort(
targetWebPort: number,
targetHost: string,
enabled: boolean,
): Promise<number> {
activeWebPort = targetWebPort; activeWebPort = targetWebPort;
activeBindHost = targetHost || "127.0.0.1";
if (secondaryWebServer) { if (secondaryWebServer) {
await new Promise<void>((res) => secondaryWebServer!.close(() => res())); await new Promise<void>((res) => secondaryWebServer!.close(() => res()));
secondaryWebServer = null; secondaryWebServer = null;
@@ -163,7 +233,7 @@ export async function buildApp() {
// Port may already be served by Vite dev server (e.g. :5173); keep activeWebPort // Port may already be served by Vite dev server (e.g. :5173); keep activeWebPort
res(targetWebPort); res(targetWebPort);
}); });
srv.listen(targetWebPort, "0.0.0.0", () => { srv.listen(targetWebPort, activeBindHost, () => {
secondaryWebServer = srv; secondaryWebServer = srv;
res(targetWebPort); res(targetWebPort);
}); });
@@ -257,6 +327,9 @@ export async function buildApp() {
return { return {
ok: true, ok: true,
enabled: persisted.enabled, enabled: persisted.enabled,
host: persisted.host,
activeHost: activeBindHost || persisted.host,
allowedHosts: persisted.allowedHosts,
port: persisted.port, port: persisted.port,
activePort: config.port, activePort: config.port,
webPort: persisted.webPort, webPort: persisted.webPort,
@@ -281,12 +354,22 @@ export async function buildApp() {
if (!Number.isFinite(nextWebPort) || nextWebPort < 1024 || nextWebPort > 65535) { if (!Number.isFinite(nextWebPort) || nextWebPort < 1024 || nextWebPort > 65535) {
return reply.code(400).send({ error: "invalid_web_port" }); return reply.code(400).send({ error: "invalid_web_port" });
} }
const nextHost =
typeof body.host === "string" && body.host.trim()
? body.host.trim()
: current.host;
const nextAllowedHosts =
typeof body.allowedHosts === "string" && body.allowedHosts.trim()
? body.allowedHosts.trim()
: current.allowedHosts;
const nextProjectDir = const nextProjectDir =
typeof body.projectDirectory === "string" && body.projectDirectory.trim() typeof body.projectDirectory === "string" && body.projectDirectory.trim()
? body.projectDirectory.trim() ? body.projectDirectory.trim()
: current.projectDirectory; : current.projectDirectory;
const next: PersistedServerSysConfig = { const next: PersistedServerSysConfig = {
enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled, enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled,
host: nextHost,
allowedHosts: nextAllowedHosts,
port: nextPort, port: nextPort,
webPort: nextWebPort, webPort: nextWebPort,
startOnBoot: startOnBoot:
@@ -294,10 +377,13 @@ export async function buildApp() {
projectDirectory: nextProjectDir, projectDirectory: nextProjectDir,
}; };
const configured = applySystemStartupFiles(next); const configured = applySystemStartupFiles(next);
const boundWebPort = await bindSecondaryWebPort(next.webPort, next.enabled); const boundWebPort = await bindSecondaryWebPort(next.webPort, next.host, next.enabled);
return { return {
ok: true, ok: true,
enabled: next.enabled, enabled: next.enabled,
host: next.host,
activeHost: activeBindHost,
allowedHosts: next.allowedHosts,
port: next.port, port: next.port,
activePort: config.port, activePort: config.port,
webPort: next.webPort, webPort: next.webPort,
@@ -412,7 +498,7 @@ export async function buildApp() {
fileURLToPath(import.meta.url) === process.argv[1] fileURLToPath(import.meta.url) === process.argv[1]
) { ) {
const persisted = loadPersistedServerSysConfig(); const persisted = loadPersistedServerSysConfig();
void bindSecondaryWebPort(persisted.webPort, persisted.enabled); void bindSecondaryWebPort(persisted.webPort, persisted.host, persisted.enabled);
} }
return app; return app;