diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 28e8e6d..8c8b694 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -370,6 +370,9 @@ export const api = { getServerConfig: async (portHint?: number): Promise<{ online: boolean; enabled: boolean; + host?: string; + activeHost?: string; + allowedHosts?: string; port: number; activePort?: number; webPort?: number; @@ -388,6 +391,9 @@ export const api = { if (res.ok && ct.includes("application/json")) { const data = (await res.json()) as { enabled: boolean; + host?: string; + activeHost?: string; + allowedHosts?: string; port: number; activePort?: number; webPort?: number; @@ -398,6 +404,9 @@ export const api = { return { online: true, enabled: data.enabled, + ...(data.host ? { host: data.host } : {}), + ...(data.activeHost ? { activeHost: data.activeHost } : {}), + ...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}), port: data.port, ...(data.activePort !== undefined ? { activePort: data.activePort } : {}), ...(data.webPort !== undefined ? { webPort: data.webPort } : {}), @@ -413,6 +422,8 @@ export const api = { return { online: false, enabled: true, + host: "127.0.0.1", + allowedHosts: "localhost, 127.0.0.1", port: portHint ?? 8081, webPort: 5173, startOnBoot: false, @@ -422,6 +433,8 @@ export const api = { updateServerConfig: async ( patch: { enabled?: boolean; + host?: string; + allowedHosts?: string; port?: number; webPort?: number; startOnBoot?: boolean; @@ -431,6 +444,9 @@ export const api = { ): Promise<{ online: boolean; enabled: boolean; + host?: string; + activeHost?: string; + allowedHosts?: string; port: number; activePort?: number; webPort?: number; @@ -454,6 +470,9 @@ export const api = { if (res.ok && ct.includes("application/json")) { const data = (await res.json()) as { enabled: boolean; + host?: string; + activeHost?: string; + allowedHosts?: string; port: number; activePort?: number; webPort?: number; @@ -464,6 +483,9 @@ export const api = { return { online: true, enabled: data.enabled, + ...(data.host ? { host: data.host } : {}), + ...(data.activeHost ? { activeHost: data.activeHost } : {}), + ...(data.allowedHosts ? { allowedHosts: data.allowedHosts } : {}), port: data.port, ...(data.activePort !== undefined ? { activePort: data.activePort } : {}), ...(data.webPort !== undefined ? { webPort: data.webPort } : {}), @@ -479,6 +501,8 @@ export const api = { return { online: false, enabled: patch.enabled ?? true, + ...(patch.host ? { host: patch.host } : {}), + ...(patch.allowedHosts ? { allowedHosts: patch.allowedHosts } : {}), port: patch.port ?? portHint ?? 8081, ...(patch.webPort !== undefined ? { webPort: patch.webPort } : {}), startOnBoot: patch.startOnBoot ?? false, diff --git a/apps/web/src/components/Settings.tsx b/apps/web/src/components/Settings.tsx index 36d68b7..50dca83 100644 --- a/apps/web/src/components/Settings.tsx +++ b/apps/web/src/components/Settings.tsx @@ -67,6 +67,10 @@ export function Settings() { const [probing, setProbing] = useState(false); // Server & Project Directory state + const [hostDraft, setHostDraft] = useState(s.serverHost || "127.0.0.1"); + const [allowedHostsDraft, setAllowedHostsDraft] = useState( + s.allowedHosts || "localhost, 127.0.0.1", + ); const [webPortDraft, setWebPortDraft] = useState(String(s.webPort || 5173)); const [portDraft, setPortDraft] = useState(String(s.serverPort)); const [projectDirDraft, setProjectDirDraft] = useState( @@ -74,6 +78,7 @@ export function Settings() { ); const [browseProjectDirOpen, setBrowseProjectDirOpen] = useState(false); const [serverOnline, setServerOnline] = useState(false); + const [activeRunningHost, setActiveRunningHost] = useState(s.serverHost || "127.0.0.1"); const [activeRunningPort, setActiveRunningPort] = useState(null); const [activeRunningWebPort, setActiveRunningWebPort] = useState(null); const [savingServerCfg, setSavingServerCfg] = useState(false); @@ -83,9 +88,12 @@ export function Settings() { void api.getServerConfig(s.serverPort).then((res) => { if (cancelled) return; setServerOnline(res.online); + setActiveRunningHost(res.activeHost ?? res.host ?? s.serverHost); setActiveRunningPort(res.activePort ?? res.port); setActiveRunningWebPort(res.activeWebPort ?? res.webPort ?? s.webPort); if (res.online) { + if (res.host) setHostDraft(res.host); + if (res.allowedHosts) setAllowedHostsDraft(res.allowedHosts); setPortDraft(String(res.port)); if (res.webPort) { setWebPortDraft(String(res.webPort)); @@ -95,6 +103,8 @@ export function Settings() { } set({ serverEnabled: res.enabled, + ...(res.host ? { serverHost: res.host } : {}), + ...(res.allowedHosts ? { allowedHosts: res.allowedHosts } : {}), serverPort: res.port, ...(res.webPort ? { webPort: res.webPort } : {}), 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 parsed = parseInt(webPortDraft.trim(), 10); if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) { @@ -1027,12 +1057,113 @@ export function Settings() { +
+
+
+
+ Bind Host IP + + {`${activeRunningHost || s.serverHost}:${activeRunningWebPort ?? s.webPort}`} + +
+
+ IP address the server listens on. Default is 127.0.0.1 (localhost only). Set to 0.0.0.0 to expose on all network interfaces for a reverse proxy or LAN. +
+
+
+ + +
+
+
+ setHostDraft(e.target.value)} + placeholder="127.0.0.1 or 0.0.0.0" + aria-label="Bind host IP" + /> + +
+
+ +
+
+
Allowed Hosts / Reverse Proxy Domain URLs
+
+ Comma-separated list of allowable hostnames, IPs, or domain URLs (e.g. localhost, 127.0.0.1, https://pubscript.mydomain.com, *.mydomain.com, 0.0.0.0). +
+
+
+ setAllowedHostsDraft(e.target.value)} + placeholder="localhost, 127.0.0.1, https://write.example.com" + aria-label="Allowed hosts and domains" + /> + + +
+
+
Web App Port - {`http://localhost:${activeRunningWebPort ?? s.webPort}`} + {`http://${activeRunningHost || s.serverHost}:${activeRunningWebPort ?? s.webPort}`}
diff --git a/apps/web/src/store/settings.ts b/apps/web/src/store/settings.ts index aca7b96..d03f05f 100644 --- a/apps/web/src/store/settings.ts +++ b/apps/web/src/store/settings.ts @@ -274,6 +274,8 @@ export interface AppSettings { activeAgentId: string; // Local / Self-Hosted Server & Startup Settings serverEnabled: boolean; + serverHost: string; + allowedHosts: string; webPort: number; serverPort: number; serverStartOnBoot: boolean; @@ -310,6 +312,8 @@ export const DEFAULT_SETTINGS: AppSettings = { activeAgentId: DEFAULT_AI_AGENTS[0]!.id, // Local / Self-Hosted Server & Startup Settings serverEnabled: true, + serverHost: "127.0.0.1", + allowedHosts: "localhost, 127.0.0.1", webPort: 5173, serverPort: 8081, serverStartOnBoot: false, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ffce22e..0215aad 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ host: "0.0.0.0", port: 5173, strictPort: false, + allowedHosts: true, proxy: { "/api": { target: process.env.VITE_API_ORIGIN ?? "http://127.0.0.1:8081", @@ -30,5 +31,6 @@ export default defineConfig({ preview: { host: "0.0.0.0", port: 4173, + allowedHosts: true, }, }); diff --git a/server/src/index.ts b/server/src/index.ts index f90e4fe..e72778e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -46,7 +46,7 @@ function makeHttpError(status: number, payload: object): HttpError { } 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(cookie); await app.register(websocket); @@ -101,6 +101,8 @@ export async function buildApp() { interface PersistedServerSysConfig { enabled: boolean; + host: string; + allowedHosts: string; port: number; webPort: number; startOnBoot: boolean; @@ -109,6 +111,48 @@ export async function buildApp() { let secondaryWebServer: HttpServer | null = null; 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 { try { @@ -116,6 +160,14 @@ export async function buildApp() { const raw = JSON.parse(readFileSync(sysConfigPath, "utf8")) as Partial; return { 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: typeof raw.port === "number" && raw.port >= 1024 && raw.port <= 65535 ? raw.port @@ -139,6 +191,8 @@ export async function buildApp() { } return { enabled: true, + host: "127.0.0.1", + allowedHosts: "localhost, 127.0.0.1", port: config.port, webPort: 5173, startOnBoot: existsSync(autostartDesktopPath) || existsSync(systemdServicePath), @@ -146,8 +200,24 @@ export async function buildApp() { }; } - async function bindSecondaryWebPort(targetWebPort: number, enabled: boolean): Promise { + 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 { activeWebPort = targetWebPort; + activeBindHost = targetHost || "127.0.0.1"; if (secondaryWebServer) { await new Promise((res) => secondaryWebServer!.close(() => res())); secondaryWebServer = null; @@ -163,7 +233,7 @@ export async function buildApp() { // Port may already be served by Vite dev server (e.g. :5173); keep activeWebPort res(targetWebPort); }); - srv.listen(targetWebPort, "0.0.0.0", () => { + srv.listen(targetWebPort, activeBindHost, () => { secondaryWebServer = srv; res(targetWebPort); }); @@ -257,6 +327,9 @@ export async function buildApp() { return { ok: true, enabled: persisted.enabled, + host: persisted.host, + activeHost: activeBindHost || persisted.host, + allowedHosts: persisted.allowedHosts, port: persisted.port, activePort: config.port, webPort: persisted.webPort, @@ -281,12 +354,22 @@ export async function buildApp() { if (!Number.isFinite(nextWebPort) || nextWebPort < 1024 || nextWebPort > 65535) { 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 = typeof body.projectDirectory === "string" && body.projectDirectory.trim() ? body.projectDirectory.trim() : current.projectDirectory; const next: PersistedServerSysConfig = { enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled, + host: nextHost, + allowedHosts: nextAllowedHosts, port: nextPort, webPort: nextWebPort, startOnBoot: @@ -294,10 +377,13 @@ export async function buildApp() { projectDirectory: nextProjectDir, }; const configured = applySystemStartupFiles(next); - const boundWebPort = await bindSecondaryWebPort(next.webPort, next.enabled); + const boundWebPort = await bindSecondaryWebPort(next.webPort, next.host, next.enabled); return { ok: true, enabled: next.enabled, + host: next.host, + activeHost: activeBindHost, + allowedHosts: next.allowedHosts, port: next.port, activePort: config.port, webPort: next.webPort, @@ -412,7 +498,7 @@ export async function buildApp() { fileURLToPath(import.meta.url) === process.argv[1] ) { const persisted = loadPersistedServerSysConfig(); - void bindSecondaryWebPort(persisted.webPort, persisted.enabled); + void bindSecondaryWebPort(persisted.webPort, persisted.host, persisted.enabled); } return app;