mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-08 02:16:41 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const HELP_TEXT = `
|
||||
ShadowBroker DM root health Prometheus exporter
|
||||
|
||||
Usage:
|
||||
node scripts/mesh/export-dm-root-health-prometheus.mjs [--stdout] [--output PATH] [--base-url URL] [--health-path PATH]
|
||||
|
||||
Environment:
|
||||
SB_DM_ROOT_BASE_URL=http://127.0.0.1:8000
|
||||
SB_DM_ROOT_HEALTH_PATH=/api/wormhole/dm/root-health
|
||||
SB_DM_ROOT_AUTH_HEADER=X-Admin-Key: change-me
|
||||
SB_DM_ROOT_AUTH_COOKIE=operator_session=...
|
||||
SB_DM_ROOT_TIMEOUT_MS=10000
|
||||
SB_DM_ROOT_PROMETHEUS_OUTPUT=/var/lib/node_exporter/textfile_collector/shadowbroker_dm_root.prom
|
||||
|
||||
Flags:
|
||||
--stdout Print Prometheus metrics to stdout
|
||||
--output PATH Override SB_DM_ROOT_PROMETHEUS_OUTPUT
|
||||
--base-url URL Override SB_DM_ROOT_BASE_URL
|
||||
--health-path PATH Override SB_DM_ROOT_HEALTH_PATH
|
||||
--help Show this text
|
||||
|
||||
Exit codes:
|
||||
0 = export succeeded
|
||||
2 = fetch or payload validation failed
|
||||
`.trim();
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim();
|
||||
if (!current) continue;
|
||||
if (current === '--stdout') {
|
||||
parsed.stdout = true;
|
||||
continue;
|
||||
}
|
||||
if (current === '--help' || current === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(current === '--output' || current === '--base-url' || current === '--health-path') &&
|
||||
index + 1 < argv.length
|
||||
) {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] =
|
||||
String(argv[index + 1] || '').trim();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl, healthPath) {
|
||||
const base = String(baseUrl || 'http://127.0.0.1:8000').trim().replace(/\/+$/, '');
|
||||
const pathValue = String(healthPath || '/api/wormhole/dm/root-health').trim();
|
||||
if (!pathValue) {
|
||||
return `${base}/api/wormhole/dm/root-health`;
|
||||
}
|
||||
return pathValue.startsWith('http://') || pathValue.startsWith('https://')
|
||||
? pathValue
|
||||
: `${base}/${pathValue.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parseHeader(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return null;
|
||||
const separator = raw.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const name = raw.slice(0, separator).trim();
|
||||
const value = raw.slice(separator + 1).trim();
|
||||
if (!name || !value) return null;
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
function boolGauge(value) {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
function metricEscape(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function labelsText(labels) {
|
||||
const entries = Object.entries(labels || {}).filter(([, value]) => String(value ?? '').length > 0);
|
||||
if (!entries.length) return '';
|
||||
return `{${entries.map(([key, value]) => `${key}="${metricEscape(value)}"`).join(',')}}`;
|
||||
}
|
||||
|
||||
function appendMetric(lines, name, help, type, value, labels = undefined) {
|
||||
lines.push(`# HELP ${name} ${help}`);
|
||||
lines.push(`# TYPE ${name} ${type}`);
|
||||
lines.push(`${name}${labelsText(labels)} ${value}`);
|
||||
}
|
||||
|
||||
function stateCode(value, mapping, fallback) {
|
||||
const key = String(value || '').trim().toLowerCase();
|
||||
if (Object.prototype.hasOwnProperty.call(mapping, key)) {
|
||||
return mapping[key];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildMetrics(payload, errorDetail = '') {
|
||||
const checkedAt = safeInt(payload?.checked_at || Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000));
|
||||
const summaryState = String(payload?.state || '').trim().toLowerCase();
|
||||
const healthState = String(payload?.health_state || '').trim().toLowerCase();
|
||||
const monitorState = String(payload?.monitoring?.state || '').trim().toLowerCase();
|
||||
const witnessState = String(payload?.witness?.state || '').trim().toLowerCase();
|
||||
const transparencyState = String(payload?.transparency?.state || '').trim().toLowerCase();
|
||||
const nextAction = String(payload?.next_action || '').trim();
|
||||
const alerts = Array.isArray(payload?.alerts) ? payload.alerts.filter((item) => item && typeof item === 'object') : [];
|
||||
|
||||
const summaryStateCode = stateCode(
|
||||
summaryState,
|
||||
{ local_cached_only: 0, current_external: 1, stale_external: 2 },
|
||||
-1,
|
||||
);
|
||||
const healthStateCode = stateCode(
|
||||
healthState,
|
||||
{ ok: 0, warning: 1, stale: 2, error: 3 },
|
||||
-1,
|
||||
);
|
||||
const monitorStateCode = stateCode(
|
||||
monitorState,
|
||||
{ ok: 0, warning: 1, critical: 2 },
|
||||
-1,
|
||||
);
|
||||
const witnessStateCode = stateCode(
|
||||
witnessState,
|
||||
{ not_configured: 0, descriptors_only: 1, current: 2, stale: 3, error: 4 },
|
||||
-1,
|
||||
);
|
||||
const transparencyStateCode = stateCode(
|
||||
transparencyState,
|
||||
{ not_configured: 0, current: 1, stale: 2, error: 3 },
|
||||
-1,
|
||||
);
|
||||
|
||||
const lines = [];
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_health_scrape_success',
|
||||
'Whether the DM root health scrape succeeded.',
|
||||
'gauge',
|
||||
errorDetail ? 0 : 1,
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_checked_at_unixtime',
|
||||
'Unix timestamp for the most recent DM root health check represented in this export.',
|
||||
'gauge',
|
||||
checkedAt,
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_summary_state_code',
|
||||
'DM root operator summary state (0=local_cached_only, 1=current_external, 2=stale_external, -1=unknown).',
|
||||
'gauge',
|
||||
summaryStateCode,
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_health_state_code',
|
||||
'DM root rolled-up health state (0=ok, 1=warning, 2=stale, 3=error, -1=unknown).',
|
||||
'gauge',
|
||||
healthStateCode,
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_monitor_state_code',
|
||||
'Monitoring severity for DM root health (0=ok, 1=warning, 2=critical, -1=unknown).',
|
||||
'gauge',
|
||||
monitorStateCode,
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_strong_trust_blocked',
|
||||
'Whether strong DM trust is currently blocked by external assurance state.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.strong_trust_blocked)),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_external_assurance_current',
|
||||
'Whether configured external witness and transparency assurances are both current.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.external_assurance_current)),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_requires_attention',
|
||||
'Whether DM root external assurance currently requires operator attention.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.requires_attention)),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_independent_quorum_met',
|
||||
'Whether the current witness state satisfies independent quorum.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.independent_quorum_met)),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_alert_count',
|
||||
'Number of active DM root health alerts.',
|
||||
'gauge',
|
||||
safeInt(payload?.alert_count, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_blocking_alert_count',
|
||||
'Number of active blocking DM root health alerts.',
|
||||
'gauge',
|
||||
safeInt(payload?.blocking_alert_count, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_warning_alert_count',
|
||||
'Number of active warning-level DM root health alerts.',
|
||||
'gauge',
|
||||
safeInt(payload?.warning_alert_count, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_state_code',
|
||||
'Witness operator state (0=not_configured, 1=descriptors_only, 2=current, 3=stale, 4=error, -1=unknown).',
|
||||
'gauge',
|
||||
witnessStateCode,
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_health_state_code',
|
||||
'Witness health state (0=ok, 1=warning, 2=stale, 3=error, -1=unknown).',
|
||||
'gauge',
|
||||
stateCode(String(payload?.witness?.health_state || '').trim().toLowerCase(), { ok: 0, warning: 1, stale: 2, error: 3 }, -1),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_age_seconds',
|
||||
'Age in seconds of the current external witness package.',
|
||||
'gauge',
|
||||
safeInt(payload?.witness?.age_s, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_warning_window_seconds',
|
||||
'Configured warning threshold for external witness freshness.',
|
||||
'gauge',
|
||||
safeInt(payload?.witness?.warning_window_s, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_freshness_window_seconds',
|
||||
'Configured maximum freshness window for external witness material.',
|
||||
'gauge',
|
||||
safeInt(payload?.witness?.freshness_window_s, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_reacquire_required',
|
||||
'Whether external witness receipt reacquisition is currently required.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.witness?.reacquire_required)),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_manifest_matches_current',
|
||||
'Whether the external witness material matches the current manifest fingerprint.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.witness?.manifest_matches_current)),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_witness_independent_quorum_met',
|
||||
'Whether the witness side independently satisfies quorum.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.witness?.independent_quorum_met)),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_transparency_state_code',
|
||||
'Transparency operator state (0=not_configured, 1=current, 2=stale, 3=error, -1=unknown).',
|
||||
'gauge',
|
||||
transparencyStateCode,
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_transparency_health_state_code',
|
||||
'Transparency health state (0=ok, 1=warning, 2=stale, 3=error, -1=unknown).',
|
||||
'gauge',
|
||||
stateCode(String(payload?.transparency?.health_state || '').trim().toLowerCase(), { ok: 0, warning: 1, stale: 2, error: 3 }, -1),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_transparency_age_seconds',
|
||||
'Age in seconds of the current external transparency ledger readback.',
|
||||
'gauge',
|
||||
safeInt(payload?.transparency?.age_s, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_transparency_warning_window_seconds',
|
||||
'Configured warning threshold for external transparency freshness.',
|
||||
'gauge',
|
||||
safeInt(payload?.transparency?.warning_window_s, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_transparency_freshness_window_seconds',
|
||||
'Configured maximum freshness window for external transparency readback.',
|
||||
'gauge',
|
||||
safeInt(payload?.transparency?.freshness_window_s, 0),
|
||||
);
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_transparency_verification_required',
|
||||
'Whether transparency verification refresh is currently required.',
|
||||
'gauge',
|
||||
boolGauge(Boolean(payload?.transparency?.verification_required)),
|
||||
);
|
||||
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_summary_info',
|
||||
'State labels for the current DM root operator summary.',
|
||||
'gauge',
|
||||
1,
|
||||
{
|
||||
summary_state: summaryState || 'unknown',
|
||||
health_state: healthState || 'unknown',
|
||||
monitor_state: monitorState || 'unknown',
|
||||
witness_state: witnessState || 'unknown',
|
||||
transparency_state: transparencyState || 'unknown',
|
||||
},
|
||||
);
|
||||
if (nextAction) {
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_next_action_info',
|
||||
'Suggested next DM root operator action.',
|
||||
'gauge',
|
||||
1,
|
||||
{ action: nextAction },
|
||||
);
|
||||
}
|
||||
if (errorDetail) {
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_health_scrape_error_info',
|
||||
'Reason for the most recent DM root health scrape failure.',
|
||||
'gauge',
|
||||
1,
|
||||
{ reason: errorDetail },
|
||||
);
|
||||
}
|
||||
for (const alert of alerts) {
|
||||
const code = String(alert?.code || '').trim();
|
||||
if (!code) continue;
|
||||
appendMetric(
|
||||
lines,
|
||||
'shadowbroker_dm_root_alert_active',
|
||||
'Active DM root health alerts.',
|
||||
'gauge',
|
||||
1,
|
||||
{
|
||||
code,
|
||||
severity: String(alert?.severity || '').trim().toLowerCase() || 'unknown',
|
||||
target: String(alert?.target || '').trim().toLowerCase() || 'dm_root',
|
||||
blocking: boolGauge(Boolean(alert?.blocking)).toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function fetchHealth(config) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
const authHeader = parseHeader(config.authHeader);
|
||||
if (authHeader) {
|
||||
headers[authHeader[0]] = authHeader[1];
|
||||
}
|
||||
if (config.authCookie) {
|
||||
headers.Cookie = config.authCookie;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(config.url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const detail = String(payload?.detail || payload?.message || `http_${response.status}`).trim();
|
||||
throw new Error(detail || 'dm_root_health_failed');
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMetrics(outputPath, text) {
|
||||
const resolved = path.resolve(outputPath);
|
||||
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
||||
const tempPath = `${resolved}.tmp`;
|
||||
await fs.writeFile(tempPath, text, 'utf8');
|
||||
await fs.rename(tempPath, resolved);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: normalizeUrl(args.baseUrl || process.env.SB_DM_ROOT_BASE_URL, args.healthPath || process.env.SB_DM_ROOT_HEALTH_PATH),
|
||||
authHeader: process.env.SB_DM_ROOT_AUTH_HEADER || '',
|
||||
authCookie: process.env.SB_DM_ROOT_AUTH_COOKIE || '',
|
||||
timeoutMs: Math.max(1000, safeInt(process.env.SB_DM_ROOT_TIMEOUT_MS, 10000)),
|
||||
output: String(args.output || process.env.SB_DM_ROOT_PROMETHEUS_OUTPUT || '').trim(),
|
||||
stdout: Boolean(args.stdout),
|
||||
};
|
||||
|
||||
let metricsText = '';
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const payload = await fetchHealth(config);
|
||||
metricsText = buildMetrics(payload);
|
||||
} catch (error) {
|
||||
const detail = String(error?.message || 'dm_root_health_fetch_failed').trim() || 'dm_root_health_fetch_failed';
|
||||
metricsText = buildMetrics(
|
||||
{
|
||||
checked_at: Math.floor(Date.now() / 1000),
|
||||
state: 'stale_external',
|
||||
health_state: 'error',
|
||||
monitoring: { state: 'critical' },
|
||||
strong_trust_blocked: true,
|
||||
requires_attention: true,
|
||||
alert_count: 1,
|
||||
blocking_alert_count: 1,
|
||||
warning_alert_count: 0,
|
||||
alerts: [
|
||||
{
|
||||
code: 'dm_root_health_scrape_failed',
|
||||
severity: 'error',
|
||||
target: 'dm_root',
|
||||
blocking: true,
|
||||
},
|
||||
],
|
||||
witness: {},
|
||||
transparency: {},
|
||||
},
|
||||
detail,
|
||||
);
|
||||
exitCode = 2;
|
||||
}
|
||||
|
||||
if (config.output) {
|
||||
await writeMetrics(config.output, metricsText);
|
||||
}
|
||||
if (config.stdout || !config.output) {
|
||||
process.stdout.write(metricsText);
|
||||
}
|
||||
if (exitCode) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const HELP_TEXT = `
|
||||
ShadowBroker DM root health poller
|
||||
|
||||
Usage:
|
||||
node scripts/mesh/poll-dm-root-health-alerts.mjs [--once] [--base-url URL] [--alerts-path PATH]
|
||||
|
||||
Environment:
|
||||
SB_DM_ROOT_BASE_URL=http://127.0.0.1:8000
|
||||
SB_DM_ROOT_ALERTS_PATH=/api/wormhole/dm/root-health/alerts
|
||||
SB_DM_ROOT_AUTH_HEADER=X-Admin-Key: change-me
|
||||
SB_DM_ROOT_AUTH_COOKIE=operator_session=...
|
||||
SB_DM_ROOT_INTERVAL_S=60
|
||||
SB_DM_ROOT_TIMEOUT_MS=10000
|
||||
SB_DM_ROOT_STATE_FILE=data/dm_root_health_bridge_state.json
|
||||
SB_DM_ROOT_WARNING_WEBHOOK_URL=https://hooks.slack.example/services/...
|
||||
SB_DM_ROOT_CRITICAL_WEBHOOK_URL=https://events.pagerduty.example/v2/enqueue
|
||||
|
||||
Flags:
|
||||
--once Poll one time and exit with status 0/1/2
|
||||
--base-url URL Override SB_DM_ROOT_BASE_URL
|
||||
--alerts-path PATH Override SB_DM_ROOT_ALERTS_PATH
|
||||
--help Show this text
|
||||
|
||||
Exit codes for --once:
|
||||
0 = ok
|
||||
1 = warning
|
||||
2 = critical or fetch failure
|
||||
`.trim();
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim();
|
||||
if (!current) continue;
|
||||
if (current === '--once') {
|
||||
parsed.once = true;
|
||||
continue;
|
||||
}
|
||||
if (current === '--help' || current === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if ((current === '--base-url' || current === '--alerts-path') && index + 1 < argv.length) {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] =
|
||||
String(argv[index + 1] || '').trim();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl, alertsPath) {
|
||||
const base = String(baseUrl || 'http://127.0.0.1:8000').trim().replace(/\/+$/, '');
|
||||
const pathValue = String(alertsPath || '/api/wormhole/dm/root-health/alerts').trim();
|
||||
if (!pathValue) {
|
||||
return `${base}/api/wormhole/dm/root-health/alerts`;
|
||||
}
|
||||
return pathValue.startsWith('http://') || pathValue.startsWith('https://')
|
||||
? pathValue
|
||||
: `${base}/${pathValue.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parseHeader(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return null;
|
||||
const separator = raw.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const name = raw.slice(0, separator).trim();
|
||||
const value = raw.slice(separator + 1).trim();
|
||||
if (!name || !value) return null;
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
function severityFromPayload(payload) {
|
||||
const state = String(payload?.state || '').trim().toLowerCase();
|
||||
if (state === 'critical') return 'critical';
|
||||
if (state === 'warning') return 'warning';
|
||||
if (state === 'ok') return 'ok';
|
||||
if (payload?.page_required) return 'critical';
|
||||
if (payload?.ticket_required) return 'warning';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
function buildFingerprint(payload) {
|
||||
return JSON.stringify({
|
||||
severity: severityFromPayload(payload),
|
||||
state: String(payload?.state || '').trim().toLowerCase(),
|
||||
page_required: Boolean(payload?.page_required),
|
||||
ticket_required: Boolean(payload?.ticket_required),
|
||||
active_alert_codes: Array.isArray(payload?.active_alert_codes) ? payload.active_alert_codes : [],
|
||||
next_action: String(payload?.next_action || '').trim(),
|
||||
alert_count: Number(payload?.alert_count || 0),
|
||||
blocking_alert_count: Number(payload?.blocking_alert_count || 0),
|
||||
warning_alert_count: Number(payload?.warning_alert_count || 0),
|
||||
});
|
||||
}
|
||||
|
||||
function buildSummary(payload) {
|
||||
const severity = severityFromPayload(payload);
|
||||
const activeAlertCodes = Array.isArray(payload?.active_alert_codes)
|
||||
? payload.active_alert_codes.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
severity,
|
||||
state: String(payload?.state || '').trim().toLowerCase() || 'critical',
|
||||
checkedAt: Number(payload?.checked_at || 0),
|
||||
pageRequired: Boolean(payload?.page_required),
|
||||
ticketRequired: Boolean(payload?.ticket_required),
|
||||
recommendedCheckIntervalS: Number(payload?.recommended_check_interval_s || 60),
|
||||
nextAction: String(payload?.next_action || '').trim(),
|
||||
primaryAlert: String(payload?.primary_alert || '').trim(),
|
||||
activeAlertCodes,
|
||||
alertCount: Number(payload?.alert_count || 0),
|
||||
blockingAlertCount: Number(payload?.blocking_alert_count || 0),
|
||||
warningAlertCount: Number(payload?.warning_alert_count || 0),
|
||||
fingerprint: buildFingerprint(payload),
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
function failureSummary(detail) {
|
||||
const message = String(detail || '').trim() || 'dm_root_health_poll_failed';
|
||||
return {
|
||||
severity: 'critical',
|
||||
state: 'critical',
|
||||
checkedAt: Math.floor(Date.now() / 1000),
|
||||
pageRequired: true,
|
||||
ticketRequired: true,
|
||||
recommendedCheckIntervalS: 60,
|
||||
nextAction: 'check_root_health_endpoint',
|
||||
primaryAlert: message,
|
||||
activeAlertCodes: ['dm_root_health_poll_failed'],
|
||||
alertCount: 1,
|
||||
blockingAlertCount: 1,
|
||||
warningAlertCount: 0,
|
||||
fingerprint: JSON.stringify({ severity: 'critical', error: message }),
|
||||
raw: {
|
||||
ok: false,
|
||||
state: 'critical',
|
||||
primary_alert: message,
|
||||
active_alert_codes: ['dm_root_health_poll_failed'],
|
||||
next_action: 'check_root_health_endpoint',
|
||||
page_required: true,
|
||||
ticket_required: true,
|
||||
recommended_check_interval_s: 60,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadStateFile(stateFile) {
|
||||
if (!stateFile) return {};
|
||||
try {
|
||||
const raw = await fs.readFile(stateFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStateFile(stateFile, value) {
|
||||
if (!stateFile) return;
|
||||
const targetPath = path.resolve(stateFile);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
async function fetchAlerts(config) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
const authHeader = parseHeader(config.authHeader);
|
||||
if (authHeader) {
|
||||
headers[authHeader[0]] = authHeader[1];
|
||||
}
|
||||
if (config.authCookie) {
|
||||
headers.Cookie = config.authCookie;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(config.url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const detail = String(payload?.detail || payload?.message || `http_${response.status}`).trim();
|
||||
throw new Error(detail || 'dm_root_health_alerts_failed');
|
||||
}
|
||||
return buildSummary(payload);
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function postWebhook(targetUrl, summary, config) {
|
||||
if (!targetUrl) {
|
||||
return { delivered: false, reason: 'no_target' };
|
||||
}
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
const payload = {
|
||||
source: 'shadowbroker_dm_root_health_bridge',
|
||||
sent_at: new Date().toISOString(),
|
||||
severity: summary.severity,
|
||||
monitoring_state: summary.state,
|
||||
page_required: summary.pageRequired,
|
||||
ticket_required: summary.ticketRequired,
|
||||
primary_alert: summary.primaryAlert,
|
||||
next_action: summary.nextAction,
|
||||
active_alert_codes: summary.activeAlertCodes,
|
||||
alert_count: summary.alertCount,
|
||||
blocking_alert_count: summary.blockingAlertCount,
|
||||
warning_alert_count: summary.warningAlertCount,
|
||||
checked_at: summary.checkedAt,
|
||||
raw: summary.raw,
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`webhook_http_${response.status}`);
|
||||
}
|
||||
return { delivered: true, reason: 'sent' };
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeDeliverWebhook(summary, config) {
|
||||
if (summary.severity === 'ok') {
|
||||
return { delivered: false, reason: 'ok_state' };
|
||||
}
|
||||
const state = await loadStateFile(config.stateFile);
|
||||
if (
|
||||
String(state.last_fingerprint || '') === summary.fingerprint &&
|
||||
String(state.last_severity || '') === summary.severity
|
||||
) {
|
||||
return { delivered: false, reason: 'duplicate' };
|
||||
}
|
||||
const targetUrl =
|
||||
summary.severity === 'critical' ? config.criticalWebhookUrl : config.warningWebhookUrl;
|
||||
const delivered = await postWebhook(targetUrl, summary, config);
|
||||
if (delivered.delivered) {
|
||||
await writeStateFile(config.stateFile, {
|
||||
last_checked_at: summary.checkedAt,
|
||||
last_severity: summary.severity,
|
||||
last_fingerprint: summary.fingerprint,
|
||||
last_target: targetUrl,
|
||||
});
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
function printSummary(summary, webhookResult) {
|
||||
const prefix = summary.severity.toUpperCase().padEnd(8, ' ');
|
||||
const alerts = summary.activeAlertCodes.length > 0 ? summary.activeAlertCodes.join(',') : 'none';
|
||||
const nextAction = summary.nextAction || 'none';
|
||||
const webhookNote = webhookResult?.reason ? ` webhook=${webhookResult.reason}` : '';
|
||||
console.log(
|
||||
`[${prefix}] state=${summary.state} page=${summary.pageRequired} ticket=${summary.ticketRequired} ` +
|
||||
`alerts=${alerts} next_action=${nextAction}${webhookNote}`,
|
||||
);
|
||||
}
|
||||
|
||||
function exitCodeForSeverity(severity) {
|
||||
if (severity === 'ok') return 0;
|
||||
if (severity === 'warning') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollOnce(config) {
|
||||
try {
|
||||
const summary = await fetchAlerts(config);
|
||||
const webhookResult = await maybeDeliverWebhook(summary, config);
|
||||
printSummary(summary, webhookResult);
|
||||
return summary;
|
||||
} catch (error) {
|
||||
const summary = failureSummary(error instanceof Error ? error.message : 'dm_root_health_poll_failed');
|
||||
const webhookResult = await maybeDeliverWebhook(summary, config).catch(() => ({
|
||||
delivered: false,
|
||||
reason: 'webhook_failed',
|
||||
}));
|
||||
printSummary(summary, webhookResult);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
once: Boolean(args.once),
|
||||
url: normalizeUrl(args.baseUrl || process.env.SB_DM_ROOT_BASE_URL, args.alertsPath || process.env.SB_DM_ROOT_ALERTS_PATH),
|
||||
authHeader: process.env.SB_DM_ROOT_AUTH_HEADER || '',
|
||||
authCookie: process.env.SB_DM_ROOT_AUTH_COOKIE || '',
|
||||
intervalMs: Math.max(5, Number(process.env.SB_DM_ROOT_INTERVAL_S || 60)) * 1000,
|
||||
timeoutMs: Math.max(1000, Number(process.env.SB_DM_ROOT_TIMEOUT_MS || 10000)),
|
||||
stateFile: process.env.SB_DM_ROOT_STATE_FILE || '',
|
||||
warningWebhookUrl: process.env.SB_DM_ROOT_WARNING_WEBHOOK_URL || '',
|
||||
criticalWebhookUrl: process.env.SB_DM_ROOT_CRITICAL_WEBHOOK_URL || '',
|
||||
};
|
||||
|
||||
if (config.once) {
|
||||
const summary = await pollOnce(config);
|
||||
process.exitCode = exitCodeForSeverity(summary.severity);
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const summary = await pollOnce(config);
|
||||
const nextDelayMs = Math.max(
|
||||
5000,
|
||||
Number(summary.recommendedCheckIntervalS || config.intervalMs / 1000) * 1000,
|
||||
);
|
||||
await sleep(nextDelayMs || config.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const PROTOCOL_VERSION = 'infonet/2';
|
||||
const NETWORK_ID = 'sb-testnet-0';
|
||||
const NODE_ID_PREFIX = '!sb_';
|
||||
const NODE_ID_HEX_LEN = 32;
|
||||
const MANIFEST_WITNESS_EVENT_TYPE = 'stable_dm_root_manifest_witness';
|
||||
const MANIFEST_WITNESS_TYPE = 'stable_dm_root_manifest_witness';
|
||||
const EXTERNAL_WITNESS_IMPORT_TYPE = 'stable_dm_root_manifest_external_witness_import';
|
||||
const EXTERNAL_WITNESS_IDENTITY_TYPE = 'stable_dm_root_manifest_external_witness_identity';
|
||||
const ROOT_DISTRIBUTION_PATH = '/api/wormhole/dm/root-distribution';
|
||||
|
||||
const HELP_TEXT = `
|
||||
ShadowBroker external root witness package publisher
|
||||
|
||||
Usage:
|
||||
node scripts/mesh/publish-external-root-witness-package.mjs --init-witness PATH
|
||||
node scripts/mesh/publish-external-root-witness-package.mjs --descriptor-only --witness-file PATH [--output PATH]
|
||||
node scripts/mesh/publish-external-root-witness-package.mjs --witness-file PATH [--output PATH]
|
||||
|
||||
Environment:
|
||||
SB_DM_ROOT_BASE_URL=http://127.0.0.1:8000
|
||||
SB_DM_ROOT_DISTRIBUTION_PATH=/api/wormhole/dm/root-distribution
|
||||
SB_DM_ROOT_AUTH_HEADER=X-Admin-Key: change-me
|
||||
SB_DM_ROOT_AUTH_COOKIE=operator_session=...
|
||||
SB_DM_ROOT_TIMEOUT_MS=10000
|
||||
SB_DM_ROOT_WITNESS_IDENTITY_FILE=./ops/witness-a.identity.json
|
||||
SB_DM_ROOT_WITNESS_OUTPUT=./ops/root_witness_import.json
|
||||
SB_DM_ROOT_WITNESS_LABEL=witness-a
|
||||
SB_DM_ROOT_WITNESS_SOURCE_SCOPE=https_publish
|
||||
SB_DM_ROOT_WITNESS_SOURCE_LABEL=witness-a
|
||||
SB_DM_ROOT_WITNESS_INDEPENDENCE_GROUP=independent_witness_a
|
||||
|
||||
Flags:
|
||||
--init-witness PATH Generate a new external witness identity file and exit
|
||||
--witness-file PATH Load the external witness identity file to publish from
|
||||
--descriptor-only Emit descriptors only, without manifest_fingerprint or signed receipts
|
||||
--stdout Print the generated package to stdout
|
||||
--output PATH Write the generated package JSON to PATH
|
||||
--base-url URL Override SB_DM_ROOT_BASE_URL
|
||||
--distribution-path PATH Override SB_DM_ROOT_DISTRIBUTION_PATH
|
||||
--label VALUE Override witness descriptor label
|
||||
--source-scope VALUE Override source_scope in the published import package
|
||||
--source-label VALUE Override source_label in the published import package
|
||||
--independence-group VAL Override witness independence group
|
||||
--help Show this text
|
||||
|
||||
Typical flow:
|
||||
1. Run --init-witness once on the external witness host.
|
||||
2. Publish a descriptor-only package so the backend can import the external descriptor and republish the manifest.
|
||||
3. Publish a full signed receipt package after the current manifest policy includes this external witness.
|
||||
`.trim();
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim();
|
||||
if (!current) continue;
|
||||
if (current === '--descriptor-only' || current === '--stdout') {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] = true;
|
||||
continue;
|
||||
}
|
||||
if (current === '--help' || current === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(
|
||||
current === '--init-witness' ||
|
||||
current === '--witness-file' ||
|
||||
current === '--output' ||
|
||||
current === '--base-url' ||
|
||||
current === '--distribution-path' ||
|
||||
current === '--label' ||
|
||||
current === '--source-scope' ||
|
||||
current === '--source-label' ||
|
||||
current === '--independence-group'
|
||||
) &&
|
||||
index + 1 < argv.length
|
||||
) {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] =
|
||||
String(argv[index + 1] || '').trim();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nowSeconds() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableJson(item)).join(',')}]`;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
return `{${entries
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl, routePath) {
|
||||
const base = String(baseUrl || 'http://127.0.0.1:8000').trim().replace(/\/+$/, '');
|
||||
const pathValue = String(routePath || ROOT_DISTRIBUTION_PATH).trim();
|
||||
if (!pathValue) {
|
||||
return `${base}${ROOT_DISTRIBUTION_PATH}`;
|
||||
}
|
||||
return pathValue.startsWith('http://') || pathValue.startsWith('https://')
|
||||
? pathValue
|
||||
: `${base}/${pathValue.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parseHeader(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return null;
|
||||
const separator = raw.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const name = raw.slice(0, separator).trim();
|
||||
const value = raw.slice(separator + 1).trim();
|
||||
if (!name || !value) return null;
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
function toBase64Url(input) {
|
||||
return Buffer.from(input)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64(value) {
|
||||
return Buffer.from(String(value || '').trim(), 'base64');
|
||||
}
|
||||
|
||||
function deriveNodeId(publicKeyBase64) {
|
||||
const digest = crypto.createHash('sha256').update(fromBase64(publicKeyBase64)).digest('hex');
|
||||
return `${NODE_ID_PREFIX}${digest.slice(0, NODE_ID_HEX_LEN)}`;
|
||||
}
|
||||
|
||||
function buildWitnessDescriptor(identity, overrides = {}) {
|
||||
return {
|
||||
scope: 'root_witness',
|
||||
label: String(overrides.label || identity.label || '').trim(),
|
||||
node_id: String(identity.node_id || '').trim(),
|
||||
public_key: String(identity.public_key || '').trim(),
|
||||
public_key_algo: 'Ed25519',
|
||||
management_scope: 'external',
|
||||
independence_group: String(
|
||||
overrides.independenceGroup || identity.independence_group || 'external_witness'
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function witnessPolicyFingerprint(policy) {
|
||||
const normalizedWitnesses = Array.isArray(policy?.witnesses)
|
||||
? policy.witnesses.map((item) => ({
|
||||
scope: String(item?.scope || 'root_witness'),
|
||||
label: String(item?.label || ''),
|
||||
node_id: String(item?.node_id || '').trim(),
|
||||
public_key: String(item?.public_key || '').trim(),
|
||||
public_key_algo: String(item?.public_key_algo || 'Ed25519'),
|
||||
management_scope: String(item?.management_scope || 'local').trim().toLowerCase(),
|
||||
independence_group: String(item?.independence_group || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
}))
|
||||
: [];
|
||||
const canonical = {
|
||||
type: String(policy?.type || 'stable_dm_root_manifest_witness_policy'),
|
||||
policy_version: safeInt(policy?.policy_version, 1),
|
||||
threshold: safeInt(policy?.threshold, 0),
|
||||
witnesses: normalizedWitnesses,
|
||||
};
|
||||
return crypto.createHash('sha256').update(stableJson(canonical), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function manifestFingerprintForEnvelope(manifest) {
|
||||
const canonical = {
|
||||
type: String(manifest?.type || 'stable_dm_root_manifest'),
|
||||
event_type: String(manifest?.event_type || 'stable_dm_root_manifest'),
|
||||
node_id: String(manifest?.node_id || '').trim(),
|
||||
public_key: String(manifest?.public_key || '').trim(),
|
||||
public_key_algo: String(manifest?.public_key_algo || 'Ed25519'),
|
||||
protocol_version: String(manifest?.protocol_version || PROTOCOL_VERSION),
|
||||
sequence: safeInt(manifest?.sequence, 0),
|
||||
payload: manifest?.payload && typeof manifest.payload === 'object' ? { ...manifest.payload } : {},
|
||||
signature: String(manifest?.signature || '').trim(),
|
||||
};
|
||||
return crypto.createHash('sha256').update(stableJson(canonical), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function buildSignaturePayload({ eventType, nodeId, sequence, payload }) {
|
||||
return [
|
||||
PROTOCOL_VERSION,
|
||||
NETWORK_ID,
|
||||
eventType,
|
||||
String(nodeId || '').trim(),
|
||||
String(safeInt(sequence, 0)),
|
||||
stableJson(payload && typeof payload === 'object' ? payload : {}),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function buildWitnessPayload(manifest) {
|
||||
const payload = manifest?.payload && typeof manifest.payload === 'object' ? { ...manifest.payload } : {};
|
||||
const witnessPolicy = payload?.witness_policy && typeof payload.witness_policy === 'object' ? payload.witness_policy : {};
|
||||
return {
|
||||
manifest_type: String(manifest?.type || 'stable_dm_root_manifest'),
|
||||
manifest_event_type: String(manifest?.event_type || 'stable_dm_root_manifest'),
|
||||
manifest_fingerprint: manifestFingerprintForEnvelope(manifest),
|
||||
root_fingerprint: String(payload?.root_fingerprint || '').trim().toLowerCase(),
|
||||
root_node_id: String(payload?.root_node_id || '').trim(),
|
||||
generation: safeInt(payload?.generation, 0),
|
||||
issued_at: safeInt(payload?.issued_at, 0),
|
||||
expires_at: safeInt(payload?.expires_at, 0),
|
||||
policy_version: safeInt(payload?.policy_version, 1),
|
||||
witness_policy_fingerprint: witnessPolicyFingerprint(witnessPolicy),
|
||||
witness_threshold: safeInt(witnessPolicy?.threshold, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function createPrivateKeyFromIdentity(identity) {
|
||||
const privateKeyRaw = fromBase64(identity.private_key);
|
||||
const publicKeyRaw = fromBase64(identity.public_key);
|
||||
if (privateKeyRaw.length !== 32 || publicKeyRaw.length !== 32) {
|
||||
throw new Error('external witness identity keys must be raw Ed25519 base64');
|
||||
}
|
||||
return crypto.createPrivateKey({
|
||||
key: {
|
||||
crv: 'Ed25519',
|
||||
d: toBase64Url(privateKeyRaw),
|
||||
kty: 'OKP',
|
||||
x: toBase64Url(publicKeyRaw),
|
||||
},
|
||||
format: 'jwk',
|
||||
});
|
||||
}
|
||||
|
||||
function generateWitnessIdentity(overrides = {}) {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
||||
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
const publicKeyRaw = Buffer.from(String(publicJwk.x || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
const privateKeyRaw = Buffer.from(String(privateJwk.d || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
const publicKeyBase64 = publicKeyRaw.toString('base64');
|
||||
return {
|
||||
type: EXTERNAL_WITNESS_IDENTITY_TYPE,
|
||||
schema_version: 1,
|
||||
created_at: nowSeconds(),
|
||||
updated_at: nowSeconds(),
|
||||
node_id: deriveNodeId(publicKeyBase64),
|
||||
public_key: publicKeyBase64,
|
||||
public_key_algo: 'Ed25519',
|
||||
private_key: privateKeyRaw.toString('base64'),
|
||||
label: String(overrides.label || 'external-witness').trim(),
|
||||
management_scope: 'external',
|
||||
independence_group: String(overrides.independenceGroup || 'external_witness').trim().toLowerCase(),
|
||||
sequence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
const raw = await fs.readFile(path.resolve(filePath), 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath, value) {
|
||||
const target = path.resolve(filePath);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function loadWitnessIdentity(filePath) {
|
||||
const parsed = await readJsonFile(filePath);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('external witness identity file root must be an object');
|
||||
}
|
||||
const identity = { ...parsed };
|
||||
if (String(identity.type || EXTERNAL_WITNESS_IDENTITY_TYPE) !== EXTERNAL_WITNESS_IDENTITY_TYPE) {
|
||||
throw new Error('external witness identity type invalid');
|
||||
}
|
||||
if (safeInt(identity.schema_version, 0) <= 0) {
|
||||
throw new Error('external witness identity schema_version required');
|
||||
}
|
||||
if (String(identity.public_key_algo || 'Ed25519') !== 'Ed25519') {
|
||||
throw new Error('external witness identity public_key_algo must be Ed25519');
|
||||
}
|
||||
if (!String(identity.public_key || '').trim() || !String(identity.private_key || '').trim()) {
|
||||
throw new Error('external witness identity keys required');
|
||||
}
|
||||
const derivedNodeId = deriveNodeId(identity.public_key);
|
||||
if (!String(identity.node_id || '').trim()) {
|
||||
identity.node_id = derivedNodeId;
|
||||
}
|
||||
if (String(identity.node_id || '').trim() !== derivedNodeId) {
|
||||
throw new Error('external witness identity node_id does not match public_key');
|
||||
}
|
||||
identity.sequence = Math.max(0, safeInt(identity.sequence, 0));
|
||||
identity.label = String(identity.label || '').trim();
|
||||
identity.independence_group = String(identity.independence_group || 'external_witness')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function fetchDistribution(config) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
const authHeader = parseHeader(config.authHeader);
|
||||
if (authHeader) {
|
||||
headers[authHeader[0]] = authHeader[1];
|
||||
}
|
||||
if (config.authCookie) {
|
||||
headers.Cookie = config.authCookie;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(config.url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const detail = String(payload?.detail || payload?.message || `http_${response.status}`).trim();
|
||||
throw new Error(detail || 'root_distribution_fetch_failed');
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildImportPackage({ identity, descriptor, sourceScope, sourceLabel, descriptorOnly, distribution }) {
|
||||
const packageBase = {
|
||||
type: EXTERNAL_WITNESS_IMPORT_TYPE,
|
||||
schema_version: 1,
|
||||
source_scope: String(sourceScope || 'external_publish').trim().toLowerCase(),
|
||||
source_label: String(sourceLabel || descriptor.label || identity.label || '').trim(),
|
||||
exported_at: nowSeconds(),
|
||||
descriptors: [descriptor],
|
||||
};
|
||||
if (descriptorOnly) {
|
||||
return packageBase;
|
||||
}
|
||||
|
||||
const manifest = distribution?.manifest && typeof distribution.manifest === 'object' ? distribution.manifest : null;
|
||||
if (!manifest) {
|
||||
throw new Error('current root-distribution manifest required');
|
||||
}
|
||||
const manifestFingerprint =
|
||||
String(distribution?.manifest_fingerprint || '').trim().toLowerCase() || manifestFingerprintForEnvelope(manifest);
|
||||
const policyWitnesses = Array.isArray(distribution?.witness_policy?.witnesses)
|
||||
? distribution.witness_policy.witnesses
|
||||
: Array.isArray(manifest?.payload?.witness_policy?.witnesses)
|
||||
? manifest.payload.witness_policy.witnesses
|
||||
: [];
|
||||
const declaredWitness = policyWitnesses.find(
|
||||
(item) =>
|
||||
String(item?.node_id || '').trim() === identity.node_id &&
|
||||
String(item?.public_key || '').trim() === identity.public_key,
|
||||
);
|
||||
if (!declaredWitness) {
|
||||
throw new Error(
|
||||
'external witness is not declared in the current manifest policy; import a descriptor-only package and let the backend republish before generating receipts',
|
||||
);
|
||||
}
|
||||
|
||||
const nextSequence = Math.max(1, safeInt(identity.sequence, 0) + 1);
|
||||
const witnessPayload = buildWitnessPayload(manifest);
|
||||
const signaturePayload = buildSignaturePayload({
|
||||
eventType: MANIFEST_WITNESS_EVENT_TYPE,
|
||||
nodeId: identity.node_id,
|
||||
sequence: nextSequence,
|
||||
payload: witnessPayload,
|
||||
});
|
||||
const signature = crypto.sign(null, Buffer.from(signaturePayload, 'utf8'), createPrivateKeyFromIdentity(identity)).toString('hex');
|
||||
|
||||
identity.sequence = nextSequence;
|
||||
identity.updated_at = nowSeconds();
|
||||
|
||||
return {
|
||||
...packageBase,
|
||||
manifest_fingerprint: manifestFingerprint,
|
||||
witnesses: [
|
||||
{
|
||||
type: MANIFEST_WITNESS_TYPE,
|
||||
event_type: MANIFEST_WITNESS_EVENT_TYPE,
|
||||
node_id: identity.node_id,
|
||||
public_key: identity.public_key,
|
||||
public_key_algo: 'Ed25519',
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
sequence: nextSequence,
|
||||
payload: witnessPayload,
|
||||
signature,
|
||||
identity_scope: 'root_witness',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = String(args.label || process.env.SB_DM_ROOT_WITNESS_LABEL || 'external-witness').trim();
|
||||
const independenceGroup = String(
|
||||
args.independenceGroup || process.env.SB_DM_ROOT_WITNESS_INDEPENDENCE_GROUP || 'external_witness',
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (args.initWitness) {
|
||||
const identity = generateWitnessIdentity({ label, independenceGroup });
|
||||
await writeJsonFile(args.initWitness, identity);
|
||||
console.log(`external witness identity written to ${path.resolve(args.initWitness)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const witnessFile = String(args.witnessFile || process.env.SB_DM_ROOT_WITNESS_IDENTITY_FILE || '').trim();
|
||||
if (!witnessFile) {
|
||||
console.error('external witness identity file required; use --witness-file PATH or --init-witness PATH');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const identity = await loadWitnessIdentity(witnessFile);
|
||||
if (label) {
|
||||
identity.label = label;
|
||||
}
|
||||
if (independenceGroup) {
|
||||
identity.independence_group = independenceGroup;
|
||||
}
|
||||
const descriptor = buildWitnessDescriptor(identity, {
|
||||
label,
|
||||
independenceGroup,
|
||||
});
|
||||
|
||||
const descriptorOnly = Boolean(args.descriptorOnly);
|
||||
const sourceScope = String(args.sourceScope || process.env.SB_DM_ROOT_WITNESS_SOURCE_SCOPE || 'external_publish').trim();
|
||||
const sourceLabel = String(args.sourceLabel || process.env.SB_DM_ROOT_WITNESS_SOURCE_LABEL || descriptor.label).trim();
|
||||
|
||||
let distribution = null;
|
||||
if (!descriptorOnly) {
|
||||
distribution = await fetchDistribution({
|
||||
url: normalizeUrl(args.baseUrl || process.env.SB_DM_ROOT_BASE_URL, args.distributionPath || process.env.SB_DM_ROOT_DISTRIBUTION_PATH),
|
||||
authHeader: process.env.SB_DM_ROOT_AUTH_HEADER || '',
|
||||
authCookie: process.env.SB_DM_ROOT_AUTH_COOKIE || '',
|
||||
timeoutMs: Math.max(1000, safeInt(process.env.SB_DM_ROOT_TIMEOUT_MS, 10000)),
|
||||
});
|
||||
}
|
||||
|
||||
const packageDocument = buildImportPackage({
|
||||
identity,
|
||||
descriptor,
|
||||
sourceScope,
|
||||
sourceLabel,
|
||||
descriptorOnly,
|
||||
distribution,
|
||||
});
|
||||
|
||||
await writeJsonFile(witnessFile, identity);
|
||||
|
||||
const outputPath = String(args.output || process.env.SB_DM_ROOT_WITNESS_OUTPUT || '').trim();
|
||||
if (outputPath) {
|
||||
await writeJsonFile(outputPath, packageDocument);
|
||||
}
|
||||
if (args.stdout || !outputPath) {
|
||||
process.stdout.write(`${JSON.stringify(packageDocument, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(String(error?.message || error || 'external witness package publish failed').trim());
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const HELP_TEXT = `
|
||||
ShadowBroker DM root deployment smoke
|
||||
|
||||
Usage:
|
||||
node scripts/mesh/smoke-dm-root-deployment-flow.mjs [--keep] [--workspace PATH] [--base-url URL] [--require-current-external]
|
||||
|
||||
Environment:
|
||||
SB_DM_ROOT_BASE_URL=http://127.0.0.1:8000
|
||||
SB_DM_ROOT_AUTH_HEADER=X-Admin-Key: change-me
|
||||
SB_DM_ROOT_AUTH_COOKIE=operator_session=...
|
||||
SB_DM_ROOT_TIMEOUT_MS=10000
|
||||
SB_DM_ROOT_DEPLOYMENT_SMOKE_WORKSPACE=.smoke/dm-root-deployment
|
||||
|
||||
What it does:
|
||||
1. Runs the external witness bootstrap smoke.
|
||||
2. Runs the transparency publication smoke.
|
||||
3. Fetches /api/wormhole/dm/root-health.
|
||||
4. Prints one rolled-up result for the current deployment state.
|
||||
|
||||
Flags:
|
||||
--keep Keep the smoke workspace instead of deleting it
|
||||
--workspace PATH Override SB_DM_ROOT_DEPLOYMENT_SMOKE_WORKSPACE
|
||||
--base-url URL Override SB_DM_ROOT_BASE_URL
|
||||
--require-current-external Fail unless root-health ends in current_external with strong trust unblocked
|
||||
--help Show this text
|
||||
`.trim();
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim();
|
||||
if (!current) continue;
|
||||
if (current === '--keep' || current === '--require-current-external') {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] = true;
|
||||
continue;
|
||||
}
|
||||
if (current === '--help' || current === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if ((current === '--workspace' || current === '--base-url') && index + 1 < argv.length) {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] =
|
||||
String(argv[index + 1] || '').trim();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl, routePath) {
|
||||
const base = String(baseUrl || 'http://127.0.0.1:8000').trim().replace(/\/+$/, '');
|
||||
return `${base}/${String(routePath || '').replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parseHeader(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return null;
|
||||
const separator = raw.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const name = raw.slice(0, separator).trim();
|
||||
const value = raw.slice(separator + 1).trim();
|
||||
if (!name || !value) return null;
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
async function requestJson({ method, url, authHeader, authCookie, timeoutMs }) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
const parsedAuth = parseHeader(authHeader);
|
||||
if (parsedAuth) {
|
||||
headers[parsedAuth[0]] = parsedAuth[1];
|
||||
}
|
||||
if (authCookie) {
|
||||
headers.Cookie = authCookie;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const detail = String(payload?.detail || payload?.message || `http_${response.status}`).trim();
|
||||
throw new Error(detail || `${method.toLowerCase()}_${url}_failed`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnNodeScript(scriptPath, args, env) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${path.basename(scriptPath)} exited ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assert(condition, detail) {
|
||||
if (!condition) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const workspace = path.resolve(
|
||||
String(args.workspace || process.env.SB_DM_ROOT_DEPLOYMENT_SMOKE_WORKSPACE || '.smoke/dm-root-deployment').trim(),
|
||||
);
|
||||
const baseUrl = String(args.baseUrl || process.env.SB_DM_ROOT_BASE_URL || 'http://127.0.0.1:8000').trim();
|
||||
const timeoutMs = Math.max(1000, safeInt(process.env.SB_DM_ROOT_TIMEOUT_MS, 10000));
|
||||
const authHeader = process.env.SB_DM_ROOT_AUTH_HEADER || '';
|
||||
const authCookie = process.env.SB_DM_ROOT_AUTH_COOKIE || '';
|
||||
|
||||
const witnessSmokeScript = path.resolve(rootDir, 'scripts/mesh/smoke-external-root-witness-flow.mjs');
|
||||
const transparencySmokeScript = path.resolve(rootDir, 'scripts/mesh/smoke-root-transparency-publication-flow.mjs');
|
||||
const witnessWorkspace = path.join(workspace, 'witness');
|
||||
const transparencyWorkspace = path.join(workspace, 'transparency');
|
||||
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
SB_DM_ROOT_BASE_URL: baseUrl,
|
||||
SB_DM_ROOT_AUTH_HEADER: authHeader,
|
||||
SB_DM_ROOT_AUTH_COOKIE: authCookie,
|
||||
SB_DM_ROOT_TIMEOUT_MS: String(timeoutMs),
|
||||
};
|
||||
|
||||
console.log('1/3 run external witness bootstrap smoke');
|
||||
await spawnNodeScript(
|
||||
witnessSmokeScript,
|
||||
['--workspace', witnessWorkspace, '--base-url', baseUrl, ...(args.keep ? ['--keep'] : [])],
|
||||
childEnv,
|
||||
);
|
||||
|
||||
console.log('2/3 run transparency publication smoke');
|
||||
await spawnNodeScript(
|
||||
transparencySmokeScript,
|
||||
['--workspace', transparencyWorkspace, '--base-url', baseUrl, ...(args.keep ? ['--keep'] : [])],
|
||||
childEnv,
|
||||
);
|
||||
|
||||
console.log('3/3 fetch rolled-up DM root health');
|
||||
const health = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-health'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
if (args.requireCurrentExternal) {
|
||||
assert(String(health?.state || '').trim() === 'current_external', 'dm root health did not reach current_external');
|
||||
assert(String(health?.health_state || '').trim() === 'ok', 'dm root health did not reach ok');
|
||||
assert(!Boolean(health?.strong_trust_blocked), 'strong DM trust is still blocked');
|
||||
}
|
||||
|
||||
const summary = {
|
||||
ok: true,
|
||||
workspace,
|
||||
require_current_external: Boolean(args.requireCurrentExternal),
|
||||
state: String(health?.state || '').trim(),
|
||||
health_state: String(health?.health_state || '').trim(),
|
||||
strong_trust_blocked: Boolean(health?.strong_trust_blocked),
|
||||
external_assurance_current: Boolean(health?.external_assurance_current),
|
||||
requires_attention: Boolean(health?.requires_attention),
|
||||
monitoring_state: String(health?.monitoring?.state || '').trim(),
|
||||
monitoring_status_line: String(health?.monitoring?.status_line || '').trim(),
|
||||
next_action: String(health?.next_action || '').trim(),
|
||||
witness_state: String(health?.witness?.state || '').trim(),
|
||||
witness_health_state: String(health?.witness?.health_state || '').trim(),
|
||||
transparency_state: String(health?.transparency?.state || '').trim(),
|
||||
transparency_health_state: String(health?.transparency?.health_state || '').trim(),
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(String(error?.message || error || 'dm root deployment smoke failed').trim());
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const HELP_TEXT = `
|
||||
ShadowBroker external root witness flow smoke
|
||||
|
||||
Usage:
|
||||
node scripts/mesh/smoke-external-root-witness-flow.mjs [--keep] [--workspace PATH] [--base-url URL]
|
||||
|
||||
Environment:
|
||||
SB_DM_ROOT_BASE_URL=http://127.0.0.1:8000
|
||||
SB_DM_ROOT_AUTH_HEADER=X-Admin-Key: change-me
|
||||
SB_DM_ROOT_AUTH_COOKIE=operator_session=...
|
||||
SB_DM_ROOT_TIMEOUT_MS=10000
|
||||
SB_DM_ROOT_SMOKE_WORKSPACE=.smoke/external-root-witness-flow
|
||||
SB_DM_ROOT_WITNESS_LABEL=witness-a
|
||||
SB_DM_ROOT_WITNESS_SOURCE_SCOPE=https_publish
|
||||
SB_DM_ROOT_WITNESS_SOURCE_LABEL=witness-a
|
||||
SB_DM_ROOT_WITNESS_INDEPENDENCE_GROUP=independent_witness_a
|
||||
|
||||
What it does:
|
||||
1. Generate a fresh external witness identity.
|
||||
2. Publish and import a descriptor-only package.
|
||||
3. Trigger root-distribution republish and verify the new witness is in policy.
|
||||
4. Publish and import a full signed witness receipt package.
|
||||
5. Verify external witness receipts are current in root-distribution.
|
||||
|
||||
Flags:
|
||||
--keep Keep the smoke workspace instead of deleting it
|
||||
--workspace PATH Override SB_DM_ROOT_SMOKE_WORKSPACE
|
||||
--base-url URL Override SB_DM_ROOT_BASE_URL
|
||||
--help Show this text
|
||||
`.trim();
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim();
|
||||
if (!current) continue;
|
||||
if (current === '--keep') {
|
||||
parsed.keep = true;
|
||||
continue;
|
||||
}
|
||||
if (current === '--help' || current === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if ((current === '--workspace' || current === '--base-url') && index + 1 < argv.length) {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] =
|
||||
String(argv[index + 1] || '').trim();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl, routePath) {
|
||||
const base = String(baseUrl || 'http://127.0.0.1:8000').trim().replace(/\/+$/, '');
|
||||
const pathValue = String(routePath || '').trim();
|
||||
if (!pathValue) {
|
||||
return base;
|
||||
}
|
||||
return `${base}/${pathValue.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parseHeader(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return null;
|
||||
const separator = raw.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const name = raw.slice(0, separator).trim();
|
||||
const value = raw.slice(separator + 1).trim();
|
||||
if (!name || !value) return null;
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
async function requestJson({ method, url, authHeader, authCookie, timeoutMs, body }) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
const parsedAuth = parseHeader(authHeader);
|
||||
if (parsedAuth) {
|
||||
headers[parsedAuth[0]] = parsedAuth[1];
|
||||
}
|
||||
if (authCookie) {
|
||||
headers.Cookie = authCookie;
|
||||
}
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const detail = String(payload?.detail || payload?.message || `http_${response.status}`).trim();
|
||||
throw new Error(detail || `${method.toLowerCase()}_${url}_failed`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnNodeScript(scriptPath, args, env) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${path.basename(scriptPath)} exited ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assert(condition, detail) {
|
||||
if (!condition) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const workspace = path.resolve(
|
||||
String(args.workspace || process.env.SB_DM_ROOT_SMOKE_WORKSPACE || '.smoke/external-root-witness-flow').trim(),
|
||||
);
|
||||
const baseUrl = String(args.baseUrl || process.env.SB_DM_ROOT_BASE_URL || 'http://127.0.0.1:8000').trim();
|
||||
const timeoutMs = Math.max(1000, safeInt(process.env.SB_DM_ROOT_TIMEOUT_MS, 10000));
|
||||
const authHeader = process.env.SB_DM_ROOT_AUTH_HEADER || '';
|
||||
const authCookie = process.env.SB_DM_ROOT_AUTH_COOKIE || '';
|
||||
const label = String(process.env.SB_DM_ROOT_WITNESS_LABEL || 'witness-a').trim();
|
||||
const sourceScope = String(process.env.SB_DM_ROOT_WITNESS_SOURCE_SCOPE || 'https_publish').trim();
|
||||
const sourceLabel = String(process.env.SB_DM_ROOT_WITNESS_SOURCE_LABEL || label).trim();
|
||||
const independenceGroup = String(
|
||||
process.env.SB_DM_ROOT_WITNESS_INDEPENDENCE_GROUP || 'independent_witness_a',
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const identityPath = path.join(workspace, 'witness.identity.json');
|
||||
const descriptorPath = path.join(workspace, 'root_witness_descriptor.json');
|
||||
const receiptPath = path.join(workspace, 'root_witness_receipt.json');
|
||||
const publisherScript = path.resolve(rootDir, 'scripts/mesh/publish-external-root-witness-package.mjs');
|
||||
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
SB_DM_ROOT_BASE_URL: baseUrl,
|
||||
SB_DM_ROOT_AUTH_HEADER: authHeader,
|
||||
SB_DM_ROOT_AUTH_COOKIE: authCookie,
|
||||
SB_DM_ROOT_TIMEOUT_MS: String(timeoutMs),
|
||||
SB_DM_ROOT_WITNESS_LABEL: label,
|
||||
SB_DM_ROOT_WITNESS_SOURCE_SCOPE: sourceScope,
|
||||
SB_DM_ROOT_WITNESS_SOURCE_LABEL: sourceLabel,
|
||||
SB_DM_ROOT_WITNESS_INDEPENDENCE_GROUP: independenceGroup,
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('1/5 init external witness identity');
|
||||
await spawnNodeScript(publisherScript, ['--init-witness', identityPath], childEnv);
|
||||
|
||||
console.log('2/5 publish and import descriptor-only package');
|
||||
await spawnNodeScript(
|
||||
publisherScript,
|
||||
['--descriptor-only', '--witness-file', identityPath, '--output', descriptorPath],
|
||||
childEnv,
|
||||
);
|
||||
const descriptorImport = await requestJson({
|
||||
method: 'POST',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-witnesses/import-config'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
body: { path: descriptorPath },
|
||||
});
|
||||
assert(descriptorImport?.ok === true, 'descriptor-only import failed');
|
||||
|
||||
console.log('3/5 trigger republish and verify the external witness is declared in policy');
|
||||
const identity = JSON.parse(await fs.readFile(identityPath, 'utf8'));
|
||||
const distributionAfterDescriptor = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-distribution'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
const policyWitnesses = Array.isArray(distributionAfterDescriptor?.witness_policy?.witnesses)
|
||||
? distributionAfterDescriptor.witness_policy.witnesses
|
||||
: [];
|
||||
const declared = policyWitnesses.some(
|
||||
(item) =>
|
||||
String(item?.node_id || '').trim() === String(identity?.node_id || '').trim() &&
|
||||
String(item?.public_key || '').trim() === String(identity?.public_key || '').trim(),
|
||||
);
|
||||
assert(declared, 'external witness was not declared in the republished manifest policy');
|
||||
|
||||
console.log('4/5 publish and import the full signed external witness receipt package');
|
||||
await spawnNodeScript(
|
||||
publisherScript,
|
||||
['--witness-file', identityPath, '--output', receiptPath],
|
||||
childEnv,
|
||||
);
|
||||
const receiptImport = await requestJson({
|
||||
method: 'POST',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-witnesses/import-config'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
body: { path: receiptPath },
|
||||
});
|
||||
assert(receiptImport?.ok === true, 'external witness receipt import failed');
|
||||
|
||||
console.log('5/5 verify current root-distribution state');
|
||||
const distributionFinal = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-distribution'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
assert(Boolean(distributionFinal?.external_witness_receipts_current), 'external witness receipts did not become current');
|
||||
assert(safeInt(distributionFinal?.external_witness_receipt_count, 0) >= 1, 'external witness receipt count did not increase');
|
||||
|
||||
const health = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-health'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
ok: true,
|
||||
workspace,
|
||||
manifest_fingerprint: String(distributionFinal?.manifest_fingerprint || '').trim().toLowerCase(),
|
||||
witness_policy_fingerprint: String(distributionFinal?.witness_policy_fingerprint || '').trim().toLowerCase(),
|
||||
external_witness_receipt_count: safeInt(distributionFinal?.external_witness_receipt_count, 0),
|
||||
witness_count: safeInt(distributionFinal?.witness_count, 0),
|
||||
witness_domain_count: safeInt(distributionFinal?.witness_domain_count, 0),
|
||||
witness_independent_quorum_met: Boolean(distributionFinal?.witness_independent_quorum_met),
|
||||
witness_operator_state: String(distributionFinal?.external_witness_operator_state || '').trim(),
|
||||
health_summary_state: String(health?.state || '').trim(),
|
||||
health_state: String(health?.health_state || '').trim(),
|
||||
health_next_action: String(health?.next_action || '').trim(),
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
if (!args.keep) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(String(error?.message || error || 'external witness flow smoke failed').trim());
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const HELP_TEXT = `
|
||||
ShadowBroker root transparency publication smoke
|
||||
|
||||
Usage:
|
||||
node scripts/mesh/smoke-root-transparency-publication-flow.mjs [--keep] [--workspace PATH] [--base-url URL]
|
||||
|
||||
Environment:
|
||||
SB_DM_ROOT_BASE_URL=http://127.0.0.1:8000
|
||||
SB_DM_ROOT_AUTH_HEADER=X-Admin-Key: change-me
|
||||
SB_DM_ROOT_AUTH_COOKIE=operator_session=...
|
||||
SB_DM_ROOT_TIMEOUT_MS=10000
|
||||
SB_DM_ROOT_TRANSPARENCY_SMOKE_WORKSPACE=.smoke/root-transparency-publication
|
||||
SB_DM_ROOT_TRANSPARENCY_MAX_RECORDS=64
|
||||
|
||||
What it does:
|
||||
1. Fetch the current root transparency record.
|
||||
2. Publish the transparency ledger to a chosen local file through the operator endpoint.
|
||||
3. Read the published ledger back through the published-ledger endpoint.
|
||||
4. Verify binding and chain fingerprints match the live transparency state.
|
||||
|
||||
Flags:
|
||||
--keep Keep the smoke workspace instead of deleting it
|
||||
--workspace PATH Override SB_DM_ROOT_TRANSPARENCY_SMOKE_WORKSPACE
|
||||
--base-url URL Override SB_DM_ROOT_BASE_URL
|
||||
--help Show this text
|
||||
`.trim();
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim();
|
||||
if (!current) continue;
|
||||
if (current === '--keep') {
|
||||
parsed.keep = true;
|
||||
continue;
|
||||
}
|
||||
if (current === '--help' || current === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if ((current === '--workspace' || current === '--base-url') && index + 1 < argv.length) {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] =
|
||||
String(argv[index + 1] || '').trim();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl, routePath) {
|
||||
const base = String(baseUrl || 'http://127.0.0.1:8000').trim().replace(/\/+$/, '');
|
||||
return `${base}/${String(routePath || '').replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parseHeader(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return null;
|
||||
const separator = raw.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const name = raw.slice(0, separator).trim();
|
||||
const value = raw.slice(separator + 1).trim();
|
||||
if (!name || !value) return null;
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
async function requestJson({ method, url, authHeader, authCookie, timeoutMs, body }) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
const parsedAuth = parseHeader(authHeader);
|
||||
if (parsedAuth) {
|
||||
headers[parsedAuth[0]] = parsedAuth[1];
|
||||
}
|
||||
if (authCookie) {
|
||||
headers.Cookie = authCookie;
|
||||
}
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const detail = String(payload?.detail || payload?.message || `http_${response.status}`).trim();
|
||||
throw new Error(detail || `${method.toLowerCase()}_${url}_failed`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, detail) {
|
||||
if (!condition) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = path.resolve(
|
||||
String(
|
||||
args.workspace || process.env.SB_DM_ROOT_TRANSPARENCY_SMOKE_WORKSPACE || '.smoke/root-transparency-publication',
|
||||
).trim(),
|
||||
);
|
||||
const baseUrl = String(args.baseUrl || process.env.SB_DM_ROOT_BASE_URL || 'http://127.0.0.1:8000').trim();
|
||||
const timeoutMs = Math.max(1000, safeInt(process.env.SB_DM_ROOT_TIMEOUT_MS, 10000));
|
||||
const authHeader = process.env.SB_DM_ROOT_AUTH_HEADER || '';
|
||||
const authCookie = process.env.SB_DM_ROOT_AUTH_COOKIE || '';
|
||||
const maxRecords = Math.max(1, safeInt(process.env.SB_DM_ROOT_TRANSPARENCY_MAX_RECORDS, 64));
|
||||
const ledgerPath = path.join(workspace, 'root_transparency_ledger.json');
|
||||
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
|
||||
try {
|
||||
console.log('1/4 fetch current root transparency state');
|
||||
const current = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-transparency'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
assert(Boolean(current?.record_fingerprint), 'current root transparency record fingerprint missing');
|
||||
assert(Boolean(current?.binding_fingerprint), 'current root transparency binding fingerprint missing');
|
||||
|
||||
console.log('2/4 publish transparency ledger to a local file through the operator endpoint');
|
||||
const published = await requestJson({
|
||||
method: 'POST',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-transparency/ledger/publish'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
body: { path: ledgerPath, max_records: maxRecords },
|
||||
});
|
||||
assert(Boolean(published?.path), 'published transparency ledger path missing');
|
||||
assert(Boolean(published?.chain_fingerprint), 'published transparency chain fingerprint missing');
|
||||
|
||||
console.log('3/4 read the published ledger back through the published-ledger endpoint');
|
||||
const publishedReadback = await requestJson({
|
||||
method: 'GET',
|
||||
url: `${normalizeUrl(baseUrl, '/api/wormhole/dm/root-transparency/ledger/published')}?path=${encodeURIComponent(ledgerPath)}`,
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
assert(Boolean(publishedReadback?.chain_fingerprint), 'published ledger readback chain fingerprint missing');
|
||||
assert(Boolean(publishedReadback?.head_binding_fingerprint), 'published ledger readback head binding missing');
|
||||
|
||||
console.log('4/4 verify the exported ledger matches live transparency state');
|
||||
assert(
|
||||
String(publishedReadback.chain_fingerprint || '').trim().toLowerCase() ===
|
||||
String(published.chain_fingerprint || '').trim().toLowerCase(),
|
||||
'published ledger chain fingerprint mismatch',
|
||||
);
|
||||
assert(
|
||||
String(publishedReadback.head_binding_fingerprint || '').trim().toLowerCase() ===
|
||||
String(current.binding_fingerprint || '').trim().toLowerCase(),
|
||||
'published ledger binding fingerprint does not match current transparency binding',
|
||||
);
|
||||
assert(
|
||||
String(publishedReadback.current_record_fingerprint || '').trim().toLowerCase() ===
|
||||
String(current.record_fingerprint || '').trim().toLowerCase(),
|
||||
'published ledger head record does not match current transparency record',
|
||||
);
|
||||
|
||||
const ledgerStat = await fs.stat(ledgerPath);
|
||||
const summary = {
|
||||
ok: true,
|
||||
workspace,
|
||||
ledger_path: ledgerPath,
|
||||
ledger_size_bytes: ledgerStat.size,
|
||||
record_fingerprint: String(current.record_fingerprint || '').trim().toLowerCase(),
|
||||
binding_fingerprint: String(current.binding_fingerprint || '').trim().toLowerCase(),
|
||||
chain_fingerprint: String(publishedReadback.chain_fingerprint || '').trim().toLowerCase(),
|
||||
record_count: safeInt(publishedReadback.record_count, 0),
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
if (!args.keep) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(String(error?.message || error || 'root transparency publication smoke failed').trim());
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const HELP_TEXT = `
|
||||
ShadowBroker DM root external assurance sync
|
||||
|
||||
Usage:
|
||||
node scripts/mesh/sync-dm-root-external-assurance.mjs [--base-url URL] [--witness-file PATH] [--publish-transparency]
|
||||
|
||||
Environment:
|
||||
SB_DM_ROOT_BASE_URL=http://127.0.0.1:8000
|
||||
SB_DM_ROOT_AUTH_HEADER=X-Admin-Key: change-me
|
||||
SB_DM_ROOT_AUTH_COOKIE=operator_session=...
|
||||
SB_DM_ROOT_TIMEOUT_MS=10000
|
||||
SB_DM_ROOT_WITNESS_IDENTITY_FILE=./ops/witness-a.identity.json
|
||||
SB_DM_ROOT_WITNESS_LABEL=witness-a
|
||||
SB_DM_ROOT_WITNESS_SOURCE_SCOPE=https_publish
|
||||
SB_DM_ROOT_WITNESS_SOURCE_LABEL=witness-a
|
||||
SB_DM_ROOT_WITNESS_INDEPENDENCE_GROUP=independent_witness_a
|
||||
SB_DM_ROOT_TRANSPARENCY_PUBLISH_PATH=./published/root_transparency_ledger.json
|
||||
SB_DM_ROOT_TRANSPARENCY_MAX_RECORDS=64
|
||||
|
||||
What it does:
|
||||
1. Creates an external witness identity if the configured file is missing.
|
||||
2. Imports a descriptor-only witness package if the external witness is not yet declared in policy.
|
||||
3. Imports a full signed witness receipt package once the current manifest policy allows it.
|
||||
4. Optionally publishes the transparency ledger to a chosen file path.
|
||||
5. Prints the final DM root health summary.
|
||||
|
||||
Flags:
|
||||
--base-url URL Override SB_DM_ROOT_BASE_URL
|
||||
--witness-file PATH Override SB_DM_ROOT_WITNESS_IDENTITY_FILE
|
||||
--publish-transparency Publish the transparency ledger using SB_DM_ROOT_TRANSPARENCY_PUBLISH_PATH
|
||||
--help Show this text
|
||||
`.trim();
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim();
|
||||
if (!current) continue;
|
||||
if (current === '--publish-transparency') {
|
||||
parsed.publishTransparency = true;
|
||||
continue;
|
||||
}
|
||||
if (current === '--help' || current === '-h') {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if ((current === '--base-url' || current === '--witness-file') && index + 1 < argv.length) {
|
||||
parsed[current.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] =
|
||||
String(argv[index + 1] || '').trim();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl, routePath) {
|
||||
const base = String(baseUrl || 'http://127.0.0.1:8000').trim().replace(/\/+$/, '');
|
||||
return `${base}/${String(routePath || '').replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function parseHeader(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return null;
|
||||
const separator = raw.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const name = raw.slice(0, separator).trim();
|
||||
const value = raw.slice(separator + 1).trim();
|
||||
if (!name || !value) return null;
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
async function requestJson({ method, url, authHeader, authCookie, timeoutMs, body }) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
const parsedAuth = parseHeader(authHeader);
|
||||
if (parsedAuth) {
|
||||
headers[parsedAuth[0]] = parsedAuth[1];
|
||||
}
|
||||
if (authCookie) {
|
||||
headers.Cookie = authCookie;
|
||||
}
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const detail = String(payload?.detail || payload?.message || `http_${response.status}`).trim();
|
||||
throw new Error(detail || `${method.toLowerCase()}_${url}_failed`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnNode(scriptPath, args, env, { expectJson = false } = {}) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk || '');
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk || '');
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `${path.basename(scriptPath)} exited ${code ?? 1}`));
|
||||
return;
|
||||
}
|
||||
if (!expectJson) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch {
|
||||
reject(new Error(`failed to parse JSON output from ${path.basename(scriptPath)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function witnessDeclaredInPolicy(distribution, identity) {
|
||||
const witnesses = Array.isArray(distribution?.witness_policy?.witnesses)
|
||||
? distribution.witness_policy.witnesses
|
||||
: [];
|
||||
return witnesses.some(
|
||||
(item) =>
|
||||
String(item?.node_id || '').trim() === String(identity?.node_id || '').trim() &&
|
||||
String(item?.public_key || '').trim() === String(identity?.public_key || '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
const raw = await fs.readFile(path.resolve(filePath), 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await fs.access(path.resolve(filePath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const publisherScript = path.resolve(rootDir, 'scripts/mesh/publish-external-root-witness-package.mjs');
|
||||
const baseUrl = String(args.baseUrl || process.env.SB_DM_ROOT_BASE_URL || 'http://127.0.0.1:8000').trim();
|
||||
const witnessFile = String(args.witnessFile || process.env.SB_DM_ROOT_WITNESS_IDENTITY_FILE || '').trim();
|
||||
const timeoutMs = Math.max(1000, safeInt(process.env.SB_DM_ROOT_TIMEOUT_MS, 10000));
|
||||
const authHeader = process.env.SB_DM_ROOT_AUTH_HEADER || '';
|
||||
const authCookie = process.env.SB_DM_ROOT_AUTH_COOKIE || '';
|
||||
const transparencyPublishPath = String(process.env.SB_DM_ROOT_TRANSPARENCY_PUBLISH_PATH || '').trim();
|
||||
const transparencyMaxRecords = Math.max(1, safeInt(process.env.SB_DM_ROOT_TRANSPARENCY_MAX_RECORDS, 64));
|
||||
|
||||
if (!witnessFile) {
|
||||
throw new Error('witness identity file required via --witness-file or SB_DM_ROOT_WITNESS_IDENTITY_FILE');
|
||||
}
|
||||
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
SB_DM_ROOT_BASE_URL: baseUrl,
|
||||
SB_DM_ROOT_AUTH_HEADER: authHeader,
|
||||
SB_DM_ROOT_AUTH_COOKIE: authCookie,
|
||||
SB_DM_ROOT_TIMEOUT_MS: String(timeoutMs),
|
||||
};
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (!(await exists(witnessFile))) {
|
||||
console.log('creating external witness identity');
|
||||
await spawnNode(publisherScript, ['--init-witness', witnessFile], childEnv);
|
||||
actions.push('witness_identity_created');
|
||||
}
|
||||
|
||||
const identity = await readJsonFile(witnessFile);
|
||||
|
||||
let distribution = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-distribution'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
if (!witnessDeclaredInPolicy(distribution, identity)) {
|
||||
console.log('importing descriptor-only external witness package');
|
||||
const descriptorMaterial = await spawnNode(
|
||||
publisherScript,
|
||||
['--descriptor-only', '--witness-file', witnessFile, '--stdout'],
|
||||
childEnv,
|
||||
{ expectJson: true },
|
||||
);
|
||||
await requestJson({
|
||||
method: 'POST',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-witnesses/import'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
body: { material: descriptorMaterial },
|
||||
});
|
||||
actions.push('descriptor_imported');
|
||||
distribution = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-distribution'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (witnessDeclaredInPolicy(distribution, identity) && !Boolean(distribution?.external_witness_receipts_current)) {
|
||||
console.log('importing full external witness receipt package');
|
||||
const receiptMaterial = await spawnNode(
|
||||
publisherScript,
|
||||
['--witness-file', witnessFile, '--stdout'],
|
||||
childEnv,
|
||||
{ expectJson: true },
|
||||
);
|
||||
await requestJson({
|
||||
method: 'POST',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-witnesses/import'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
body: { material: receiptMaterial },
|
||||
});
|
||||
actions.push('receipt_imported');
|
||||
distribution = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-distribution'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
let transparency = null;
|
||||
if (args.publishTransparency) {
|
||||
if (!transparencyPublishPath) {
|
||||
throw new Error('SB_DM_ROOT_TRANSPARENCY_PUBLISH_PATH required when --publish-transparency is used');
|
||||
}
|
||||
console.log('publishing transparency ledger');
|
||||
transparency = await requestJson({
|
||||
method: 'POST',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-transparency/ledger/publish'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
body: {
|
||||
path: transparencyPublishPath,
|
||||
max_records: transparencyMaxRecords,
|
||||
},
|
||||
});
|
||||
actions.push('transparency_published');
|
||||
}
|
||||
|
||||
const health = await requestJson({
|
||||
method: 'GET',
|
||||
url: normalizeUrl(baseUrl, '/api/wormhole/dm/root-health'),
|
||||
authHeader,
|
||||
authCookie,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
ok: true,
|
||||
actions,
|
||||
state: String(health?.state || '').trim(),
|
||||
health_state: String(health?.health_state || '').trim(),
|
||||
strong_trust_blocked: Boolean(health?.strong_trust_blocked),
|
||||
external_assurance_current: Boolean(health?.external_assurance_current),
|
||||
requires_attention: Boolean(health?.requires_attention),
|
||||
next_action: String(health?.next_action || '').trim(),
|
||||
witness: {
|
||||
state: String(health?.witness?.state || '').trim(),
|
||||
health_state: String(health?.witness?.health_state || '').trim(),
|
||||
source_ref: String(health?.witness?.source_ref || '').trim(),
|
||||
age_s: safeInt(health?.witness?.age_s, 0),
|
||||
reacquire_required: Boolean(health?.witness?.reacquire_required),
|
||||
independent_quorum_met: Boolean(health?.witness?.independent_quorum_met),
|
||||
},
|
||||
transparency: {
|
||||
state: String(health?.transparency?.state || '').trim(),
|
||||
health_state: String(health?.transparency?.health_state || '').trim(),
|
||||
source_ref: String(health?.transparency?.source_ref || '').trim(),
|
||||
age_s: safeInt(health?.transparency?.age_s, 0),
|
||||
verification_required: Boolean(health?.transparency?.verification_required),
|
||||
},
|
||||
distribution: {
|
||||
manifest_fingerprint: String(distribution?.manifest_fingerprint || '').trim().toLowerCase(),
|
||||
witness_policy_fingerprint: String(distribution?.witness_policy_fingerprint || '').trim().toLowerCase(),
|
||||
external_witness_receipt_count: safeInt(distribution?.external_witness_receipt_count, 0),
|
||||
external_witness_receipts_current: Boolean(distribution?.external_witness_receipts_current),
|
||||
witness_count: safeInt(distribution?.witness_count, 0),
|
||||
witness_domain_count: safeInt(distribution?.witness_domain_count, 0),
|
||||
},
|
||||
};
|
||||
if (transparency) {
|
||||
summary.transparency_publish = {
|
||||
path: String(transparency?.path || '').trim(),
|
||||
chain_fingerprint: String(transparency?.chain_fingerprint || '').trim().toLowerCase(),
|
||||
head_binding_fingerprint: String(transparency?.head_binding_fingerprint || '').trim().toLowerCase(),
|
||||
record_count: safeInt(transparency?.record_count, 0),
|
||||
};
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(String(error?.message || error || 'dm root external assurance sync failed').trim());
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
param(
|
||||
[string]$NodeA = "http://127.0.0.1:8001",
|
||||
[string]$NodeB = "http://127.0.0.1:8002",
|
||||
[string]$AdminKey = "dm-test-node-local-admin-key-00000001"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$RuntimeRoot = Join-Path $Root ".runtime\dm-two-node"
|
||||
$ReportPath = Join-Path $RuntimeRoot "two-node-selftest.json"
|
||||
$Headers = @{ "X-Admin-Key" = $AdminKey }
|
||||
|
||||
function Invoke-Json(
|
||||
[string]$Method,
|
||||
[string]$Uri,
|
||||
[object]$Body = $null,
|
||||
[switch]$Admin
|
||||
) {
|
||||
$args = @{
|
||||
Method = $Method
|
||||
Uri = $Uri
|
||||
TimeoutSec = 30
|
||||
}
|
||||
if ($Admin) {
|
||||
$args.Headers = $Headers
|
||||
}
|
||||
if ($null -ne $Body) {
|
||||
$args.Body = ($Body | ConvertTo-Json -Depth 100)
|
||||
$args.ContentType = "application/json"
|
||||
}
|
||||
return Invoke-RestMethod @args
|
||||
}
|
||||
|
||||
function Assert-Ok([object]$Result, [string]$Step) {
|
||||
if (-not $Result -or -not [bool]$Result.ok) {
|
||||
$detail = if ($Result -and $Result.detail) { [string]$Result.detail } else { "no detail" }
|
||||
throw "$Step failed: $detail"
|
||||
}
|
||||
}
|
||||
|
||||
function Register-DmNode([string]$BaseUrl, [string]$Label) {
|
||||
$registered = Invoke-Json "Post" "$BaseUrl/api/wormhole/dm/register-key" -Admin
|
||||
Assert-Ok $registered "$Label key registration"
|
||||
if (-not [bool]$registered.prekeys_ok -or -not $registered.prekey_detail -or -not $registered.prekey_detail.bundle) {
|
||||
throw "$Label prekey registration failed"
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
label = $Label
|
||||
base = $BaseUrl
|
||||
node_id = [string]$registered.node_id
|
||||
dh_pub_key = [string]$registered.dh_pub_key
|
||||
prekey_bundle = $registered.prekey_detail.bundle
|
||||
registered = $registered
|
||||
}
|
||||
}
|
||||
|
||||
function Try-Compose(
|
||||
[object]$Sender,
|
||||
[object]$Receiver,
|
||||
[string]$Plaintext
|
||||
) {
|
||||
$body = @{
|
||||
peer_id = $Receiver.node_id
|
||||
peer_dh_pub = $Receiver.dh_pub_key
|
||||
plaintext = $Plaintext
|
||||
local_alias = $Sender.label
|
||||
remote_alias = $Receiver.label
|
||||
}
|
||||
return Invoke-Json "Post" "$($Sender.base)/api/wormhole/dm/compose" $body
|
||||
}
|
||||
|
||||
function Decrypt-OnReceiver(
|
||||
[object]$Receiver,
|
||||
[object]$Sender,
|
||||
[object]$Envelope
|
||||
) {
|
||||
$body = @{
|
||||
peer_id = $Sender.node_id
|
||||
ciphertext = $Envelope.ciphertext
|
||||
nonce = $Envelope.nonce
|
||||
format = $Envelope.format
|
||||
local_alias = $Receiver.label
|
||||
remote_alias = $Sender.label
|
||||
session_welcome = $Envelope.session_welcome
|
||||
}
|
||||
return Invoke-Json "Post" "$($Receiver.base)/api/wormhole/dm/decrypt" $body -Admin
|
||||
}
|
||||
|
||||
function Search-PlaintextInNodeData([string]$Needle) {
|
||||
$hits = @()
|
||||
foreach ($nodeName in @("node-a", "node-b")) {
|
||||
$dataPath = Join-Path $RuntimeRoot "$nodeName\backend\data"
|
||||
if (-not (Test-Path $dataPath)) {
|
||||
continue
|
||||
}
|
||||
$matches = Get-ChildItem -Path $dataPath -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Select-String -Pattern ([regex]::Escape($Needle)) -SimpleMatch -ErrorAction SilentlyContinue
|
||||
foreach ($match in @($matches)) {
|
||||
$hits += [pscustomobject]@{
|
||||
node = $nodeName
|
||||
path = $match.Path
|
||||
line = $match.LineNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
return @($hits)
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $RuntimeRoot | Out-Null
|
||||
|
||||
$healthA = Invoke-Json "Get" "$NodeA/api/health"
|
||||
$healthB = Invoke-Json "Get" "$NodeB/api/health"
|
||||
|
||||
$nodeAState = Register-DmNode $NodeA "node-a"
|
||||
$nodeBState = Register-DmNode $NodeB "node-b"
|
||||
|
||||
Invoke-Json "Post" "$NodeA/api/wormhole/dm/reset" @{ peer_id = $nodeBState.node_id } -Admin | Out-Null
|
||||
Invoke-Json "Post" "$NodeB/api/wormhole/dm/reset" @{ peer_id = $nodeAState.node_id } -Admin | Out-Null
|
||||
|
||||
$inviteA = Invoke-Json "Get" "$NodeA/api/wormhole/dm/invite"
|
||||
$inviteB = Invoke-Json "Get" "$NodeB/api/wormhole/dm/invite"
|
||||
$inviteImportBIntoA = Invoke-Json "Post" "$NodeA/api/wormhole/dm/invite/import" @{
|
||||
invite = $inviteB.invite
|
||||
alias = "node-b"
|
||||
} -Admin
|
||||
Assert-Ok $inviteImportBIntoA "node-a import node-b signed invite"
|
||||
$inviteImportAIntoB = Invoke-Json "Post" "$NodeB/api/wormhole/dm/invite/import" @{
|
||||
invite = $inviteA.invite
|
||||
alias = "node-a"
|
||||
} -Admin
|
||||
Assert-Ok $inviteImportAIntoB "node-b import node-a signed invite"
|
||||
|
||||
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$messageAB = "dm-two-node-a-to-b-$timestamp"
|
||||
$messageBA = "dm-two-node-b-to-a-$timestamp"
|
||||
|
||||
# Keep the actual round-trip below a clean first session so the receiver gets a
|
||||
# fresh welcome every time.
|
||||
Invoke-Json "Post" "$NodeA/api/wormhole/dm/reset" @{ peer_id = $nodeBState.node_id } -Admin | Out-Null
|
||||
Invoke-Json "Post" "$NodeB/api/wormhole/dm/reset" @{ peer_id = $nodeAState.node_id } -Admin | Out-Null
|
||||
|
||||
$composeAB = Try-Compose $nodeAState $nodeBState $messageAB
|
||||
Assert-Ok $composeAB "node-a compose to node-b"
|
||||
$decryptAB = Decrypt-OnReceiver $nodeBState $nodeAState $composeAB
|
||||
Assert-Ok $decryptAB "node-b decrypt from node-a"
|
||||
if ([string]$decryptAB.plaintext -ne $messageAB) {
|
||||
throw "node-b decrypted unexpected plaintext"
|
||||
}
|
||||
|
||||
$composeBA = Try-Compose $nodeBState $nodeAState $messageBA
|
||||
Assert-Ok $composeBA "node-b compose to node-a"
|
||||
$decryptBA = Decrypt-OnReceiver $nodeAState $nodeBState $composeBA
|
||||
Assert-Ok $decryptBA "node-a decrypt from node-b"
|
||||
if ([string]$decryptBA.plaintext -ne $messageBA) {
|
||||
throw "node-a decrypted unexpected plaintext"
|
||||
}
|
||||
|
||||
$plaintextHits = @()
|
||||
$plaintextHits += Search-PlaintextInNodeData $messageAB
|
||||
$plaintextHits += Search-PlaintextInNodeData $messageBA
|
||||
|
||||
$report = [pscustomobject]@{
|
||||
ok = $true
|
||||
checked_at = $timestamp
|
||||
nodes = @{
|
||||
node_a = @{
|
||||
url = $NodeA
|
||||
id = $nodeAState.node_id
|
||||
health_status = $healthA.status
|
||||
}
|
||||
node_b = @{
|
||||
url = $NodeB
|
||||
id = $nodeBState.node_id
|
||||
health_status = $healthB.status
|
||||
}
|
||||
}
|
||||
first_contact = @{
|
||||
node_a_to_node_b = @{
|
||||
local = "node-a"
|
||||
remote = "node-b"
|
||||
trust_level = [string]$inviteImportBIntoA.trust_level
|
||||
invite_attested = [bool]$inviteImportBIntoA.invite_attested
|
||||
}
|
||||
node_b_to_node_a = @{
|
||||
local = "node-b"
|
||||
remote = "node-a"
|
||||
trust_level = [string]$inviteImportAIntoB.trust_level
|
||||
invite_attested = [bool]$inviteImportAIntoB.invite_attested
|
||||
}
|
||||
invite_export_ok = ([bool]$inviteA.ok -and [bool]$inviteB.ok)
|
||||
invite_import_node_b_into_node_a = @{
|
||||
ok = [bool]$inviteImportBIntoA.ok
|
||||
detail = [string]$inviteImportBIntoA.detail
|
||||
}
|
||||
invite_import_node_a_into_node_b = @{
|
||||
ok = [bool]$inviteImportAIntoB.ok
|
||||
detail = [string]$inviteImportAIntoB.detail
|
||||
}
|
||||
}
|
||||
message_round_trip = @{
|
||||
node_a_to_node_b = @{
|
||||
compose_ok = [bool]$composeAB.ok
|
||||
decrypt_ok = [bool]$decryptAB.ok
|
||||
format = [string]$composeAB.format
|
||||
has_session_welcome = [bool]$composeAB.session_welcome
|
||||
ciphertext_contains_plaintext = ([string]$composeAB.ciphertext).Contains($messageAB)
|
||||
}
|
||||
node_b_to_node_a = @{
|
||||
compose_ok = [bool]$composeBA.ok
|
||||
decrypt_ok = [bool]$decryptBA.ok
|
||||
format = [string]$composeBA.format
|
||||
has_session_welcome = [bool]$composeBA.session_welcome
|
||||
ciphertext_contains_plaintext = ([string]$composeBA.ciphertext).Contains($messageBA)
|
||||
}
|
||||
}
|
||||
privacy_storage_check = @{
|
||||
plaintext_found_in_node_data = ($plaintextHits.Count -gt 0)
|
||||
hits = @($plaintextHits)
|
||||
}
|
||||
limits = @(
|
||||
"This proves two separate localhost backend processes can perform MLS DM compose/decrypt both ways.",
|
||||
"It proves signed invite import can resolve invite-scoped prekeys over the peer-authenticated local test lane.",
|
||||
"It does not prove RNS/Tor/relay delivery because this local runtime intentionally disables those transports."
|
||||
)
|
||||
}
|
||||
|
||||
$report | ConvertTo-Json -Depth 100 | Set-Content -Path $ReportPath -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DM two-node selftest passed."
|
||||
Write-Host "A -> B: $($composeAB.format), decrypted by node-b."
|
||||
Write-Host "B -> A: $($composeBA.format), decrypted by node-a."
|
||||
Write-Host "Plaintext in node data: $($report.privacy_storage_check.plaintext_found_in_node_data)"
|
||||
Write-Host "Invite import A<-B: $($report.first_contact.invite_import_node_b_into_node_a.ok) $($report.first_contact.invite_import_node_b_into_node_a.detail)"
|
||||
Write-Host "Invite import B<-A: $($report.first_contact.invite_import_node_a_into_node_b.ok) $($report.first_contact.invite_import_node_a_into_node_b.detail)"
|
||||
Write-Host "Report: $ReportPath"
|
||||
@@ -0,0 +1,191 @@
|
||||
param(
|
||||
[int]$NodeAPort = 8001,
|
||||
[int]$NodeBPort = 8002,
|
||||
[switch]$NoSync
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$SourceBackend = Join-Path $Root "backend"
|
||||
$RuntimeRoot = Join-Path $Root ".runtime\dm-two-node"
|
||||
$PidFile = Join-Path $RuntimeRoot "pids.json"
|
||||
|
||||
function Resolve-SharedPython {
|
||||
$marker = Join-Path $SourceBackend ".venv-dir"
|
||||
$candidates = @()
|
||||
if (Test-Path $marker) {
|
||||
$raw = (Get-Content $marker -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()
|
||||
if ($raw) {
|
||||
$venvDir = if ([System.IO.Path]::IsPathRooted($raw)) { $raw } else { Join-Path $SourceBackend $raw }
|
||||
$candidates += Join-Path $venvDir "Scripts\python.exe"
|
||||
}
|
||||
}
|
||||
$candidates += @(
|
||||
(Join-Path $SourceBackend "venv\Scripts\python.exe"),
|
||||
(Join-Path $SourceBackend "venv-repair\Scripts\python.exe")
|
||||
)
|
||||
$candidates += Get-ChildItem -Path $SourceBackend -Directory -Filter "venv-repair*" -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Join-Path $_.FullName "Scripts\python.exe" }
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if ($candidate -and (Test-Path $candidate)) {
|
||||
return (Resolve-Path $candidate).Path
|
||||
}
|
||||
}
|
||||
throw "Could not find an existing backend Python venv. Start the normal backend once first, then rerun this script."
|
||||
}
|
||||
|
||||
function Stop-PortIfListening([int]$Port) {
|
||||
$listeners = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
foreach ($listener in $listeners) {
|
||||
if ($listener.OwningProcess) {
|
||||
Stop-Process -Id $listener.OwningProcess -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Sync-RuntimeBackend([string]$NodeName) {
|
||||
$nodeRoot = Join-Path $RuntimeRoot $NodeName
|
||||
$destBackend = Join-Path $nodeRoot "backend"
|
||||
New-Item -ItemType Directory -Force -Path $nodeRoot | Out-Null
|
||||
|
||||
if (-not $NoSync) {
|
||||
New-Item -ItemType Directory -Force -Path $destBackend | Out-Null
|
||||
$excludeDirs = @(
|
||||
"data",
|
||||
"node_modules",
|
||||
"venv",
|
||||
".venv",
|
||||
"venv-repair",
|
||||
"venv-repair-*",
|
||||
".venv-repair",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"backend.egg-info",
|
||||
"tests",
|
||||
"timemachine",
|
||||
"sb-custody-verify-*"
|
||||
)
|
||||
$excludeFiles = @(".env", "*.pyc", "*.pyo", "*.log", "test_*.py")
|
||||
$args = @(
|
||||
$SourceBackend,
|
||||
$destBackend,
|
||||
"/MIR",
|
||||
"/R:1",
|
||||
"/W:1",
|
||||
"/NFL",
|
||||
"/NDL",
|
||||
"/NJH",
|
||||
"/NJS",
|
||||
"/NP",
|
||||
"/XD"
|
||||
) + $excludeDirs + @("/XF") + $excludeFiles
|
||||
& robocopy @args | Out-Null
|
||||
if ($LASTEXITCODE -gt 7) {
|
||||
throw "robocopy failed for $NodeName with exit code $LASTEXITCODE"
|
||||
}
|
||||
foreach ($runtimeOnlyDir in @("tests", "timemachine", ".pytest_cache", ".ruff_cache", "__pycache__")) {
|
||||
$path = Join-Path $destBackend $runtimeOnlyDir
|
||||
if (Test-Path $path) {
|
||||
Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Get-ChildItem -Path $destBackend -Filter "test_*.py" -File -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$dataDir = Join-Path $destBackend "data"
|
||||
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
|
||||
'{"enabled":true,"updated_at":0}' | Set-Content -Path (Join-Path $dataDir "node.json") -Encoding ASCII
|
||||
return $destBackend
|
||||
}
|
||||
|
||||
function Write-NodeRunner(
|
||||
[string]$NodeName,
|
||||
[string]$BackendDir,
|
||||
[string]$Python,
|
||||
[int]$Port,
|
||||
[int]$PeerPort
|
||||
) {
|
||||
$nodeRoot = Split-Path $BackendDir -Parent
|
||||
$logPath = Join-Path $nodeRoot "backend-$Port.log"
|
||||
$runner = Join-Path $nodeRoot "run-$Port.cmd"
|
||||
$peer = "http://127.0.0.1:$PeerPort"
|
||||
$privacyCore = Join-Path $Root "privacy-core\target\release\privacy_core.dll"
|
||||
if (-not (Test-Path $privacyCore)) {
|
||||
$privacyCore = Join-Path $Root "privacy-core\debug\privacy_core.dll"
|
||||
}
|
||||
if (-not (Test-Path $privacyCore)) {
|
||||
throw "Could not find privacy-core DLL under privacy-core\target\release or privacy-core\debug."
|
||||
}
|
||||
$content = @"
|
||||
@echo off
|
||||
set SB_TEST_NODE_NAME=$NodeName
|
||||
set SB_TEST_NODE_URL=http://127.0.0.1:$Port
|
||||
set ADMIN_KEY=dm-test-node-local-admin-key-00000001
|
||||
set MESH_SELF_PEER_URL=http://127.0.0.1:$Port
|
||||
set MESH_PEER_PUSH_SECRET=dm-test-two-node-peer-push-secret-00000001
|
||||
set MESH_ONLY=true
|
||||
set MESH_NODE_MODE=participant
|
||||
set MESH_BOOTSTRAP_DISABLED=true
|
||||
set MESH_MQTT_ENABLED=false
|
||||
set MESH_RNS_ENABLED=false
|
||||
set MESH_ARTI_ENABLED=false
|
||||
set MESH_DM_SECURE_MODE=true
|
||||
set MESH_PRIVATE_RELEASE_APPROVAL_ENABLE=true
|
||||
set MESH_DM_RELAY_AUTO_RELOAD=true
|
||||
set MESH_RELAY_PEERS=$peer
|
||||
set PRIVACY_CORE_LIB=$privacyCore
|
||||
set PYTHONPATH=$BackendDir
|
||||
cd /d "$BackendDir"
|
||||
"$Python" -m uvicorn main:app --host 127.0.0.1 --port $Port --timeout-keep-alive 120
|
||||
"@
|
||||
$content | Set-Content -Path $runner -Encoding ASCII
|
||||
return @{ Runner = $runner; Log = $logPath }
|
||||
}
|
||||
|
||||
function Start-TestNode([string]$NodeName, [int]$Port, [int]$PeerPort, [string]$Python) {
|
||||
Stop-PortIfListening $Port
|
||||
$backendDir = Sync-RuntimeBackend $NodeName
|
||||
$runnerInfo = Write-NodeRunner $NodeName $backendDir $Python $Port $PeerPort
|
||||
$cmd = "/c `"`"$($runnerInfo.Runner)`" > `"$($runnerInfo.Log)`" 2>&1`""
|
||||
$process = Start-Process -FilePath "cmd.exe" -ArgumentList $cmd -PassThru -WindowStyle Minimized
|
||||
return @{
|
||||
node = $NodeName
|
||||
port = $Port
|
||||
pid = $process.Id
|
||||
backend = $backendDir
|
||||
data = Join-Path $backendDir "data"
|
||||
log = $runnerInfo.Log
|
||||
peer = "http://127.0.0.1:$PeerPort"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $RuntimeRoot | Out-Null
|
||||
$python = Resolve-SharedPython
|
||||
|
||||
$nodes = @(
|
||||
Start-TestNode "node-a" $NodeAPort $NodeBPort $python
|
||||
Start-TestNode "node-b" $NodeBPort $NodeAPort $python
|
||||
)
|
||||
|
||||
$payload = @{
|
||||
started_at = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
shared_python = $python
|
||||
nodes = $nodes
|
||||
}
|
||||
$payload | ConvertTo-Json -Depth 5 | Set-Content -Path $PidFile -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DM two-node test runtime started without copying dependencies."
|
||||
Write-Host "Shared Python: $python"
|
||||
foreach ($node in $nodes) {
|
||||
Write-Host "$($node.node): http://127.0.0.1:$($node.port)"
|
||||
Write-Host " data: $($node.data)"
|
||||
Write-Host " log: $($node.log)"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Stop with: powershell -ExecutionPolicy Bypass -File scripts\stop-dm-test-nodes.ps1"
|
||||
@@ -0,0 +1,37 @@
|
||||
param(
|
||||
[int[]]$Ports = @(8001, 8002)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$RuntimeRoot = Join-Path $Root ".runtime\dm-two-node"
|
||||
$PidFile = Join-Path $RuntimeRoot "pids.json"
|
||||
|
||||
if (Test-Path $PidFile) {
|
||||
try {
|
||||
$payload = Get-Content $PidFile -Raw | ConvertFrom-Json
|
||||
foreach ($node in @($payload.nodes)) {
|
||||
if ($node.pid) {
|
||||
Stop-Process -Id ([int]$node.pid) -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($node.port) {
|
||||
$Ports += [int]$node.port
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Could not parse $PidFile; falling back to port cleanup."
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($port in ($Ports | Select-Object -Unique)) {
|
||||
$listeners = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
|
||||
foreach ($listener in $listeners) {
|
||||
if ($listener.OwningProcess) {
|
||||
Write-Host "Stopping PID $($listener.OwningProcess) on port $port"
|
||||
Stop-Process -Id $listener.OwningProcess -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "DM test nodes stopped."
|
||||
Reference in New Issue
Block a user