fix remote auth proxy bypass and shutdown
This commit is contained in:
parent
0f1a549cb5
commit
331a08b6d7
|
|
@ -10,6 +10,7 @@ import { createRequire } from "node:module";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { isLinux, isWindows } from "@aoagents/ao-core";
|
||||
import { formatCommandError } from "./cli-errors.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -32,9 +33,18 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||
return new Promise((resolve) => {
|
||||
const s = new Socket();
|
||||
s.setTimeout(300);
|
||||
s.once("connect", () => { s.destroy(); resolve(false); }); // something listening → in use
|
||||
s.once("error", () => { s.destroy(); resolve(true); }); // ECONNREFUSED → free
|
||||
s.once("timeout", () => { s.destroy(); resolve(true); }); // no response → free
|
||||
s.once("connect", () => {
|
||||
s.destroy();
|
||||
resolve(false);
|
||||
}); // something listening → in use
|
||||
s.once("error", () => {
|
||||
s.destroy();
|
||||
resolve(true);
|
||||
}); // ECONNREFUSED → free
|
||||
s.once("timeout", () => {
|
||||
s.destroy();
|
||||
resolve(true);
|
||||
}); // no response → free
|
||||
s.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
|
@ -57,10 +67,9 @@ export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Prom
|
|||
* Open a URL in the user's browser without throwing back into the caller.
|
||||
*/
|
||||
export function openUrl(url: string): void {
|
||||
const [cmd, args]: [string, string[]] =
|
||||
process.platform === "win32"
|
||||
? ["cmd.exe", ["/c", "start", "", url]]
|
||||
: [process.platform === "linux" ? "xdg-open" : "open", [url]];
|
||||
const [cmd, args]: [string, string[]] = isWindows()
|
||||
? ["cmd.exe", ["/c", "start", "", url]]
|
||||
: [isLinux() ? "xdg-open" : "open", [url]];
|
||||
const browser = spawn(cmd, args, { stdio: "ignore" });
|
||||
browser.on("error", (err) => {
|
||||
console.warn(
|
||||
|
|
@ -68,12 +77,11 @@ export function openUrl(url: string): void {
|
|||
cmd,
|
||||
args,
|
||||
action: `open ${url} in a browser`,
|
||||
installHints:
|
||||
process.platform === "linux"
|
||||
? ["Install xdg-utils so `xdg-open` is available on PATH."]
|
||||
: process.platform === "win32"
|
||||
? []
|
||||
: [],
|
||||
installHints: isLinux()
|
||||
? ["Install xdg-utils so `xdg-open` is available on PATH."]
|
||||
: isWindows()
|
||||
? []
|
||||
: [],
|
||||
}).message,
|
||||
);
|
||||
});
|
||||
|
|
@ -145,8 +153,11 @@ export async function buildDashboardEnv(
|
|||
|
||||
// If explicit ports provided (config or env var), use them directly.
|
||||
// Otherwise, auto-detect an available pair starting from the default.
|
||||
const explicitTerminal = terminalPort ?? (env["TERMINAL_PORT"] ? parseInt(env["TERMINAL_PORT"], 10) : undefined);
|
||||
const explicitDirect = directTerminalPort ?? (env["DIRECT_TERMINAL_PORT"] ? parseInt(env["DIRECT_TERMINAL_PORT"], 10) : undefined);
|
||||
const explicitTerminal =
|
||||
terminalPort ?? (env["TERMINAL_PORT"] ? parseInt(env["TERMINAL_PORT"], 10) : undefined);
|
||||
const explicitDirect =
|
||||
directTerminalPort ??
|
||||
(env["DIRECT_TERMINAL_PORT"] ? parseInt(env["DIRECT_TERMINAL_PORT"], 10) : undefined);
|
||||
|
||||
let resolvedTerminal: number;
|
||||
let resolvedDirect: number;
|
||||
|
|
@ -202,8 +213,8 @@ export function findWebDir(): string {
|
|||
}
|
||||
throw new Error(
|
||||
"Could not find @aoagents/ao-web package.\n" +
|
||||
" If installed via npm: npm install -g @aoagents/ao\n" +
|
||||
" If cloned from source: pnpm install && pnpm build",
|
||||
" If installed via npm: npm install -g @aoagents/ao\n" +
|
||||
" If cloned from source: pnpm install && pnpm build",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,17 @@ import {
|
|||
|
||||
export interface DirectTerminalServer {
|
||||
server: Server;
|
||||
shutdown: () => void;
|
||||
shutdown: (onClosed?: () => void) => void;
|
||||
}
|
||||
|
||||
function isRemoteAuthAllowed(url: URL, initialConfiguredAuth: RemoteAuthCredentials): boolean {
|
||||
const { username, password: expectedPassword } = activeRemoteAuth(initialConfiguredAuth);
|
||||
if (!expectedPassword) return true;
|
||||
|
||||
return verifyRemoteWsToken(url.searchParams.get("auth_token"), { username, password: expectedPassword });
|
||||
return verifyRemoteWsToken(url.searchParams.get("auth_token"), {
|
||||
username,
|
||||
password: expectedPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,7 +92,12 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm
|
|||
}
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
let shuttingDown = false;
|
||||
|
||||
function shutdown(onClosed?: () => void) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
// Terminate all connected mux clients — this triggers their 'close' events
|
||||
// which unsubscribe terminal callbacks and kill PTY processes.
|
||||
if (muxWss) {
|
||||
|
|
@ -98,7 +106,12 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm
|
|||
}
|
||||
muxWss.close();
|
||||
}
|
||||
server.close();
|
||||
server.close((err?: Error & { code?: string }) => {
|
||||
if (err && err.code !== "ERR_SERVER_NOT_RUNNING") {
|
||||
console.error("[DirectTerminal] Error during shutdown:", err);
|
||||
}
|
||||
onClosed?.();
|
||||
});
|
||||
}
|
||||
|
||||
return { server, shutdown };
|
||||
|
|
@ -125,19 +138,27 @@ if (isMainModule) {
|
|||
}
|
||||
|
||||
const { server, shutdown } = createDirectTerminalServer(TMUX);
|
||||
let shuttingDown = false;
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[DirectTerminal] WebSocket server listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
function handleShutdown(signal: string) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
console.log(`[DirectTerminal] Received ${signal}, shutting down...`);
|
||||
shutdown();
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.error("[DirectTerminal] Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
forceExitTimer.unref();
|
||||
|
||||
shutdown(() => {
|
||||
clearTimeout(forceExitTimer);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => handleShutdown("SIGINT"));
|
||||
|
|
|
|||
|
|
@ -84,6 +84,36 @@ describe("remote auth middleware", () => {
|
|||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("requires credentials for proxied requests even when the socket is loopback", () => {
|
||||
vi.stubEnv("AO_REMOTE_AUTH_USER", "ao");
|
||||
vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret");
|
||||
vi.stubEnv("AO_TRUST_REMOTE_ADDRESS_HEADER", "1");
|
||||
|
||||
const response = middleware(
|
||||
request("/api/projects", undefined, {
|
||||
"x-ao-remote-address": "127.0.0.1",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("requires credentials for Cloudflare-proxied requests from loopback", () => {
|
||||
vi.stubEnv("AO_REMOTE_AUTH_USER", "ao");
|
||||
vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret");
|
||||
vi.stubEnv("AO_TRUST_REMOTE_ADDRESS_HEADER", "1");
|
||||
|
||||
const response = middleware(
|
||||
request("/api/projects", undefined, {
|
||||
"x-ao-remote-address": "::1",
|
||||
"cf-connecting-ip": "203.0.113.10",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("ignores the loopback socket header unless the AO server marked it trusted", () => {
|
||||
vi.stubEnv("AO_REMOTE_AUTH_USER", "ao");
|
||||
vi.stubEnv("AO_REMOTE_AUTH_PASSWORD", "secret");
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ function isLoopbackAddress(address: string | undefined): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function hasProxyHeaders(request: NextRequest): boolean {
|
||||
return (
|
||||
request.headers.has("x-forwarded-for") ||
|
||||
request.headers.has("x-real-ip") ||
|
||||
request.headers.has("cf-connecting-ip")
|
||||
);
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const password = remoteAuthPassword();
|
||||
const requestIp =
|
||||
|
|
@ -67,7 +75,8 @@ export function middleware(request: NextRequest) {
|
|||
(process.env["AO_TRUST_REMOTE_ADDRESS_HEADER"] === "1"
|
||||
? (request.headers.get("x-ao-remote-address") ?? undefined)
|
||||
: undefined);
|
||||
if (!password || isPublicAsset(request.nextUrl.pathname) || isLoopbackAddress(requestIp)) {
|
||||
const allowLoopbackBypass = isLoopbackAddress(requestIp) && !hasProxyHeaders(request);
|
||||
if (!password || isPublicAsset(request.nextUrl.pathname) || allowLoopbackBypass) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue