feat: add privacy-safe server diagnostics
This commit is contained in:
1 parent
2360506607
commit
a3963ad0f6
27 files changed
+1305
-112
No files matched your search
@@ -3,6 +3,7 @@ import { request as httpsRequest } from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import { basename } from 'node:path';
|
||||
import { sha256Bytes } from './crypto.js';
|
||||
import { diagnosticDurationMs } from './diagnostics.js';
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/i;
|
||||
const BASE64_PATTERN = /^[A-Za-z0-9+/_-]*={0,2}$/;
|
||||
@@ -35,6 +36,11 @@ export interface AgentBusInputAttachmentReference {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type InputAttachmentDiagnostic = (
|
||||
event: string,
|
||||
metadata: Record<string, unknown>
|
||||
) => void;
|
||||
|
||||
export class InputAttachmentError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
@@ -46,6 +52,20 @@ export class InputAttachmentError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function emitAttachmentDiagnostic(
|
||||
diagnostic: InputAttachmentDiagnostic | undefined,
|
||||
event: string,
|
||||
metadata: Record<string, unknown>
|
||||
): void {
|
||||
if (!diagnostic) return;
|
||||
try {
|
||||
diagnostic(event, metadata);
|
||||
} catch {
|
||||
// Diagnostics are deliberately non-authoritative and cannot interrupt
|
||||
// validation, DNS pinning, downloading, or byte verification.
|
||||
}
|
||||
}
|
||||
|
||||
function extensionOf(fileName: string): string {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (lower.endsWith('.xlsx')) return '.xlsx';
|
||||
@@ -282,28 +302,76 @@ async function downloadPinnedHttps(
|
||||
|
||||
export async function downloadAgentBusInputAttachment(
|
||||
reference: AgentBusInputAttachmentReference,
|
||||
maxBytes: number
|
||||
maxBytes: number,
|
||||
diagnostic?: InputAttachmentDiagnostic
|
||||
): Promise<TaskInputAttachmentInput> {
|
||||
let url = validateAgentBusAttachmentUrl(reference.url);
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
const addresses = await publicAddresses(url.hostname.replace(/^\[|\]$/g, ''));
|
||||
const response = await downloadPinnedHttps(url, addresses[0], maxBytes);
|
||||
if (response.statusCode >= 300 && response.statusCode < 400) {
|
||||
if (!response.location || redirects === MAX_REDIRECTS) {
|
||||
throw new InputAttachmentError('roster_attachment_redirect_invalid', '名单附件重定向无效或次数过多。');
|
||||
const startedAt = process.hrtime.bigint();
|
||||
let redirectCount = 0;
|
||||
emitAttachmentDiagnostic(diagnostic, 'download_started', {
|
||||
file_extension: extensionOf(reference.name),
|
||||
declared_size: reference.size ?? null,
|
||||
declared_sha256_present: Boolean(reference.sha256),
|
||||
max_bytes: maxBytes
|
||||
});
|
||||
try {
|
||||
let url = validateAgentBusAttachmentUrl(reference.url);
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
redirectCount = redirects;
|
||||
const dnsStartedAt = process.hrtime.bigint();
|
||||
emitAttachmentDiagnostic(diagnostic, 'dns_started', { redirect_count: redirects });
|
||||
const addresses = await publicAddresses(url.hostname.replace(/^\[|\]$/g, ''));
|
||||
emitAttachmentDiagnostic(diagnostic, 'dns_validated', {
|
||||
redirect_count: redirects,
|
||||
address_count: addresses.length,
|
||||
address_families: [...new Set(addresses.map((address) => address.family))].sort(),
|
||||
selected_address_family: addresses[0]?.family || null,
|
||||
duration_ms: diagnosticDurationMs(dnsStartedAt)
|
||||
});
|
||||
const requestStartedAt = process.hrtime.bigint();
|
||||
const response = await downloadPinnedHttps(url, addresses[0], maxBytes);
|
||||
emitAttachmentDiagnostic(diagnostic, 'http_response', {
|
||||
redirect_count: redirects,
|
||||
status_code: response.statusCode,
|
||||
response_bytes: response.content.byteLength,
|
||||
duration_ms: diagnosticDurationMs(requestStartedAt)
|
||||
});
|
||||
if (response.statusCode >= 300 && response.statusCode < 400) {
|
||||
if (!response.location || redirects === MAX_REDIRECTS) {
|
||||
throw new InputAttachmentError('roster_attachment_redirect_invalid', '名单附件重定向无效或次数过多。');
|
||||
}
|
||||
emitAttachmentDiagnostic(diagnostic, 'redirect_followed', {
|
||||
redirect_count: redirects + 1,
|
||||
status_code: response.statusCode
|
||||
});
|
||||
url = validateAgentBusAttachmentUrl(new URL(response.location, url).toString());
|
||||
continue;
|
||||
}
|
||||
url = validateAgentBusAttachmentUrl(new URL(response.location, url).toString());
|
||||
continue;
|
||||
validateAttachmentBytes(response.content, maxBytes, reference.size, reference.sha256);
|
||||
emitAttachmentDiagnostic(diagnostic, 'download_completed', {
|
||||
redirect_count: redirects,
|
||||
byte_size: response.content.byteLength,
|
||||
declared_size_match: reference.size === undefined || reference.size === response.content.byteLength,
|
||||
declared_sha256_verified: Boolean(reference.sha256),
|
||||
duration_ms: diagnosticDurationMs(startedAt)
|
||||
});
|
||||
return {
|
||||
fileName: reference.name,
|
||||
contentType: response.contentType === 'application/octet-stream' ? reference.contentType : response.contentType,
|
||||
content: response.content,
|
||||
declaredSize: reference.size,
|
||||
declaredSha256: reference.sha256,
|
||||
source: 'agentbus'
|
||||
};
|
||||
}
|
||||
validateAttachmentBytes(response.content, maxBytes, reference.size, reference.sha256);
|
||||
return {
|
||||
fileName: reference.name,
|
||||
contentType: response.contentType === 'application/octet-stream' ? reference.contentType : response.contentType,
|
||||
content: response.content,
|
||||
declaredSize: reference.size,
|
||||
declaredSha256: reference.sha256,
|
||||
source: 'agentbus'
|
||||
};
|
||||
throw new InputAttachmentError('roster_attachment_download_failed', '名单附件下载失败。');
|
||||
} catch (error) {
|
||||
emitAttachmentDiagnostic(diagnostic, 'download_failed', {
|
||||
redirect_count: redirectCount,
|
||||
duration_ms: diagnosticDurationMs(startedAt),
|
||||
error_code: error instanceof InputAttachmentError
|
||||
? error.code
|
||||
: 'roster_attachment_download_exception'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
throw new InputAttachmentError('roster_attachment_download_failed', '名单附件下载失败。');
|
||||
}
|
||||
Reference in new issue
Block a user