387 lines
13 KiB
JavaScript
387 lines
13 KiB
JavaScript
const dgram = require('dgram');
|
|
const dnsPacket = require('dns-packet');
|
|
const net = require('net');
|
|
const { logDebug, logWarn, logError, logInfo } = require('./logger');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const state = require('./state');
|
|
|
|
// Check port responsive
|
|
function checkPortResponsive(ip, port) {
|
|
return new Promise((resolve) => {
|
|
const client = new net.Socket();
|
|
client.setTimeout(parseSecondsToMs(process.env.PORT_CHECK_TIMEOUT || '2'));
|
|
client.connect(port, ip,() => {
|
|
client.destroy();
|
|
resolve(true);
|
|
});
|
|
client.on('error', () => resolve(false));
|
|
client.on('timeout', () => {
|
|
client.destroy();
|
|
resolve(false);
|
|
});
|
|
});
|
|
}
|
|
// Check public DNS with failover support
|
|
function checkPublicDNS(domain) {
|
|
return new Promise(async (resolve) => {
|
|
// Parse comma-separated DNS servers
|
|
const publicDnsServerEnv = process.env.PUBLIC_DNS_SERVER || '1.1.1.1';
|
|
const dnsServers = publicDnsServerEnv.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
|
|
// Try each DNS server in order (failover strategy)
|
|
for (let serverIndex = 0; serverIndex < dnsServers.length; serverIndex++) {
|
|
const publicDNSServer = dnsServers[serverIndex];
|
|
const isLastServer = serverIndex === dnsServers.length - 1;
|
|
|
|
const result = await new Promise((innerResolve) => {
|
|
const resolver = dgram.createSocket('udp4');
|
|
let resolved = false;
|
|
|
|
const cleanup = () => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
try {
|
|
resolver.removeAllListeners();
|
|
resolver.close();
|
|
} catch (err) {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
};
|
|
|
|
const timeout = setTimeout(() => {
|
|
logWarn('Utils', `DNS query timeout for ${domain} on server ${publicDNSServer}`);
|
|
cleanup();
|
|
innerResolve(null);
|
|
}, 5000);
|
|
|
|
resolver.on('message', (res) => {
|
|
if (resolved) return;
|
|
clearTimeout(timeout);
|
|
try {
|
|
const response = dnsPacket.decode(res);
|
|
if (response.answers.length > 0) {
|
|
logInfo('Utils', `Public DNS returned records for ${domain} from ${publicDNSServer}`);
|
|
cleanup();
|
|
innerResolve(response.answers);
|
|
} else {
|
|
cleanup();
|
|
innerResolve(null);
|
|
}
|
|
} catch (err) {
|
|
logError('Utils', `Error decoding DNS response from ${publicDNSServer}: ${err.message}`);
|
|
cleanup();
|
|
innerResolve(null);
|
|
}
|
|
});
|
|
|
|
resolver.on('error', (err) => {
|
|
if (resolved) return;
|
|
clearTimeout(timeout);
|
|
logWarn('Utils', `Error receiving DNS response from ${publicDNSServer}: ${err.message}`);
|
|
cleanup();
|
|
innerResolve(null);
|
|
});
|
|
|
|
const query = dnsPacket.encode({
|
|
type: 'query',
|
|
id: Math.floor(Math.random() * 65535),
|
|
questions: [{ type: 'A', name: domain }]
|
|
});
|
|
|
|
resolver.send(query, 53, publicDNSServer, (err) => {
|
|
if (err) {
|
|
clearTimeout(timeout);
|
|
logWarn('Utils', `Error forwarding DNS query to ${publicDNSServer}: ${err.message}`);
|
|
cleanup();
|
|
innerResolve(null);
|
|
}
|
|
});
|
|
});
|
|
|
|
// If we got a successful response, return it
|
|
if (result) {
|
|
return resolve(result);
|
|
}
|
|
|
|
// If this was the last server, return null
|
|
if (isLastServer) {
|
|
logWarn('Utils', `All DNS servers failed for ${domain}`);
|
|
return resolve(null);
|
|
}
|
|
|
|
// Otherwise, try next server
|
|
logDebug('Utils', `DNS server ${publicDNSServer} failed, trying next server...`);
|
|
}
|
|
|
|
resolve(null);
|
|
});
|
|
}
|
|
|
|
async function setupCache() {
|
|
const cacheDir = './cache';
|
|
const cacheFiles = [
|
|
'domains.json',
|
|
'holesail_clients.json',
|
|
'holesail_servers.json',
|
|
'local_dns.json',
|
|
'selector_cache.json'
|
|
];
|
|
|
|
try {
|
|
// Check if cache directory exists, create if it doesn't
|
|
await fs.access(cacheDir).catch(async () => {
|
|
await fs.mkdir(cacheDir);
|
|
logInfo('Utils', 'Created cache directory');
|
|
});
|
|
|
|
// Create empty JSON files if they don't exist
|
|
for (const file of cacheFiles) {
|
|
const filePath = path.join(cacheDir, file);
|
|
try {
|
|
await fs.access(filePath);
|
|
} catch {
|
|
await fs.writeFile(filePath, '{}');
|
|
logInfo('Utils', `Created ${file}`);
|
|
}
|
|
}
|
|
logInfo('Utils', `Cache setup complete`);
|
|
|
|
} catch (error) {
|
|
logError('Utils', `Error setting up cache`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a time value that can be in seconds or minutes
|
|
* Accepts formats: "60", "60s", "1m", "1.5m", etc.
|
|
* Returns milliseconds
|
|
* @param {string|number} value - Time value to parse
|
|
* @param {string} defaultUnit - Default unit if no suffix ('s' for seconds, 'm' for minutes)
|
|
* @returns {number} - Time in milliseconds
|
|
*/
|
|
function parseTimeToMs(value, defaultUnit = 's') {
|
|
if (typeof value === 'number') {
|
|
// If it's a number, assume it's already in the default unit
|
|
return defaultUnit === 'm' ? value * 60 * 1000 : value * 1000;
|
|
}
|
|
|
|
if (typeof value !== 'string') {
|
|
throw new Error(`Invalid time value: ${value}`);
|
|
}
|
|
|
|
const trimmed = value.trim().toLowerCase();
|
|
|
|
// Check for explicit unit suffix
|
|
if (trimmed.endsWith('s')) {
|
|
const num = parseFloat(trimmed.slice(0, -1));
|
|
if (isNaN(num)) throw new Error(`Invalid time value: ${value}`);
|
|
return num * 1000; // seconds to ms
|
|
}
|
|
|
|
if (trimmed.endsWith('m')) {
|
|
const num = parseFloat(trimmed.slice(0, -1));
|
|
if (isNaN(num)) throw new Error(`Invalid time value: ${value}`);
|
|
return num * 60 * 1000; // minutes to ms
|
|
}
|
|
|
|
// No suffix, use default unit
|
|
const num = parseFloat(trimmed);
|
|
if (isNaN(num)) throw new Error(`Invalid time value: ${value}`);
|
|
return defaultUnit === 'm' ? num * 60 * 1000 : num * 1000;
|
|
}
|
|
|
|
/**
|
|
* Parse time as seconds (converts to milliseconds)
|
|
* @param {string|number} value - Time in seconds
|
|
* @returns {number} - Time in milliseconds
|
|
*/
|
|
function parseSecondsToMs(value) {
|
|
return parseTimeToMs(value, 's');
|
|
}
|
|
|
|
/**
|
|
* Parse time as minutes (converts to milliseconds)
|
|
* @param {string|number} value - Time in minutes
|
|
* @returns {number} - Time in milliseconds
|
|
*/
|
|
function parseMinutesToMs(value) {
|
|
return parseTimeToMs(value, 'm');
|
|
}
|
|
|
|
/**
|
|
* Convert seconds to milliseconds
|
|
* @param {number} seconds - Time in seconds
|
|
* @returns {number} - Time in milliseconds
|
|
*/
|
|
function secondsToMs(seconds) {
|
|
return seconds * 1000;
|
|
}
|
|
|
|
/**
|
|
* Convert minutes to milliseconds
|
|
* @param {number} minutes - Time in minutes
|
|
* @returns {number} - Time in milliseconds
|
|
*/
|
|
function minutesToMs(minutes) {
|
|
return minutes * 60 * 1000;
|
|
}
|
|
|
|
/**
|
|
* Get the persistent hyperswarm public key as a hex string
|
|
* This is the key that should be used for claim records and voting
|
|
* @returns {string|null} - The persistent public key in hex format, or null if not available
|
|
*/
|
|
function getPersistentPublicKey() {
|
|
// First check if already loaded in state (fast path)
|
|
if (state.keypair && state.keypair.publicKey) {
|
|
return state.keypair.publicKey.toString('hex');
|
|
}
|
|
|
|
// Read directly from file - this is the reliable source
|
|
// Use absolute path from process.cwd() to ensure we find the file regardless of where the code is called from
|
|
try {
|
|
const fsSync = require('fs');
|
|
// Try multiple possible paths to handle different execution contexts
|
|
const possiblePaths = [
|
|
path.join(process.cwd(), 'cache', 'keypair.json'),
|
|
path.join(__dirname, '..', '..', 'cache', 'keypair.json'),
|
|
'./cache/keypair.json'
|
|
];
|
|
|
|
let keypairData = null;
|
|
let keypairPath = null;
|
|
|
|
for (const tryPath of possiblePaths) {
|
|
try {
|
|
const resolvedPath = path.resolve(tryPath);
|
|
if (fsSync.existsSync(resolvedPath)) {
|
|
keypairData = fsSync.readFileSync(resolvedPath, 'utf8');
|
|
keypairPath = resolvedPath;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
// Try next path
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!keypairData) {
|
|
logError('Utils', 'Could not find keypair.json file in any expected location');
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(keypairData);
|
|
if (!parsed || !parsed.publicKey || typeof parsed.publicKey !== 'string') {
|
|
logError('Utils', 'Invalid keypair file format');
|
|
return null;
|
|
}
|
|
|
|
// Store in state for future use
|
|
if (!state.keypair && parsed.secretKey) {
|
|
try {
|
|
state.keypair = {
|
|
publicKey: Buffer.from(parsed.publicKey, 'hex'),
|
|
secretKey: Buffer.from(parsed.secretKey, 'hex')
|
|
};
|
|
} catch (err) {
|
|
logWarn('Utils', `Could not parse keypair buffers: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
logDebug('Utils', `Loaded persistent public key from file: ${keypairPath}`);
|
|
return parsed.publicKey; // Already in hex format from JSON
|
|
} catch (err) {
|
|
logError('Utils', `Failed to read persistent public key: ${err.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load or create a Hyperswarm keypair, persisting it to cache/keypair.json
|
|
* @returns {Promise<{publicKey: Buffer, secretKey: Buffer}>} - The keypair object
|
|
*/
|
|
async function loadOrCreateKeypair() {
|
|
const keypairPath = path.join('./cache', 'keypair.json');
|
|
|
|
try {
|
|
// Try to load existing keypair
|
|
const keypairData = await fs.readFile(keypairPath, 'utf8');
|
|
const parsed = JSON.parse(keypairData);
|
|
|
|
// Validate the keypair structure
|
|
if (!parsed.publicKey || !parsed.secretKey) {
|
|
throw new Error('Invalid keypair format in cache file');
|
|
}
|
|
|
|
const keypair = {
|
|
publicKey: Buffer.from(parsed.publicKey, 'hex'),
|
|
secretKey: Buffer.from(parsed.secretKey, 'hex')
|
|
};
|
|
|
|
// Validate buffer sizes (Ed25519: 32 bytes public, 64 bytes secret)
|
|
if (keypair.publicKey.length !== 32 || keypair.secretKey.length !== 64) {
|
|
throw new Error('Invalid keypair buffer sizes');
|
|
}
|
|
|
|
logInfo('Utils', 'Loaded existing Hyperswarm keypair from cache');
|
|
return keypair;
|
|
} catch (err) {
|
|
// File doesn't exist or is invalid, generate a new keypair
|
|
if (err.code === 'ENOENT' || err.message.includes('Invalid')) {
|
|
logInfo('Utils', 'Generating new Hyperswarm keypair');
|
|
|
|
// Generate Ed25519 keypair directly using sodium-native to avoid creating
|
|
// any Hyperswarm instances that might interfere with network initialization
|
|
const sodium = require('sodium-native');
|
|
const publicKey = Buffer.allocUnsafe(32);
|
|
const secretKey = Buffer.allocUnsafe(64);
|
|
|
|
// Generate keypair (secretKey contains both secret and public key)
|
|
sodium.crypto_sign_keypair(publicKey, secretKey);
|
|
|
|
const keypair = {
|
|
publicKey: publicKey,
|
|
secretKey: secretKey
|
|
};
|
|
|
|
// Ensure cache directory exists
|
|
const cacheDir = './cache';
|
|
try {
|
|
await fs.access(cacheDir);
|
|
} catch {
|
|
await fs.mkdir(cacheDir);
|
|
logInfo('Utils', 'Created cache directory for keypair');
|
|
}
|
|
|
|
// Save the keypair to file
|
|
const keypairData = {
|
|
publicKey: keypair.publicKey.toString('hex'),
|
|
secretKey: keypair.secretKey.toString('hex')
|
|
};
|
|
|
|
await fs.writeFile(keypairPath, JSON.stringify(keypairData, null, 2));
|
|
logInfo('Utils', 'Saved new Hyperswarm keypair to cache');
|
|
|
|
return keypair;
|
|
} else {
|
|
// Unexpected error, rethrow
|
|
logError('Utils', `Error loading keypair: ${err.message}`);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
checkPortResponsive,
|
|
checkPublicDNS,
|
|
setupCache,
|
|
parseTimeToMs,
|
|
parseSecondsToMs,
|
|
parseMinutesToMs,
|
|
secondsToMs,
|
|
minutesToMs,
|
|
loadOrCreateKeypair,
|
|
getPersistentPublicKey
|
|
}; |