feat: add Bind Host IP and Allowed Hosts / Reverse Proxy Domain settings
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -67,6 +67,10 @@ export function Settings() {
|
||||
const [probing, setProbing] = useState(false);
|
||||
|
||||
// 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 [portDraft, setPortDraft] = useState<string>(String(s.serverPort));
|
||||
const [projectDirDraft, setProjectDirDraft] = useState<string>(
|
||||
@@ -74,6 +78,7 @@ export function Settings() {
|
||||
);
|
||||
const [browseProjectDirOpen, setBrowseProjectDirOpen] = 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 [activeRunningWebPort, setActiveRunningWebPort] = useState<number | null>(null);
|
||||
const [savingServerCfg, setSavingServerCfg] = useState<boolean>(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() {
|
||||
</label>
|
||||
</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>
|
||||
<div className="ps-settings-label" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span>Web App Port</span>
|
||||
<Badge variant={!s.serverEnabled ? "neutral" : "accent"}>
|
||||
{`http://localhost:${activeRunningWebPort ?? s.webPort}`}
|
||||
{`http://${activeRunningHost || s.serverHost}:${activeRunningWebPort ?? s.webPort}`}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="ps-settings-desc">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+91
-5
@@ -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<PersistedServerSysConfig>;
|
||||
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<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;
|
||||
activeBindHost = targetHost || "127.0.0.1";
|
||||
if (secondaryWebServer) {
|
||||
await new Promise<void>((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;
|
||||
|
||||
Reference in New Issue
Block a user