Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { restartPollerCadence, startPoller, stopPoller } from './services/node-m
import { replayPendingSpecs } from './services/node-specs';
import { refreshWalletBalance } from './services/wallet';
import { primeDeploySettings } from './services/deploy';
import { startUpdater } from './services/updater';
import { startUpdater, stopUpdater } from './services/updater';
import { getSettings, onSettingsChanged } from './services/settings';
import { isCliServerRunning, startCliServer, stopCliServer } from './services/cli-server';
import type { AppSettings } from '../shared/types';
Expand Down Expand Up @@ -52,8 +52,25 @@ crashReporter.start({
});

// Single-instance lock — avoids two app windows racing to manage the same node.
if (!app.requestSingleInstanceLock()) {
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
// We're the second instance. Quit immediately; the primary will get a
// `second-instance` event and surface its window. `app.quit()` alone only
// schedules teardown, so guard the rest of module init from running and
// briefly spinning up a poller/updater that we're about to tear down.
app.quit();
} else {
// L-7: without a `second-instance` handler the user's second launch (or a
// file double-click that re-invokes the exe) was silently swallowed — the
// app would appear "not to open". Restore + focus the existing window so a
// re-launch behaves like a click on the taskbar icon.
app.on('second-instance', () => {
const win = BrowserWindow.getAllWindows()[0];
if (!win) return;
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
});
}

function createWindow(): BrowserWindow {
Expand Down Expand Up @@ -206,6 +223,9 @@ function installContentSecurityPolicy(): void {
}

app.whenReady().then(async () => {
// Second instance: the lock was denied and app.quit() is already scheduled.
// Bail before we initialise services we're about to tear down.
if (!gotSingleInstanceLock) return;
installContentSecurityPolicy();
log.info('app ready', {
version: app.getVersion(),
Expand Down Expand Up @@ -310,6 +330,7 @@ app.on('before-quit', () => {
log.info('app quitting');
if (balancePoll) clearInterval(balancePoll);
stopPoller();
stopUpdater();
destroyAppTray();
// Stop the CLI server if the user opted in (default true). Best-effort —
// we don't await here because before-quit is synchronous; the underlying
Expand Down
87 changes: 36 additions & 51 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../shared/types';
import { testSSHConnection } from './services/ssh';
import { forgetHostKey } from './services/host-keys';
import { HOSTNAME_RE, vSSHCredentials, vUUID } from './validate';
import { publishNodeSpecs } from './services/node-specs';
import {
startDeploy,
Expand Down Expand Up @@ -83,46 +84,9 @@ import { spawn } from 'node:child_process';
// `ipcMain.handle` returns a structured error to the caller instead of
// silently passing malformed data into the service layer.

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HOSTNAME_RE = /^[a-zA-Z0-9.\-:_]{1,255}$/; // permits IPv4, hostnames, IPv6 in brackets is rejected here — refused on purpose
const USERNAME_RE = /^[a-zA-Z0-9._\-]{1,32}$/;

function vUUID(id: unknown, label: string): string {
if (typeof id !== 'string' || !UUID_RE.test(id)) {
throw new Error(`Invalid ${label}: expected UUID`);
}
return id;
}

function vSSHCredentials(raw: unknown): SSHCredentials {
if (!raw || typeof raw !== 'object') throw new Error('Invalid SSH credentials');
const c = raw as Record<string, unknown>;
const host = String(c.host ?? '');
if (!HOSTNAME_RE.test(host)) throw new Error('Invalid SSH host');
const port = Number(c.port ?? 22);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Invalid SSH port');
}
const username = String(c.username ?? '');
if (!USERNAME_RE.test(username)) throw new Error('Invalid SSH username');
// password / privateKey / passphrase: pass-through. Length-bound only
// to avoid trivial DoS via gigabyte payloads.
const cap = (s: unknown, max: number) => {
if (s === undefined || s === null) return undefined;
const v = String(s);
if (v.length > max) throw new Error('SSH credential field too long');
return v;
};
return {
host,
port,
username,
password: cap(c.password, 4096),
privateKey: cap(c.privateKey, 32_768),
passphrase: cap(c.passphrase, 4096),
} as SSHCredentials;
}

// Validators live in ./validate so the CLI registry (cli-registry.ts) shares
// the exact same bounds/charset checks (M-13). HOSTNAME_RE is re-used by the
// forget-host-key handler below.
function broadcast(channel: string, payload: unknown) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(channel, payload);
Expand Down Expand Up @@ -174,12 +138,12 @@ async function reportLocalSystem(): Promise<LocalSystemReport> {

export function registerIpcHandlers(): void {
ipcMain.handle(IPC.SYSTEM_REPORT, reportLocalSystem);
ipcMain.handle(IPC.SYSTEM_LIVE_STATS_START, () => {
startLiveStats();
ipcMain.handle(IPC.SYSTEM_LIVE_STATS_START, (e) => {
startLiveStats(e.sender);
return { ok: true };
});
ipcMain.handle(IPC.SYSTEM_LIVE_STATS_STOP, () => {
stopLiveStats();
ipcMain.handle(IPC.SYSTEM_LIVE_STATS_STOP, (e) => {
stopLiveStats(e.sender);
return { ok: true };
});
ipcMain.handle(IPC.DOCKER_START, async () => {
Expand Down Expand Up @@ -342,11 +306,19 @@ export function registerIpcHandlers(): void {
);

ipcMain.handle(IPC.NODES_BACKUP_MNEMONIC, async (_e, nodeId: string, mnemonic: string) => {
const id = vUUID(nodeId, 'node id');
if (typeof mnemonic !== 'string' || !mnemonic.trim()) {
return { ok: false, error: 'No mnemonic to back up.' };
}
if (!safeStorage.isEncryptionAvailable()) {
return { ok: false, error: 'OS keychain unavailable — cannot back up.' };
}
const blob = safeStorage.encryptString(mnemonic).toString('base64');
if (!blob) {
return { ok: false, error: 'Encryption produced an empty blob — backup aborted.' };
}
const store = await readStore();
store.nodeBackups[nodeId] = safeStorage.encryptString(mnemonic).toString('base64');
store.nodeBackups[id] = blob;
await writeStore(store);
return { ok: true };
});
Expand Down Expand Up @@ -382,8 +354,9 @@ export function registerIpcHandlers(): void {
);

ipcMain.handle(IPC.NODES_REVEAL_MNEMONIC, async (_e, nodeId: string) => {
const id = vUUID(nodeId, 'node id');
const store = await readStore();
const blob = store.nodeBackups[nodeId];
const blob = store.nodeBackups[id];
if (!blob) {
return {
ok: false,
Expand Down Expand Up @@ -455,7 +428,10 @@ export function registerIpcHandlers(): void {
{
detached: true,
stdio: 'ignore',
windowsHide: true,
// Must stay false: this command's whole purpose is to surface a
// visible PowerShell console. windowsHide:true can propagate
// CREATE_NO_WINDOW to the start-launched grandchild on some builds.
windowsHide: false,
},
);
child.on('error', (err) => log.warn('cli powershell spawn error', { err: String(err) }));
Expand Down Expand Up @@ -520,14 +496,23 @@ async function exportDiagnostics(targetZip: string): Promise<void> {
);
zip.addFile('store.json', Buffer.from(JSON.stringify(sanitizedStore, null, 2)));
zip.addFile('settings.json', Buffer.from(JSON.stringify(settings, null, 2)));
let files: string[] = [];
try {
const files = await fs.readdir(logDir());
for (const f of files) {
files = await fs.readdir(logDir());
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
log.warn('exportDiagnostics: could not read log dir', { err: String(err) });
}
}
for (const f of files) {
try {
const contents = await fs.readFile(path.join(logDir(), f));
zip.addFile(`logs/${f}`, contents);
} catch (err) {
// One unreadable/locked log file must not abort the whole bundle.
log.warn('exportDiagnostics: skipped log file', { file: f, err: String(err) });
}
} catch {
/* no logs yet */
}
zip.writeZip(targetZip);
}
Expand Down
15 changes: 13 additions & 2 deletions src/main/services/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ export const DEFAULT_RPC_POOL: readonly string[] = [
];

export const udvpnToDvpn = (u: string | number | bigint): number => {
const n = typeof u === 'bigint' ? Number(u) : Number(u);
return n / 1_000_000;
// For bigint (and integer-string) inputs, divide in integer space first so
// we don't blow past Number.MAX_SAFE_INTEGER before the /1e6. The original
// ternary collapsed both branches to Number(u) — a no-op that lost precision
// for balances above ~9e15 udvpn (~9e9 P2P).
if (typeof u === 'bigint') {
const whole = u / 1_000_000n;
const frac = Number(u % 1_000_000n) / 1_000_000;
return Number(whole) + frac;
}
if (typeof u === 'string' && /^-?\d+$/.test(u.trim())) {
return udvpnToDvpn(BigInt(u.trim()));
}
return Number(u) / 1_000_000;
};

export const dvpnToUdvpn = (d: number): string =>
Expand Down
23 changes: 20 additions & 3 deletions src/main/services/cli-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from '../../shared/types';
import { testSSHConnection } from './ssh';
import { forgetHostKey } from './host-keys';
import { vSSHCredentials } from '../validate';
import {
startDeploy,
cancelDeploy,
Expand Down Expand Up @@ -497,14 +498,17 @@ export const MAIN_COMMANDS: MainCliCommand[] = [
{ name: 'passphrase', kind: 'flag', describe: 'Key passphrase.' },
],
exec: (p) => {
const creds: SSHCredentials = {
// M-13: validate through the same checker the IPC path uses, instead of
// constructing SSHCredentials raw. Catches bad hosts/ports/usernames and
// bounds the credential field sizes before they reach the ssh2 client.
const creds = vSSHCredentials({
host: requireFlag(p, 'host'),
port: numberFlag(p, 'port') ?? 22,
username: requireFlag(p, 'username'),
password: optionalFlag(p, 'password'),
privateKey: optionalFlag(p, 'privateKey'),
passphrase: optionalFlag(p, 'passphrase'),
};
});
return testSSHConnection(creds);
},
},
Expand Down Expand Up @@ -550,6 +554,19 @@ export const MAIN_COMMANDS: MainCliCommand[] = [
if (!isVpnServiceType(service))
throw new Error('--service must be "wireguard" or "v2ray"');
const sshRaw = optionalFlag(p, 'ssh');
// M-13: a remote deploy's SSH blob arrives as a raw JSON string from the
// CLI. Parse then validate through the shared checker — the same gate the
// IPC DEPLOY_START handler applies — so malformed creds can't slip past.
let ssh: SSHCredentials | undefined;
if (sshRaw) {
let parsed: unknown;
try {
parsed = JSON.parse(sshRaw);
} catch {
throw new Error('--ssh must be valid JSON for SSHCredentials');
}
ssh = vSSHCredentials(parsed);
}
const req: DeployRequest = {
target,
moniker: requireFlag(p, 'moniker'),
Expand All @@ -558,7 +575,7 @@ export const MAIN_COMMANDS: MainCliCommand[] = [
serviceType: service,
port: numberFlag(p, 'port', true) as number,
remoteUrl: optionalFlag(p, 'remoteUrl'),
ssh: sshRaw ? (JSON.parse(sshRaw) as SSHCredentials) : undefined,
ssh,
};
await primeDeploySettings();
return startDeploy(req, () => {
Expand Down
Loading
Loading