mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 21:20:44 +00:00
feat: add proxy validation command
Adds `openclaw proxy validate` for operator-managed proxy preflight checks, including allowed/denied destination validation, CLI output, tests, docs, and changelog coverage. Maintainer follow-ups before landing: - validate custom allowed URLs before probing; - use a temporary loopback canary for default denied checks and fail custom denied transport errors as unverifiable; - redact proxy URL userinfo, query strings, and fragments from text/JSON validation output. Validation: - `pnpm test src/infra/net/proxy/proxy-validation.test.ts src/cli/proxy-cli.runtime.test.ts src/cli/proxy-cli.test.ts -- --reporter=verbose` - `pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/cli/proxy-cli.ts src/cli/proxy-cli.runtime.ts src/cli/proxy-cli.test.ts src/cli/proxy-cli.runtime.test.ts src/infra/net/proxy/proxy-validation.ts src/infra/net/proxy/proxy-validation.test.ts docs/cli/proxy.md docs/security/network-proxy.md` - `pnpm exec oxlint src/cli/proxy-cli.runtime.ts src/cli/proxy-cli.runtime.test.ts` - `git diff --check` - Testbox `pnpm install && OPENCLAW_TESTBOX=1 pnpm check:changed` on `tbx_01kqgz68ff20n3dtrgq0j1mykt` - GitHub CI success on `321b3aaf2b8be27dec6ce2ac5e4007ed064218b5`
This commit is contained in:
@@ -4,10 +4,14 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { serverStopSpy, spawnMock } = vi.hoisted(() => ({
|
||||
serverStopSpy: vi.fn(async () => undefined),
|
||||
spawnMock: vi.fn(),
|
||||
}));
|
||||
const { getRuntimeConfigMock, runProxyValidationMock, serverStopSpy, spawnMock } = vi.hoisted(
|
||||
() => ({
|
||||
getRuntimeConfigMock: vi.fn(),
|
||||
runProxyValidationMock: vi.fn(),
|
||||
serverStopSpy: vi.fn(async () => undefined),
|
||||
spawnMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
@@ -24,6 +28,14 @@ vi.mock("../proxy-capture/proxy-server.js", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
getRuntimeConfig: getRuntimeConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/net/proxy/proxy-validation.js", () => ({
|
||||
runProxyValidation: runProxyValidationMock,
|
||||
}));
|
||||
|
||||
describe("proxy cli runtime", () => {
|
||||
const envKeys = [
|
||||
"OPENCLAW_DEBUG_PROXY_DB_PATH",
|
||||
@@ -42,6 +54,33 @@ describe("proxy cli runtime", () => {
|
||||
process.env.OPENCLAW_DEBUG_PROXY_CERT_DIR = path.join(tempDir, "certs");
|
||||
delete process.env.OPENCLAW_DEBUG_PROXY_ENABLED;
|
||||
delete process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID;
|
||||
getRuntimeConfigMock.mockReset();
|
||||
getRuntimeConfigMock.mockReturnValue({
|
||||
proxy: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://config-proxy.example:3128",
|
||||
},
|
||||
});
|
||||
runProxyValidationMock.mockReset();
|
||||
runProxyValidationMock.mockResolvedValue({
|
||||
ok: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://config-proxy.example:3128",
|
||||
source: "config",
|
||||
errors: [],
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
kind: "allowed",
|
||||
url: "https://example.com/",
|
||||
ok: true,
|
||||
status: 200,
|
||||
},
|
||||
],
|
||||
});
|
||||
process.exitCode = undefined;
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
serverStopSpy.mockClear();
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
@@ -49,7 +88,9 @@ describe("proxy cli runtime", () => {
|
||||
afterEach(async () => {
|
||||
const { closeDebugProxyCaptureStore } = await import("../proxy-capture/store.sqlite.js");
|
||||
closeDebugProxyCaptureStore();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
process.exitCode = undefined;
|
||||
for (const key of envKeys) {
|
||||
const value = savedEnv[key];
|
||||
if (value == null) {
|
||||
@@ -61,6 +102,289 @@ describe("proxy cli runtime", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prints proxy validation text and leaves exit code unset on success", async () => {
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({
|
||||
proxyUrl: "http://override.example:3128",
|
||||
allowedUrls: ["https://allowed.example/"],
|
||||
deniedUrls: ["http://127.0.0.1/"],
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
|
||||
expect(getRuntimeConfigMock).toHaveBeenCalledOnce();
|
||||
expect(runProxyValidationMock).toHaveBeenCalledWith({
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://config-proxy.example:3128",
|
||||
},
|
||||
env: process.env,
|
||||
proxyUrlOverride: "http://override.example:3128",
|
||||
allowedUrls: ["https://allowed.example/"],
|
||||
deniedUrls: ["http://127.0.0.1/"],
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
"Proxy validation passed\n\n" +
|
||||
"Proxy\n" +
|
||||
" Source: config\n" +
|
||||
" URL: http://config-proxy.example:3128/\n\n" +
|
||||
"Checks\n" +
|
||||
" ✓ allowed https://example.com/ HTTP 200\n",
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts proxy credentials in text output", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://user:secret@proxy.example:3128?token=secret#fragment",
|
||||
source: "config",
|
||||
errors: [],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({});
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
"Proxy validation passed\n\n" +
|
||||
"Proxy\n" +
|
||||
" Source: config\n" +
|
||||
" URL: http://redacted:redacted@proxy.example:3128/\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts proxy credentials in JSON output", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://user:secret@proxy.example:3128?token=secret#fragment",
|
||||
source: "config",
|
||||
errors: [],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({ json: true });
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://redacted:redacted@proxy.example:3128/",
|
||||
source: "config",
|
||||
errors: [],
|
||||
},
|
||||
checks: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it("prints actionable disabled proxy config output", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
proxyUrl: "http://proxy.example:3128",
|
||||
source: "config",
|
||||
errors: ["proxy validation requires proxy.enabled to be true for configured proxy URLs"],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({});
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
"Proxy validation failed\n\n" +
|
||||
"Proxy\n" +
|
||||
" Source: config\n" +
|
||||
" URL: http://proxy.example:3128/\n\n" +
|
||||
"Problems\n" +
|
||||
" - proxy validation requires proxy.enabled to be true for configured proxy URLs\n\n" +
|
||||
"Next steps\n" +
|
||||
" Enable proxy.enabled with proxy.proxyUrl or OPENCLAW_PROXY_URL, or pass --proxy-url for an explicit one-off validation.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints actionable output when proxy config is disabled and missing", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
source: "disabled",
|
||||
errors: [
|
||||
"proxy validation requires proxy.enabled=true with proxy.proxyUrl or OPENCLAW_PROXY_URL, or --proxy-url",
|
||||
],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({});
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
"Proxy validation failed\n\n" +
|
||||
"Proxy\n" +
|
||||
" Source: disabled\n" +
|
||||
" URL: not configured\n\n" +
|
||||
"Problems\n" +
|
||||
" - proxy validation requires proxy.enabled=true with proxy.proxyUrl or OPENCLAW_PROXY_URL, or --proxy-url\n\n" +
|
||||
"Next steps\n" +
|
||||
" Enable proxy.enabled with proxy.proxyUrl or OPENCLAW_PROXY_URL, or pass --proxy-url for an explicit one-off validation.\n",
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("redacts malformed proxy URLs in text output", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://user:secret@",
|
||||
source: "env",
|
||||
errors: ["proxyUrl must use http://"],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({});
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
"Proxy validation failed\n\n" +
|
||||
"Proxy\n" +
|
||||
" Source: env\n" +
|
||||
" URL: <invalid proxy URL>\n\n" +
|
||||
"Problems\n" +
|
||||
" - proxyUrl must use http://\n\n" +
|
||||
"Next steps\n" +
|
||||
" Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// proxy.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts malformed proxy URLs in JSON output", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://user:secret@",
|
||||
source: "override",
|
||||
errors: ["proxyUrl must use http://"],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({ json: true });
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "<invalid proxy URL>",
|
||||
source: "override",
|
||||
errors: ["proxyUrl must use http://"],
|
||||
},
|
||||
checks: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it("prints actionable check failure output", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://proxy.example:3128",
|
||||
source: "config",
|
||||
errors: [],
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
kind: "allowed",
|
||||
url: "http://target.example/allowed",
|
||||
ok: true,
|
||||
status: 200,
|
||||
},
|
||||
{
|
||||
kind: "denied",
|
||||
url: "http://target.example/allowed",
|
||||
ok: false,
|
||||
status: 200,
|
||||
error: "Denied destination was reachable through the proxy",
|
||||
},
|
||||
],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({});
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
"Proxy validation failed\n\n" +
|
||||
"Proxy\n" +
|
||||
" Source: config\n" +
|
||||
" URL: http://proxy.example:3128/\n\n" +
|
||||
"Checks\n" +
|
||||
" ✓ allowed http://target.example/allowed HTTP 200\n" +
|
||||
" ✗ denied http://target.example/allowed HTTP 200\n" +
|
||||
" Denied destination was reachable through the proxy\n\n" +
|
||||
"Next steps\n" +
|
||||
" Update the proxy ACL so denied destinations are blocked, or pass the expected --denied-url values.\n",
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("prints proxy validation JSON and sets exit code on failure", async () => {
|
||||
runProxyValidationMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: true,
|
||||
source: "missing",
|
||||
errors: ["proxy validation requires proxy.proxyUrl, --proxy-url, or OPENCLAW_PROXY_URL"],
|
||||
},
|
||||
checks: [],
|
||||
});
|
||||
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
|
||||
|
||||
await runProxyValidateCommand({ json: true });
|
||||
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
config: {
|
||||
enabled: true,
|
||||
source: "missing",
|
||||
errors: [
|
||||
"proxy validation requires proxy.proxyUrl, --proxy-url, or OPENCLAW_PROXY_URL",
|
||||
],
|
||||
},
|
||||
checks: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("stops the proxy server and ends the session when child spawn fails", async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new EventEmitter();
|
||||
|
||||
Reference in New Issue
Block a user