mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-30 10:35:00 +00:00
4.5.0 taught the server to make its certificate itself, but Start Haven.bat, Install Haven.ps1 and the web installer still went looking for openssl.exe first and reported a skipped certificate as done. They now call the same generator through scripts/gen-cert.js, the certificate carries the CA and server-auth flags phones expect when you import it, and the guide and support page stop telling people to install OpenSSL. Reported by MutantRabbit767.
318 lines
13 KiB
JavaScript
318 lines
13 KiB
JavaScript
// ═══════════════════════════════════════════════════════════
|
|
// Haven — Web-based Installer Server
|
|
// Uses ONLY Node.js built-in modules (no npm install needed)
|
|
// ═══════════════════════════════════════════════════════════
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawn, execSync } = require('child_process');
|
|
const os = require('os');
|
|
|
|
const HAVEN_DIR = path.resolve(__dirname, '..');
|
|
const IS_WIN = process.platform === 'win32';
|
|
const DATA_DIR = IS_WIN
|
|
? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Haven')
|
|
: path.join(os.homedir(), '.haven');
|
|
|
|
// ── Serve the installer HTML ──────────────────────────────
|
|
const indexHtml = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
|
|
|
|
// ── HTTP Server ───────────────────────────────────────────
|
|
const server = http.createServer((req, res) => {
|
|
// Only allow localhost connections
|
|
const host = req.socket.remoteAddress;
|
|
if (host !== '127.0.0.1' && host !== '::1' && host !== '::ffff:127.0.0.1') {
|
|
res.writeHead(403); res.end('Forbidden'); return;
|
|
}
|
|
|
|
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
res.end(indexHtml);
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/api/check') {
|
|
let nodeVersion = '';
|
|
try { nodeVersion = process.version; } catch {}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
platform: process.platform,
|
|
nodeVersion,
|
|
dataDir: DATA_DIR,
|
|
alreadyInstalled: fs.existsSync(path.join(DATA_DIR, '.tunnel_configured'))
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/install') {
|
|
let body = '';
|
|
req.on('data', c => body += c);
|
|
req.on('end', () => {
|
|
let config;
|
|
try { config = JSON.parse(body); } catch {
|
|
res.writeHead(400); res.end('Bad JSON'); return;
|
|
}
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
});
|
|
runInstall(config, res);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/launch') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end('{"ok":true}');
|
|
launchHaven();
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404); res.end('Not found');
|
|
});
|
|
|
|
// ── Pick a random available port and start ────────────────
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const port = server.address().port;
|
|
const url = `http://127.0.0.1:${port}`;
|
|
console.log(`\n Haven Installer running at ${url}\n`);
|
|
|
|
// Open browser
|
|
try {
|
|
if (IS_WIN) {
|
|
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
|
|
} else if (process.platform === 'darwin') {
|
|
spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
} else {
|
|
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
}
|
|
} catch {
|
|
console.log(` Open this URL in your browser: ${url}`);
|
|
}
|
|
});
|
|
|
|
// ── SSE helper ────────────────────────────────────────────
|
|
function send(res, data) {
|
|
try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}
|
|
}
|
|
|
|
// ── Run a command and return a promise ────────────────────
|
|
function run(cmd, args, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(cmd, args, {
|
|
cwd: opts.cwd || HAVEN_DIR,
|
|
env: { ...process.env, ...opts.env },
|
|
shell: IS_WIN,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
let out = '', err = '';
|
|
child.stdout.on('data', d => out += d);
|
|
child.stderr.on('data', d => err += d);
|
|
child.on('close', code => {
|
|
if (code === 0) resolve(out);
|
|
else reject(new Error(err || out || `Exit code ${code}`));
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
}
|
|
|
|
// ── Installation pipeline ─────────────────────────────────
|
|
async function runInstall(config, res) {
|
|
const { serverName, adminUser, adminPass, tunnel } = config;
|
|
let hasError = false;
|
|
|
|
try {
|
|
// ── Step 1: Install npm dependencies ──
|
|
send(res, { step: 'deps', state: 'active', label: 'Installing dependencies\u2026', progress: 5 });
|
|
try {
|
|
const npmCmd = IS_WIN ? 'npm.cmd' : 'npm';
|
|
const npmArgs = ['install', '--no-audit', '--no-fund'];
|
|
if (tunnel === 'localtunnel') npmArgs.push('localtunnel');
|
|
await run(npmCmd, npmArgs);
|
|
send(res, { step: 'deps', state: 'done', label: 'Dependencies installed', progress: 35 });
|
|
} catch (e) {
|
|
send(res, { step: 'deps', state: 'error', label: 'npm install failed: ' + e.message, progress: 35 });
|
|
hasError = true;
|
|
}
|
|
|
|
// ── Step 2: Create data directory ──
|
|
send(res, { step: 'datadir', state: 'active', label: 'Creating data directory\u2026', progress: 40 });
|
|
try {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const envExample = path.join(HAVEN_DIR, '.env.example');
|
|
const envDest = path.join(DATA_DIR, '.env');
|
|
if (fs.existsSync(envExample) && !fs.existsSync(envDest)) {
|
|
let envContent = fs.readFileSync(envExample, 'utf8');
|
|
if (adminUser) envContent = envContent.replace(/ADMIN_USERNAME=.*/,'ADMIN_USERNAME=' + adminUser);
|
|
envContent += '\nFCM_RELAY_URL=https://us-central1-amni-haven.cloudfunctions.net/sendPush\n';
|
|
envContent += 'FCM_PUSH_KEY=YOUR_GLOBAL_SECRET_KEY_HERE\n';
|
|
fs.writeFileSync(envDest, envContent);
|
|
}
|
|
send(res, { step: 'datadir', state: 'done', label: 'Data directory ready', progress: 45 });
|
|
} catch (e) {
|
|
send(res, { step: 'datadir', state: 'error', label: 'Data dir failed: ' + e.message, progress: 45 });
|
|
hasError = true;
|
|
}
|
|
|
|
// ── Step 3: Generate SSL certificate ──
|
|
send(res, { step: 'ssl', state: 'active', label: 'Generating SSL certificate\u2026', progress: 50 });
|
|
const certDir = path.join(DATA_DIR, 'certs');
|
|
const certPath = path.join(certDir, 'cert.pem');
|
|
if (fs.existsSync(certPath)) {
|
|
send(res, { step: 'ssl', state: 'done', label: 'SSL certificate exists', progress: 60 });
|
|
} else {
|
|
try {
|
|
fs.mkdirSync(certDir, { recursive: true });
|
|
require('../src/selfsignedCert').ensureCerts(certDir);
|
|
send(res, { step: 'ssl', state: 'done', label: 'SSL certificate generated', progress: 60 });
|
|
} catch (err) {
|
|
send(res, { step: 'ssl', state: 'error', label: `SSL generation failed: ${err.message}`, progress: 60 });
|
|
}
|
|
}
|
|
|
|
// ── Step 4: Configure server ──
|
|
send(res, { step: 'config', state: 'active', label: 'Configuring server\u2026', progress: 65 });
|
|
try {
|
|
const tunnelEnabled = (tunnel === 'cloudflare' || tunnel === 'localtunnel') ? 'true' : 'false';
|
|
const tunnelProvider = tunnel === 'cloudflare' ? 'cloudflared' :
|
|
tunnel === 'localtunnel' ? 'localtunnel' : '';
|
|
|
|
// Build a configuration script that uses the project's own database module
|
|
// Write to a temp file to avoid shell escaping issues on Windows
|
|
const configScript = `
|
|
const { initDatabase, getDb } = require('./src/database');
|
|
initDatabase();
|
|
const db = getDb();
|
|
db.prepare("INSERT OR REPLACE INTO server_settings(key,value) VALUES('server_name',?)").run(${JSON.stringify(serverName || 'Haven')});
|
|
db.prepare("INSERT OR REPLACE INTO server_settings(key,value) VALUES('tunnel_enabled',?)").run(${JSON.stringify(tunnelEnabled)});
|
|
db.prepare("INSERT OR REPLACE INTO server_settings(key,value) VALUES('tunnel_provider',?)").run(${JSON.stringify(tunnelProvider)});
|
|
${adminUser && adminPass ? `
|
|
const bcrypt = require('bcryptjs');
|
|
const hash = bcrypt.hashSync(${JSON.stringify(adminPass)}, 12);
|
|
const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(${JSON.stringify(adminUser)});
|
|
if (!existing) {
|
|
db.prepare("INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)").run(${JSON.stringify(adminUser)}, hash);
|
|
console.log('Admin account created');
|
|
} else {
|
|
db.prepare("UPDATE users SET is_admin = 1, password_hash = ? WHERE id = ?").run(hash, existing.id);
|
|
console.log('Existing user promoted to admin and password updated');
|
|
}
|
|
` : ''}
|
|
`;
|
|
|
|
const tmpScript = path.join(HAVEN_DIR, `haven-config-${Date.now()}.js`);
|
|
fs.writeFileSync(tmpScript, configScript);
|
|
try {
|
|
await run('node', [tmpScript], { cwd: HAVEN_DIR });
|
|
send(res, { step: 'config', state: 'done', label: 'Server configured', progress: 80 });
|
|
try { fs.unlinkSync(tmpScript); } catch {}
|
|
} catch (runErr) {
|
|
throw new Error(runErr.message || 'Run failed');
|
|
}
|
|
} catch (e) {
|
|
send(res, { step: 'config', state: 'error', label: 'Config failed: ' + e.message, progress: 80 });
|
|
hasError = true;
|
|
}
|
|
|
|
// Mark tunnel as configured
|
|
try {
|
|
fs.writeFileSync(path.join(DATA_DIR, '.tunnel_configured'), 'configured');
|
|
} catch {}
|
|
|
|
// ── Step 5: Create shortcuts (Windows only) ──
|
|
send(res, { step: 'shortcuts', state: 'active', label: 'Creating shortcuts\u2026', progress: 85 });
|
|
if (IS_WIN) {
|
|
try {
|
|
const desktop = path.join(os.homedir(), 'Desktop');
|
|
const startMenu = path.join(process.env.APPDATA, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
|
|
const batPath = path.join(HAVEN_DIR, 'Start Haven.bat');
|
|
|
|
const psScript = `
|
|
$shell = New-Object -ComObject WScript.Shell
|
|
$lnk = $shell.CreateShortcut('${desktop.replace(/'/g, "''")}\\\Haven.lnk')
|
|
$lnk.TargetPath = '${batPath.replace(/'/g, "''")}'
|
|
$lnk.WorkingDirectory = '${HAVEN_DIR.replace(/'/g, "''")}'
|
|
$lnk.Description = 'Launch Haven server'
|
|
$lnk.Save()
|
|
$lnk2 = $shell.CreateShortcut('${startMenu.replace(/'/g, "''")}\\\Haven.lnk')
|
|
$lnk2.TargetPath = '${batPath.replace(/'/g, "''")}'
|
|
$lnk2.WorkingDirectory = '${HAVEN_DIR.replace(/'/g, "''")}'
|
|
$lnk2.Description = 'Launch Haven server'
|
|
$lnk2.Save()
|
|
`.replace(/\n/g, '; ');
|
|
|
|
await run('powershell.exe', ['-NoProfile', '-Command', psScript]);
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Desktop & Start Menu shortcuts created', progress: 95 });
|
|
} catch {
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Shortcuts skipped', progress: 95 });
|
|
}
|
|
} else {
|
|
// Linux: create a .desktop file if the desktop directory exists
|
|
try {
|
|
const desktopDir = path.join(os.homedir(), 'Desktop');
|
|
const appsDir = path.join(os.homedir(), '.local', 'share', 'applications');
|
|
const startSh = path.join(HAVEN_DIR, 'start.sh');
|
|
|
|
const desktopEntry = [
|
|
'[Desktop Entry]',
|
|
'Type=Application',
|
|
'Name=Haven',
|
|
'Comment=Launch Haven private chat server',
|
|
`Exec=bash "${startSh}"`,
|
|
`Path=${HAVEN_DIR}`,
|
|
'Terminal=true',
|
|
'Categories=Network;Chat;',
|
|
''
|
|
].join('\n');
|
|
|
|
if (fs.existsSync(desktopDir)) {
|
|
const dFile = path.join(desktopDir, 'Haven.desktop');
|
|
fs.writeFileSync(dFile, desktopEntry);
|
|
try { fs.chmodSync(dFile, 0o755); } catch {}
|
|
}
|
|
fs.mkdirSync(appsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(appsDir, 'Haven.desktop'), desktopEntry);
|
|
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Application shortcut created', progress: 95 });
|
|
} catch {
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Shortcuts skipped', progress: 95 });
|
|
}
|
|
}
|
|
|
|
send(res, { progress: 100, done: true, error: hasError });
|
|
} catch (e) {
|
|
send(res, { done: true, error: true, message: e.message });
|
|
}
|
|
res.end();
|
|
}
|
|
|
|
// ── Launch Haven server ───────────────────────────────────
|
|
function launchHaven() {
|
|
setTimeout(() => {
|
|
if (IS_WIN) {
|
|
// Launch Start Haven.bat in a new console window
|
|
const batPath = path.join(HAVEN_DIR, 'Start Haven.bat');
|
|
spawn('cmd', ['/c', 'start', '', 'cmd', '/c', batPath], {
|
|
cwd: HAVEN_DIR,
|
|
stdio: 'ignore',
|
|
detached: true
|
|
}).unref();
|
|
} else {
|
|
// Launch start.sh
|
|
const shPath = path.join(HAVEN_DIR, 'start.sh');
|
|
spawn('bash', [shPath], {
|
|
cwd: HAVEN_DIR,
|
|
stdio: 'ignore',
|
|
detached: true
|
|
}).unref();
|
|
}
|
|
|
|
// Shut down installer server after a brief delay
|
|
setTimeout(() => {
|
|
server.close();
|
|
process.exit(0);
|
|
}, 2000);
|
|
}, 500);
|
|
}
|