206 lines
6.9 KiB
TypeScript
206 lines
6.9 KiB
TypeScript
|
|
import { createHash } from "node:crypto";
|
||
|
|
import { isIP } from "node:net";
|
||
|
|
import type { DesktopSetup } from "@rakazo/contracts";
|
||
|
|
|
||
|
|
/** Where `pnpm dev` serves the Rakazo web app on this machine. */
|
||
|
|
export const DEFAULT_LOCAL_WEB_URL = "http://127.0.0.1:5173";
|
||
|
|
|
||
|
|
export const SETUP_FILE_NAME = "setup.json";
|
||
|
|
|
||
|
|
export type StartupTarget =
|
||
|
|
| { kind: "app"; url: string; source: "env" | "saved" }
|
||
|
|
| { kind: "setup" };
|
||
|
|
|
||
|
|
const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Accepts what a person would actually type ("localhost:5173", "rakazo.example.com")
|
||
|
|
* and returns a canonical http(s) origin, or null when the input can never
|
||
|
|
* securely address a Rakazo server.
|
||
|
|
*/
|
||
|
|
export function normalizeServerUrl(input: string): string | null {
|
||
|
|
const trimmed = input.trim();
|
||
|
|
if (trimmed === "") return null;
|
||
|
|
|
||
|
|
let url: URL;
|
||
|
|
try {
|
||
|
|
if (SCHEME.test(trimmed)) {
|
||
|
|
url = new URL(trimmed);
|
||
|
|
} else {
|
||
|
|
const candidate = new URL(`http://${trimmed}`);
|
||
|
|
url = isLocalNetworkHost(candidate.hostname) ? candidate : new URL(`https://${trimmed}`);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||
|
|
if (url.hostname === "") return null;
|
||
|
|
// Embedded credentials would be written to disk in cleartext.
|
||
|
|
if (url.username !== "" || url.password !== "") return null;
|
||
|
|
// Public login cookies and API traffic must never cross a cleartext connection.
|
||
|
|
if (url.protocol === "http:" && !isLocalNetworkHost(url.hostname)) return null;
|
||
|
|
|
||
|
|
// Rakazo serves its renderer, RPC, and auth routes from one origin. Keeping a
|
||
|
|
// user-supplied path would make the setup probe and the loaded app disagree.
|
||
|
|
return url.origin;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Validates an untrusted value (saved file or IPC payload) into a usable setup. */
|
||
|
|
export function parseSetupInput(value: unknown): DesktopSetup | null {
|
||
|
|
if (typeof value !== "object" || value === null) return null;
|
||
|
|
|
||
|
|
const { mode, serverUrl } = value as Record<string, unknown>;
|
||
|
|
if (mode !== "new" && mode !== "existing") return null;
|
||
|
|
if (typeof serverUrl !== "string") return null;
|
||
|
|
|
||
|
|
const normalized = normalizeServerUrl(serverUrl);
|
||
|
|
if (normalized === null) return null;
|
||
|
|
if (mode === "new" && !isLoopbackHost(new URL(normalized).hostname)) return null;
|
||
|
|
return { mode, serverUrl: normalized };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function parseStoredSetup(raw: string): DesktopSetup | null {
|
||
|
|
try {
|
||
|
|
return parseSetupInput(JSON.parse(raw));
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function serializeSetup(setup: DesktopSetup): string {
|
||
|
|
return `${JSON.stringify(setup, null, 2)}\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Decides between the first-run setup window and the app window. An explicit
|
||
|
|
* `RAKAZO_WEB_URL` still wins over saved configuration so test and performance
|
||
|
|
* harnesses can point the shell anywhere without touching a user's real setup.
|
||
|
|
*/
|
||
|
|
export function resolveStartupTarget(input: {
|
||
|
|
envUrl?: string;
|
||
|
|
saved?: DesktopSetup | null;
|
||
|
|
forceSetup?: boolean;
|
||
|
|
}): StartupTarget {
|
||
|
|
if (input.forceSetup === true) return { kind: "setup" };
|
||
|
|
|
||
|
|
const envUrl = input.envUrl?.trim();
|
||
|
|
if (envUrl !== undefined && envUrl !== "") return { kind: "app", url: envUrl, source: "env" };
|
||
|
|
|
||
|
|
if (input.saved != null) {
|
||
|
|
const saved = parseSetupInput(input.saved);
|
||
|
|
if (saved !== null) return { kind: "app", url: saved.serverUrl, source: "saved" };
|
||
|
|
}
|
||
|
|
return { kind: "setup" };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Turns a network failure into something a person can act on. */
|
||
|
|
export function probeFailureMessage(error: unknown): string {
|
||
|
|
const name = error instanceof Error ? error.name : "";
|
||
|
|
if (name === "TimeoutError" || name === "AbortError") {
|
||
|
|
return "Timed out reaching that address.";
|
||
|
|
}
|
||
|
|
|
||
|
|
const detail = error instanceof Error ? error.message : String(error);
|
||
|
|
if (detail.includes("CONNECTION_REFUSED") || detail.includes("ECONNREFUSED")) {
|
||
|
|
return "Nothing is listening at that address yet.";
|
||
|
|
}
|
||
|
|
if (detail.includes("NAME_NOT_RESOLVED") || detail.includes("ENOTFOUND")) {
|
||
|
|
return "That host could not be found.";
|
||
|
|
}
|
||
|
|
if (detail.includes("CERT_") || detail.includes("SSL")) {
|
||
|
|
return "The server's HTTPS certificate was rejected.";
|
||
|
|
}
|
||
|
|
return "Could not reach that address.";
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The bundled renderer only stands in for a real http(s) origin. */
|
||
|
|
export function servesBundledRenderer(targetUrl: string): boolean {
|
||
|
|
try {
|
||
|
|
const { protocol } = new URL(targetUrl);
|
||
|
|
return protocol === "http:" || protocol === "https:";
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Each Rakazo origin gets its own persistent cookie and storage partition. */
|
||
|
|
export function sessionPartitionForServerUrl(targetUrl: string): string | null {
|
||
|
|
try {
|
||
|
|
const url = new URL(targetUrl);
|
||
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||
|
|
const digest = createHash("sha256").update(url.origin).digest("hex").slice(0, 24);
|
||
|
|
return `persist:rakazo-${digest}`;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** External pages are opened by the OS, never in a privileged Electron child window. */
|
||
|
|
export function safeExternalUrl(targetUrl: string): string | null {
|
||
|
|
if (!servesBundledRenderer(targetUrl)) return null;
|
||
|
|
return new URL(targetUrl).toString();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isRakazoHealth(value: unknown): boolean {
|
||
|
|
if (typeof value !== "object" || value === null) return false;
|
||
|
|
const json = (value as { json?: unknown }).json;
|
||
|
|
return (
|
||
|
|
typeof json === "object" &&
|
||
|
|
json !== null &&
|
||
|
|
(json as { ok?: unknown }).ok === true &&
|
||
|
|
typeof (json as { version?: unknown }).version === "string"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isLoopbackHost(hostname: string) {
|
||
|
|
const host = unbracketedHost(hostname);
|
||
|
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||
|
|
if (isIP(host) === 4) return host.startsWith("127.");
|
||
|
|
return isIP(host) === 6 && host === "::1";
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Link-local addresses (IPv4 169.254/16, IPv6 fe80::/10) often host cloud
|
||
|
|
* metadata endpoints. Cleartext HTTP to them is never a legitimate Rakazo
|
||
|
|
* deploy target, so they stay out of the private-network HTTP allowlist.
|
||
|
|
*/
|
||
|
|
function isLinkLocalHost(hostname: string) {
|
||
|
|
const host = unbracketedHost(hostname);
|
||
|
|
if (isIP(host) === 4) {
|
||
|
|
const [first, second] = host.split(".").map(Number);
|
||
|
|
return first === 169 && second === 254;
|
||
|
|
}
|
||
|
|
if (isIP(host) === 6) {
|
||
|
|
const first = host.split(":", 1)[0] ?? "";
|
||
|
|
return /^fe[89ab]/.test(first);
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isLocalNetworkHost(hostname: string) {
|
||
|
|
const host = unbracketedHost(hostname);
|
||
|
|
if (isLoopbackHost(host) || host.endsWith(".local")) return true;
|
||
|
|
if (isLinkLocalHost(host)) return false;
|
||
|
|
|
||
|
|
if (isIP(host) === 4) {
|
||
|
|
const [first, second] = host.split(".").map(Number);
|
||
|
|
return (
|
||
|
|
first === 10 ||
|
||
|
|
(first === 172 && second !== undefined && second >= 16 && second <= 31) ||
|
||
|
|
(first === 192 && second === 168)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isIP(host) === 6) {
|
||
|
|
const first = host.split(":", 1)[0] ?? "";
|
||
|
|
// Unique-local only (fc00::/7). Link-local is rejected above.
|
||
|
|
return /^f[cd]/.test(first);
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function unbracketedHost(hostname: string) {
|
||
|
|
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||
|
|
}
|