fix remote auth proxy bypass and shutdown

This commit is contained in:
Ashish Huddar 2026-05-20 11:51:27 +05:30
parent 0f1a549cb5
commit 331a08b6d7
4 changed files with 94 additions and 23 deletions

View File

@ -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",
);
}
}

View File

@ -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"));

View File

@ -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");

View File

@ -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();
}