mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-05-25 18:07:54 +02:00
feat: add target URL reachability preflight check (#254)
This commit is contained in:
@@ -15,9 +15,13 @@
|
||||
* 1. Repository path exists and contains .git
|
||||
* 2. Config file parses and validates (if provided)
|
||||
* 3. Credentials validate via Claude Agent SDK query (API key, OAuth, Bedrock, Vertex AI, or router mode)
|
||||
* 4. Target URL is reachable from the container (DNS + HTTP)
|
||||
*/
|
||||
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { resolveModel } from '../ai/models.js';
|
||||
@@ -27,6 +31,12 @@ import { ErrorCode } from '../types/errors.js';
|
||||
import { err, ok, type Result } from '../types/result.js';
|
||||
import { isRetryableError, PentestError } from './error-handling.js';
|
||||
|
||||
const TARGET_URL_TIMEOUT_MS = 10_000;
|
||||
|
||||
function isLoopbackAddress(address: string): boolean {
|
||||
return address === '127.0.0.1' || address === '::1' || address === '0.0.0.0';
|
||||
}
|
||||
|
||||
// === Repository Validation ===
|
||||
|
||||
async function validateRepo(repoPath: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
|
||||
@@ -325,6 +335,111 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
|
||||
}
|
||||
}
|
||||
|
||||
// === Target URL Validation ===
|
||||
|
||||
/** HTTP HEAD with TLS verification disabled — we check reachability, not certificate validity. */
|
||||
function httpHead(url: string, timeoutMs: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const req = transport.request(
|
||||
url,
|
||||
{
|
||||
method: 'HEAD',
|
||||
timeout: timeoutMs,
|
||||
...(isHttps && { rejectUnauthorized: false }),
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode ?? 0);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Connection timed out after ${timeoutMs}ms`));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Check that the target URL is reachable from inside the container. */
|
||||
async function validateTargetUrl(targetUrl: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
|
||||
logger.info('Checking target URL reachability...', { targetUrl });
|
||||
|
||||
// 1. Parse URL
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(targetUrl);
|
||||
} catch {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Invalid target URL: ${targetUrl}`,
|
||||
'config',
|
||||
false,
|
||||
{ targetUrl },
|
||||
ErrorCode.TARGET_UNREACHABLE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. DNS lookup — detect loopback addresses early for a better hint
|
||||
const hostname = parsed.hostname;
|
||||
let resolvedAddress: string | undefined;
|
||||
try {
|
||||
const result = await lookup(hostname);
|
||||
resolvedAddress = result.address;
|
||||
} catch {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Target URL ${targetUrl} is not reachable. Verify the URL is correct and the site is up.`,
|
||||
'network',
|
||||
false,
|
||||
{ targetUrl, hostname },
|
||||
ErrorCode.TARGET_UNREACHABLE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. HTTP reachability check
|
||||
try {
|
||||
await httpHead(targetUrl, TARGET_URL_TIMEOUT_MS);
|
||||
|
||||
logger.info('Target URL OK');
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
const isLoopback = isLoopbackAddress(resolvedAddress);
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (isLoopback) {
|
||||
const suggestion = targetUrl.replace(hostname, 'host.docker.internal');
|
||||
return err(
|
||||
new PentestError(
|
||||
`Target URL ${targetUrl} resolves to ${resolvedAddress} (loopback) and is not reachable. ` +
|
||||
`For local services, use host.docker.internal instead of ${hostname} (e.g., ${suggestion})`,
|
||||
'network',
|
||||
false,
|
||||
{ targetUrl, resolvedAddress, hostname },
|
||||
ErrorCode.TARGET_UNREACHABLE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return err(
|
||||
new PentestError(
|
||||
`Target URL ${targetUrl} is not reachable: ${detail}`,
|
||||
'network',
|
||||
false,
|
||||
{ targetUrl, resolvedAddress },
|
||||
ErrorCode.TARGET_UNREACHABLE,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Preflight Orchestrator ===
|
||||
|
||||
/**
|
||||
@@ -333,10 +448,12 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
|
||||
* 1. Repository path exists and contains .git
|
||||
* 2. Config file parses and validates (if configPath provided)
|
||||
* 3. Credentials validate (API key, OAuth, or router mode)
|
||||
* 4. Target URL is reachable from the container
|
||||
*
|
||||
* Returns on first failure.
|
||||
*/
|
||||
export async function runPreflightChecks(
|
||||
targetUrl: string,
|
||||
repoPath: string,
|
||||
configPath: string | undefined,
|
||||
logger: ActivityLogger,
|
||||
@@ -361,6 +478,12 @@ export async function runPreflightChecks(
|
||||
return credResult;
|
||||
}
|
||||
|
||||
// 4. Target URL reachability check (cheap — 1 HTTP round-trip)
|
||||
const urlResult = await validateTargetUrl(targetUrl, logger);
|
||||
if (!urlResult.ok) {
|
||||
return urlResult;
|
||||
}
|
||||
|
||||
logger.info('All preflight checks passed');
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@ export async function runReportAgent(input: ActivityInput): Promise<AgentMetrics
|
||||
* 1. Repository path exists with .git
|
||||
* 2. Config file validates (if provided)
|
||||
* 3. Credential validation (API key, OAuth, or router mode)
|
||||
* 4. Target URL reachable from the container
|
||||
*
|
||||
* NOT using runAgentActivity — preflight doesn't run an agent via the SDK.
|
||||
*/
|
||||
@@ -267,7 +268,7 @@ export async function runPreflightValidation(input: ActivityInput): Promise<void
|
||||
const logger = createActivityLogger();
|
||||
logger.info('Running preflight validation...', { attempt: attemptNumber });
|
||||
|
||||
const result = await runPreflightChecks(input.repoPath, input.configPath, logger);
|
||||
const result = await runPreflightChecks(input.webUrl, input.repoPath, input.configPath, logger);
|
||||
|
||||
if (isErr(result)) {
|
||||
const classified = classifyErrorForTemporal(result.error);
|
||||
|
||||
@@ -42,6 +42,7 @@ export enum ErrorCode {
|
||||
|
||||
// Preflight validation errors
|
||||
REPO_NOT_FOUND = 'REPO_NOT_FOUND',
|
||||
TARGET_UNREACHABLE = 'TARGET_UNREACHABLE',
|
||||
AUTH_FAILED = 'AUTH_FAILED',
|
||||
BILLING_ERROR = 'BILLING_ERROR',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user