import { randomUUID } from 'node:crypto';
import { existsSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { promises as dnsPromises } from 'node:dns';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { isIP } from 'node:net';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
// A deployment URL has to be a real internet-resolvable HTTPS endpoint
// because the security model is "TLS in front via Let's Encrypt + reverse
// proxy". Anything else (raw IP, http, localhost, .local) cannot get a
// public cert and is rejected at the prompt so the operator does not silently
// end up with an unencrypted exposed setup.
export function validateDeploymentUrl(url) {
    let parsed;
    try {
        parsed = new URL(url);
    }
    catch {
        return { ok: false, reason: 'not a valid URL' };
    }
    if (parsed.protocol !== 'https:') {
        return { ok: false, reason: 'must use https://' };
    }
    const host = parsed.hostname.toLowerCase();
    if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
        return { ok: false, reason: 'use a domain name, not an IP — Let’s Encrypt does not issue certs for raw IPs' };
    }
    if (host.startsWith('[') || host.includes(':')) {
        return { ok: false, reason: 'use a domain name, not an IPv6 literal' };
    }
    if (host === 'localhost' || host.endsWith('.local')) {
        return { ok: false, reason: 'use a public DNS domain, not localhost / .local' };
    }
    if (!host.includes('.')) {
        return { ok: false, reason: 'use a fully qualified domain (e.g. terminal.example.test)' };
    }
    return { ok: true };
}
export function normalizeFirewallSource(value) {
    const trimmed = value.trim();
    if (!trimmed) {
        return null;
    }
    const parts = trimmed.split('/');
    if (parts.length > 2) {
        return null;
    }
    const address = parts[0] ?? '';
    const version = isIP(address);
    if (version === 0) {
        return null;
    }
    if (parts.length === 1) {
        return address;
    }
    const prefixText = parts[1] ?? '';
    if (!/^\d+$/.test(prefixText)) {
        return null;
    }
    const prefix = Number(prefixText);
    const maxPrefix = version === 4 ? 32 : 128;
    // /0 would allow the whole internet, which defeats the purpose of the
    // secondary firewall restriction. Operators can still skip restriction and
    // use the legacy broad allow path explicitly.
    if (!Number.isInteger(prefix) || prefix < 1 || prefix > maxPrefix) {
        return null;
    }
    return `${address}/${prefix}`;
}
export function analyzeUfwRestrictedStatus(output, port, source) {
    const rules = parseUfwRules(output, port, source);
    const sourceAllowIndex = rules.find((rule) => rule.action === 'allow' && rule.sourceMatch)?.order;
    const denyIndex = rules.find((rule) => rule.action === 'deny')?.order;
    const hasBroadAllowBeforeDeny = denyIndex !== undefined &&
        rules.some((rule) => rule.action === 'allow' && rule.broad && rule.order < denyIndex);
    return {
        restricted: sourceAllowIndex !== undefined && denyIndex !== undefined && sourceAllowIndex < denyIndex && !hasBroadAllowBeforeDeny,
        hasSourceAllow: sourceAllowIndex !== undefined,
        hasDeny: denyIndex !== undefined,
        hasBroadAllowBeforeDeny
    };
}
export function manualFirewallCommands(kind, port, source) {
    if (kind === 'ufw') {
        return source
            ? [
                'sudo ufw status numbered',
                `sudo ufw insert 1 allow from ${source} to any port ${port} proto tcp`,
                `sudo ufw insert 2 deny to any port ${port} proto tcp`
            ]
            : ['sudo ufw status numbered', `sudo ufw allow ${port}/tcp`];
    }
    const allowRule = source ? firewalldAllowRichRule(port, source) : '';
    const dropRule = source ? firewalldDropRichRule(port, source) : '';
    return source
        ? [
            'sudo firewall-cmd --state',
            'sudo firewall-cmd --list-ports',
            `sudo firewall-cmd --permanent --add-rich-rule='${allowRule}'`,
            `sudo firewall-cmd --permanent --add-rich-rule='${dropRule}'`,
            'sudo firewall-cmd --reload',
            `if sudo firewall-cmd --list-ports | tr ' ' '\\n' | grep -qx '${port}/tcp'; then sudo firewall-cmd --permanent --remove-port=${port}/tcp && sudo firewall-cmd --reload; fi`
        ]
        : ['sudo firewall-cmd --state', 'sudo firewall-cmd --list-ports', `sudo firewall-cmd --permanent --add-port=${port}/tcp`, 'sudo firewall-cmd --reload'];
}
// Best-effort guess at the DNS record's "Name" field at the provider's UI.
// For "terminal.example.test" we return "terminal"; for the apex "example.test" we return
// "@". We do NOT try to handle ccTLDs (terminal.example.co.uk) correctly — the
// Public Suffix List is out of scope; the printed instructions tell the
// operator to double-check.
export function dnsRecordName(domain) {
    const parts = domain.toLowerCase().split('.').filter(Boolean);
    if (parts.length <= 2) {
        return '@';
    }
    return parts.slice(0, -2).join('.');
}
export function generateNginxConfig(domain, port) {
    return `# NTerminal — generated by onboarding for ${domain}
# Run \`sudo certbot --nginx -d ${domain}\` after the site is reachable on :80
# to layer HTTPS on top of this server block. certbot edits this file in
# place; rerunning onboarding will skip overwriting once the file exists.
server {
    listen 80;
    listen [::]:80;
    server_name ${domain};

    location / {
        proxy_pass http://127.0.0.1:${port};
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        # Long timeout so terminal WebSockets do not idle out.
        proxy_read_timeout 86400;
        proxy_send_timeout 86400;
    }
}
`;
}
// Order matters: Debian-style sites-available/sites-enabled is checked first
// because conf.d exists on Debian too (for global tweaks) but the per-site
// convention is sites-available.
const NGINX_LAYOUT_CANDIDATES = [
    { confDir: '/etc/nginx/sites-available', enabledDir: '/etc/nginx/sites-enabled', family: 'debian' },
    { confDir: '/etc/nginx/conf.d', family: 'rhel-mac' },
    { confDir: '/usr/local/etc/nginx/servers', family: 'rhel-mac' },
    { confDir: '/opt/homebrew/etc/nginx/servers', family: 'rhel-mac' }
];
function whichBinary(cmd) {
    const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: ['ignore', 'pipe', 'ignore'] });
    if (result.status !== 0) {
        return null;
    }
    const out = result.stdout.toString().trim();
    return out || null;
}
export function isRunningAsRoot(uid = typeof process.getuid === 'function' ? process.getuid() : undefined) {
    return uid === 0;
}
function privilegedCommand(args, options = {}) {
    if (isRunningAsRoot()) {
        return [args[0] ?? '', args.slice(1)];
    }
    return ['sudo', [...(options.nonInteractive ? ['-n'] : []), ...args]];
}
// Return the apt/dnf/pacman command (as argv minus the leading "sudo") that
// installs certbot + its nginx plugin on this host. Returns null when the
// package manager is unknown or known not to support sudo installs (e.g.
// homebrew, which refuses to run as root). The caller passes the array to
// `sudoRun` so we get a TTY-attached install with apt's "Y" handled by `-y`
// and noninteractive frontend.
function detectCertbotInstaller() {
    if (whichBinary('apt-get') || whichBinary('apt')) {
        // DEBIAN_FRONTEND=noninteractive prevents debconf dialogs (e.g. the
        // post-install kernel-upgrade whiptail that bit us during nginx install).
        return ['env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', 'certbot', 'python3-certbot-nginx'];
    }
    if (whichBinary('dnf')) {
        return ['dnf', 'install', '-y', 'certbot', 'python3-certbot-nginx'];
    }
    if (whichBinary('yum')) {
        return ['yum', 'install', '-y', 'certbot', 'python3-certbot-nginx'];
    }
    if (whichBinary('pacman')) {
        return ['pacman', '-S', '--noconfirm', 'certbot', 'certbot-nginx'];
    }
    // homebrew should not be run via sudo (it warns + can break perms), so
    // leave Mac users to install certbot themselves.
    return null;
}
export function detectNginxLayout() {
    const binary = whichBinary('nginx');
    if (!binary) {
        return null;
    }
    for (const candidate of NGINX_LAYOUT_CANDIDATES) {
        try {
            if (existsSync(candidate.confDir) && statSync(candidate.confDir).isDirectory()) {
                return { binary, ...candidate };
            }
        }
        catch {
            // unreadable dir — keep looking
        }
    }
    return null;
}
export async function detectPublicIp(timeoutMs = 5000) {
    // Multiple sources because any one of these can be down or block specific
    // user-agents; we accept the first plausible IPv4 reply.
    const sources = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
    for (const source of sources) {
        try {
            const res = await fetch(source, { signal: AbortSignal.timeout(timeoutMs) });
            if (!res.ok)
                continue;
            const text = (await res.text()).trim();
            if (/^\d{1,3}(\.\d{1,3}){3}$/.test(text)) {
                return text;
            }
        }
        catch {
            // try the next source
        }
    }
    return null;
}
// Query public DNS (Cloudflare 1.1.1.1 + Google 8.8.8.8 as fallback) directly
// instead of going through whatever's in /etc/resolv.conf. Local resolvers
// like systemd-resolved or dnsmasq cache for the record's TTL, which during
// propagation can keep returning stale answers long after the operator has
// already changed the record at their DNS provider — so we'd sit in the
// "DNS not pointing yet" retry loop even though the change went through.
// Pinning to a public resolver skips that cache layer entirely.
export async function resolveDomainIps(domain) {
    const resolver = new dnsPromises.Resolver();
    resolver.setServers(['1.1.1.1', '8.8.8.8']);
    try {
        return await resolver.resolve4(domain);
    }
    catch {
        return null;
    }
}
export function tryWriteConfig(filepath, contents) {
    if (existsSync(filepath)) {
        return { kind: 'exists', path: filepath };
    }
    try {
        writeFileSync(filepath, contents, { mode: 0o644, encoding: 'utf8' });
        return { kind: 'written' };
    }
    catch (err) {
        const code = err.code;
        if (code === 'EACCES' || code === 'EPERM') {
            return { kind: 'permission' };
        }
        throw err;
    }
}
export function trySymlink(target, link) {
    if (existsSync(link)) {
        return { kind: 'exists' };
    }
    try {
        symlinkSync(target, link);
        return { kind: 'created' };
    }
    catch (err) {
        const code = err.code;
        if (code === 'EACCES' || code === 'EPERM') {
            return { kind: 'permission' };
        }
        throw err;
    }
}
export function nginxDumpLoadsPath(dump, filePath) {
    const marker = `# configuration file ${filePath}:`;
    return dump.split(/\r?\n/).some((line) => line.trim() === marker);
}
export function nginxDumpIncludesGlob(dump, glob) {
    const escaped = escapeRegExp(glob);
    const includePattern = new RegExp(String.raw `^include\s+${escaped}\s*;$`, 'i');
    return dump
        .split(/\r?\n/)
        .map((line) => line.trim())
        .filter((line) => line && !line.startsWith('#'))
        .some((line) => includePattern.test(line));
}
export function findNginxServerNameFiles(dump, domain) {
    const target = domain.toLowerCase();
    const files = new Set();
    let currentFile = '(unknown file)';
    for (const line of dump.split(/\r?\n/)) {
        const marker = line.match(/^# configuration file (.+):\s*$/);
        if (marker) {
            currentFile = marker[1].trim();
            continue;
        }
        const serverName = line.match(/\bserver_name\s+([^;]+);/i);
        if (!serverName) {
            continue;
        }
        const names = serverName[1]
            .split(/\s+/)
            .map((name) => name.trim().toLowerCase())
            .filter(Boolean);
        if (names.includes(target)) {
            files.add(currentFile);
        }
    }
    return [...files];
}
export function certbotRegistrationArgs(email) {
    const trimmed = email.trim();
    return trimmed
        ? ['--email', trimmed, '--no-eff-email']
        : ['--register-unsafely-without-email'];
}
function escapeRegExp(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// True when `sudo` would not prompt for a password right now — either the
// process is already root, or the operator has a cached sudo timestamp, or
// they have NOPASSWD configured for these commands. Lets us skip the
// "use sudo?" prompt entirely in those cases.
export function canSudoNonInteractive() {
    if (isRunningAsRoot()) {
        return true;
    }
    const result = spawnSync('sudo', ['-n', 'true'], { stdio: 'ignore' });
    return result.status === 0;
}
// Run a command via sudo with the parent's terminal attached so any password
// prompt (or other interactive output) is answered directly by the operator.
// Synchronous so the verify step that runs after this sees the final state.
//
// Important: we explicitly restore the TTY to canonical mode before
// spawning. The onboarding prompter uses readline with terminal=true, which
// can leave stdin in raw mode between question() calls — and a child that
// expects line-mode input (e.g. an interactive certbot) would then read
// nothing because Enter never gets delivered as a newline. We re-enable raw
// mode afterwards so the prompter still works on return.
export function sudoRun(args) {
    const stdin = process.stdin;
    const wasRaw = Boolean(stdin.isTTY && stdin.isRaw);
    if (wasRaw && typeof stdin.setRawMode === 'function') {
        stdin.setRawMode(false);
    }
    try {
        const [command, runArgs] = privilegedCommand(args);
        const result = spawnSync(command, runArgs, { stdio: 'inherit' });
        return { ok: result.status === 0 };
    }
    finally {
        if (wasRaw && typeof stdin.setRawMode === 'function') {
            stdin.setRawMode(true);
        }
    }
}
function sudoRunCapture(args, options = {}) {
    const [command, runArgs] = privilegedCommand(args, options);
    const result = spawnSync(command, runArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
    const stdout = result.stdout?.toString() ?? '';
    const stderr = result.stderr?.toString() ?? '';
    return { ok: result.status === 0, output: `${stdout}${stderr}` };
}
// Place `content` at `targetPath` as root in one shot: stage to a per-process
// tmp file (writable by us), then `sudo install -m 644` it into place. install(1)
// is atomic-rename-equivalent and sets perms in one syscall, which is cleaner
// than `sudo tee + sudo chmod`. The tmp file is removed whether the install
// succeeds or not so we don't litter /tmp on cancel.
export function sudoWriteFile(targetPath, content) {
    if (isRunningAsRoot()) {
        try {
            writeFileSync(targetPath, content, { mode: 0o644, encoding: 'utf8' });
            return { ok: true };
        }
        catch {
            return { ok: false };
        }
    }
    const tempPath = `/tmp/nterminal-onboard-${process.pid}-${randomUUID()}.tmp`;
    try {
        writeFileSync(tempPath, content, { mode: 0o644, encoding: 'utf8' });
    }
    catch {
        return { ok: false };
    }
    try {
        const install = spawnSync('sudo', ['install', '-m', '644', tempPath, targetPath], {
            stdio: 'inherit'
        });
        return { ok: install.status === 0 };
    }
    finally {
        try {
            unlinkSync(tempPath);
        }
        catch {
            // tmp file may have been moved or never created; ignore
        }
    }
}
export function tryRun(cmd, args) {
    const result = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
    const stdout = result.stdout?.toString() ?? '';
    const stderr = result.stderr?.toString() ?? '';
    const combined = `${stdout}${stderr}`.trim();
    if (result.status === 0) {
        return { kind: 'ok', output: stdout };
    }
    const permission = /permission|eacces|operation not permitted|must be run as root/i.test(combined);
    return { kind: 'failed', permission, output: combined };
}
function runtimeStatusCommand() {
    return 'nterminal status';
}
// Check that nginx is actually proxying `domain` to NTerminal (and not just
// serving the default nginx welcome page or routing to some unrelated site).
// We hit the local nginx with a faked Host header so the check works regardless
// of DNS, and look for NTerminal's JSON response on /api/auth/session — the
// default nginx vhost would return HTML, and a misconfigured proxy_pass would
// return 502.
export function verifyProxyServing(domain, expectedBackendPort) {
    return new Promise((resolve) => {
        const req = httpRequest({
            host: '127.0.0.1',
            port: 80,
            path: '/api/auth/session',
            method: 'GET',
            headers: { Host: domain },
            timeout: 5000
        }, async (res) => {
            const contentType = String(res.headers['content-type'] ?? '');
            res.resume();
            if (res.statusCode === 502 || res.statusCode === 503 || res.statusCode === 504) {
                resolve({ ok: false, reason: `nginx replied ${res.statusCode} — config is loaded but it can't reach NTerminal on 127.0.0.1:${expectedBackendPort}. Run \`${runtimeStatusCommand()}\` to confirm NTerminal is running.` });
                return;
            }
            if (isHttpsRedirectForDomain(res.statusCode, String(res.headers.location ?? ''), domain)) {
                const https = await verifyHttpsReachable(domain);
                if (https.ok) {
                    resolve({ ok: true });
                    return;
                }
                resolve({ ok: false, reason: `nginx redirects ${domain} HTTP to HTTPS, but HTTPS verify failed: ${https.reason}` });
                return;
            }
            if (!contentType.includes('json')) {
                resolve({ ok: false, reason: `expected JSON from /api/auth/session, got ${contentType || 'no content-type'} — nginx is still serving its default vhost, not the new ${domain} block.` });
                return;
            }
            if (res.statusCode !== 200) {
                resolve({ ok: false, reason: `nginx routed to NTerminal but it returned ${res.statusCode}.` });
                return;
            }
            resolve({ ok: true });
        });
        req.on('error', (err) => resolve({ ok: false, reason: `could not reach nginx on 127.0.0.1:80 — ${err.message}` }));
        req.on('timeout', () => {
            req.destroy();
            resolve({ ok: false, reason: 'nginx did not respond within 5s' });
        });
        req.end();
    });
}
export function isHttpsRedirectForDomain(statusCode, location, domain) {
    if (statusCode !== 301 && statusCode !== 302 && statusCode !== 307 && statusCode !== 308) {
        return false;
    }
    try {
        const parsed = new URL(location, `http://${domain}`);
        return parsed.protocol === 'https:' && parsed.hostname === domain;
    }
    catch {
        return false;
    }
}
// Check that HTTPS is actually serving the domain — proves both that nginx
// has a port-443 server block (certbot --nginx wrote one) AND that the cert
// is valid for the requested domain.
export function verifyHttpsReachable(domain) {
    return new Promise((resolve) => {
        const req = httpsRequest({
            host: domain,
            port: 443,
            path: '/api/auth/session',
            method: 'GET',
            timeout: 10000
        }, (res) => {
            const contentType = String(res.headers['content-type'] ?? '');
            res.resume();
            if (res.statusCode == null || res.statusCode >= 500) {
                resolve({ ok: false, reason: `https://${domain} responded ${res.statusCode ?? '<no status>'}` });
                return;
            }
            if (!contentType.includes('json')) {
                resolve({ ok: false, reason: `https://${domain}/api/auth/session returned ${contentType || 'no content-type'}, not NTerminal JSON` });
                return;
            }
            if (res.statusCode !== 200) {
                resolve({ ok: false, reason: `https://${domain}/api/auth/session returned ${res.statusCode}` });
                return;
            }
            resolve({ ok: true });
        });
        req.on('error', (err) => {
            const message = err.message;
            if (/certificate|self.?signed|hostname|ALPN|protocol|wrong|UNABLE_TO_GET/i.test(message)) {
                resolve({ ok: false, reason: `TLS error: ${message} — certbot may not have completed.` });
            }
            else if (/ECONNREFUSED|EADDRNOTAVAIL/i.test(message)) {
                resolve({ ok: false, reason: `nothing listening on 443 yet — certbot adds the SSL server block during \`certbot --nginx\`.` });
            }
            else {
                resolve({ ok: false, reason: message });
            }
        });
        req.on('timeout', () => {
            req.destroy();
            resolve({ ok: false, reason: `https://${domain} did not respond within 10s` });
        });
        req.end();
    });
}
const PROXY_STEPS = [
    'Public IP',
    'DNS A record',
    'nginx installed',
    'nginx site config',
    'nginx reload + verify',
    'Firewall (80/443 open)',
    "TLS cert + HTTPS verify"
];
function printStepsOverview() {
    console.log('\x1b[2mSteps:\x1b[0m');
    for (let i = 0; i < PROXY_STEPS.length; i += 1) {
        console.log(`\x1b[2m  ${i + 1}/${PROXY_STEPS.length} ${PROXY_STEPS[i]}\x1b[0m`);
    }
    console.log('');
}
function printStepHeader(stepIndex) {
    // 1-indexed for human reading. Bold + brackets so the eye lands on the step
    // boundary even when the screen has scrolled past several lines of output
    // from the previous step.
    const label = PROXY_STEPS[stepIndex - 1];
    console.log(`\n\x1b[1m[${stepIndex}/${PROXY_STEPS.length}] ${label}\x1b[0m`);
}
export async function setupNginxProxy(args) {
    const { domain, port, prompter } = args;
    console.log(`\n=== Reverse proxy + TLS setup (${domain}) ===\n`);
    printStepsOverview();
    // Pick how to elevate. We do this once up-front so the operator
    // answers at most one question, and so the missing-package check below
    // can offer to install nginx / certbot itself when auto-sudo is on.
    const sudoMode = await chooseSudoMode(prompter);
    // Step 1 — Public IP, auto or ask if detection failed.
    printStepHeader(1);
    const publicIp = await resolvePublicIp(prompter);
    if (!publicIp) {
        console.log('Skipping reverse-proxy setup. Re-run onboarding once you know your public IP.');
        return { httpsReady: false };
    }
    // Step 2 — DNS check loop. Print the exact A record and let the operator
    // retry as many times as they need.
    printStepHeader(2);
    await waitForDnsMatch(domain, publicIp, prompter);
    // Step 3 — nginx detection + auto-install when allowed.
    printStepHeader(3);
    let layout = detectNginxLayout();
    if (!layout) {
        layout = await tryInstallNginx(sudoMode, prompter);
        if (!layout) {
            printNginxInstallHint();
            return { httpsReady: false };
        }
    }
    console.log(`nginx detected at ${layout.binary} (config dir: ${layout.confDir}).`);
    // Step 4 — write the per-site config + (Debian only) enable via symlink.
    printStepHeader(4);
    const configPath = path.join(layout.confDir, `${domain}.conf`);
    const config = generateNginxConfig(domain, port);
    if (!(await ensureConfigWritten(configPath, config, prompter, sudoMode))) {
        return { httpsReady: false };
    }
    if (layout.family === 'debian' && layout.enabledDir) {
        const linkPath = path.join(layout.enabledDir, `${domain}.conf`);
        if (!(await ensureSymlinked(configPath, linkPath, prompter, sudoMode))) {
            return { httpsReady: false };
        }
    }
    // Step 5 — reload nginx, verified by curl-with-Host against the local
    // nginx so we know the new site block is actually loaded (not the default
    // welcome page) and proxies to NTerminal.
    printStepHeader(5);
    if (!(await ensureNginxServingDomain(layout, domain, port, prompter, sudoMode))) {
        return { httpsReady: false };
    }
    // Step 6 — open the host firewall so Let's Encrypt's HTTP-01 challenge
    // can actually reach :80 from the internet. Catches the canonical
    // "ufw is active and only ssh is allowed" case before certbot wastes its
    // time hitting a ratelimit on a guaranteed failure.
    printStepHeader(6);
    await ensureFirewallAllowsHttp(prompter, sudoMode);
    // Step 7 — certbot, verified by a real HTTPS request against the domain
    // so we know both the cert and the port-443 server block are in place.
    printStepHeader(7);
    if (!(await ensureHttpsWorks(domain, prompter, sudoMode))) {
        return { httpsReady: false };
    }
    console.log(`\n\x1b[32m✓\x1b[0m Reverse proxy + TLS ready. https://${domain} is serving NTerminal.`);
    return { httpsReady: true };
}
async function chooseSudoMode(prompter) {
    if (isRunningAsRoot()) {
        console.log('Running as root — running the privileged steps directly.');
        return 'auto-no-prompt';
    }
    if (canSudoNonInteractive()) {
        console.log('sudo works without a password here — running the privileged steps automatically.');
        return 'auto-no-prompt';
    }
    console.log('The reverse-proxy / TLS steps below need root (install nginx + certbot if\n' +
        'missing, write the site config, reload nginx, issue the Let’s Encrypt cert).\n' +
        'You can:\n' +
        '  • let onboarding run them via sudo (you will be prompted for your password\n' +
        '    once — sudo caches it briefly for the rest of the flow), or\n' +
        '  • have onboarding print the commands so you can run them manually in another\n' +
        '    terminal (useful when copying out of tmux is awkward, or when you want to\n' +
        '    review each command before it runs).');
    const useSudo = await prompter.confirm('Run the privileged steps via sudo?', true);
    return useSudo ? 'auto-with-prompt' : 'manual';
}
// Auto-install nginx when sudoMode permits. Returns the freshly-detected
// layout on success, null when the operator declined, the package manager
// is unknown, or the install failed (in which case the caller falls back
// to printing manual install instructions).
async function tryInstallNginx(sudoMode, prompter) {
    if (sudoMode === 'manual') {
        return null;
    }
    const installer = detectNginxInstaller();
    if (!installer) {
        return null;
    }
    console.log(`\nnginx is not installed. Install it via \`sudo ${installer.join(' ')}\`?`);
    const ok = await prompter.confirm('Install nginx now?', true);
    if (!ok) {
        return null;
    }
    const install = sudoRun(installer);
    if (!install.ok) {
        console.log('nginx install failed; falling back to manual instructions.');
        return null;
    }
    const layout = detectNginxLayout();
    if (!layout) {
        console.log('nginx was installed but the standard config directories were not found.');
    }
    return layout;
}
function detectNginxInstaller() {
    if (whichBinary('apt-get') || whichBinary('apt')) {
        return ['env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', 'nginx'];
    }
    if (whichBinary('dnf')) {
        return ['dnf', 'install', '-y', 'nginx'];
    }
    if (whichBinary('yum')) {
        return ['yum', 'install', '-y', 'nginx'];
    }
    if (whichBinary('pacman')) {
        return ['pacman', '-S', '--noconfirm', 'nginx'];
    }
    return null;
}
async function waitForOperator(args) {
    const check = async () => {
        const raw = await args.verify();
        return typeof raw === 'boolean' ? { ok: raw } : raw;
    };
    // Upfront check — the operator may have already finished this manually
    // before reaching the prompt (re-runs, parallel terminal, etc.).
    let result = await check();
    if (result.ok) {
        console.log(`\x1b[32m✓\x1b[0m ${args.label} — already done.`);
        return true;
    }
    console.log(`\n\x1b[1mNeed root to: ${args.label}\x1b[0m`);
    console.log(`\x1b[2m${args.description}\x1b[0m`);
    console.log('\nRun in another terminal:\n');
    for (const block of args.commands) {
        for (const line of block.split('\n')) {
            console.log(`  ${line}`);
        }
    }
    for (;;) {
        if (result.reason) {
            console.log(`\n\x1b[2m${result.reason}\x1b[0m`);
        }
        const choice = (await args.prompter.ask('Press Enter to re-check, or type "skip" to skip remaining proxy steps', '')).trim().toLowerCase();
        if (choice === 'skip' || choice === 's') {
            console.log(`Skipped: ${args.label}. Re-run onboarding (or finish the remaining commands manually) when ready.`);
            return false;
        }
        result = await check();
        if (result.ok) {
            console.log(`\x1b[32m✓\x1b[0m ${args.label}`);
            return true;
        }
    }
}
async function ensureConfigWritten(configPath, config, prompter, sudoMode) {
    const attempt = tryWriteConfig(configPath, config);
    if (attempt.kind === 'written') {
        console.log(`\x1b[32m✓\x1b[0m wrote ${configPath}`);
        return true;
    }
    if (attempt.kind === 'exists') {
        console.log(`\x1b[32m✓\x1b[0m config already exists at ${configPath} — leaving it alone`);
        return true;
    }
    // attempt.kind === 'permission'
    if (sudoMode !== 'manual') {
        console.log(`\nWriting ${configPath} via sudo (proxies https://<this domain> to NTerminal on 127.0.0.1)…`);
        const written = sudoWriteFile(configPath, config);
        if (written.ok && existsSync(configPath)) {
            console.log(`\x1b[32m✓\x1b[0m wrote ${configPath} via sudo`);
            return true;
        }
        console.log('sudo write failed; falling back to manual instructions.');
    }
    return waitForOperator({
        label: `write the nginx site config at ${configPath}`,
        description: 'Creates a new nginx site config that tells nginx to proxy requests for this ' +
            'domain to NTerminal running on 127.0.0.1. The file is a plain config text — ' +
            'no service is restarted yet, nginx will only pick it up on reload below.',
        commands: [
            `sudo tee ${configPath} > /dev/null <<'NGINX_EOF'\n${config.trimEnd()}\nNGINX_EOF`
        ],
        verify: () => existsSync(configPath),
        prompter
    });
}
async function ensureSymlinked(configPath, linkPath, prompter, sudoMode) {
    const attempt = trySymlink(configPath, linkPath);
    if (attempt.kind === 'created') {
        console.log(`\x1b[32m✓\x1b[0m enabled via ${linkPath}`);
        return true;
    }
    if (attempt.kind === 'exists') {
        console.log(`\x1b[32m✓\x1b[0m already enabled at ${linkPath}`);
        return true;
    }
    // attempt.kind === 'permission'
    if (sudoMode !== 'manual') {
        console.log(`\nEnabling site via sudo (symlinks the config into ${path.dirname(linkPath)})…`);
        const linked = sudoRun(['ln', '-s', configPath, linkPath]);
        if (linked.ok && existsSync(linkPath)) {
            console.log(`\x1b[32m✓\x1b[0m enabled via ${linkPath}`);
            return true;
        }
        console.log('sudo symlink failed; falling back to manual instructions.');
    }
    return waitForOperator({
        label: `enable the site by symlinking into ${path.dirname(linkPath)}`,
        description: 'Debian/Ubuntu’s nginx only loads configs that have a symlink in sites-enabled. ' +
            'This creates that symlink so nginx will include the file we just wrote on the ' +
            'next reload. Nothing else changes — no restart, no traffic shift.',
        commands: [`sudo ln -s ${configPath} ${linkPath}`],
        verify: () => existsSync(linkPath),
        prompter
    });
}
async function ensureNginxServingDomain(layout, domain, port, prompter, sudoMode) {
    const reloadAttempt = tryRun(layout.binary, ['-s', 'reload']);
    if (reloadAttempt.kind === 'failed' && !reloadAttempt.permission) {
        console.log(`\n\x1b[33m⚠\x1b[0m nginx reload failed (not a permission issue):\n${reloadAttempt.output}`);
        console.log('Fix the issue and run `sudo nginx -t` for details.');
        return false;
    }
    if (reloadAttempt.kind === 'failed' && sudoMode !== 'manual') {
        console.log('\nReloading nginx via sudo (validates the config, then graceful re-read — no downtime)…');
        const testResult = sudoRun([layout.binary, '-t']);
        if (!testResult.ok) {
            console.log(`\x1b[33m⚠\x1b[0m \`sudo ${layout.binary} -t\` failed. Fix the config and re-run.`);
            return false;
        }
        const reload = sudoRun([layout.binary, '-s', 'reload']);
        if (!reload.ok) {
            console.log('sudo reload failed; falling back to manual instructions.');
        }
    }
    const verified = await verifyProxyServing(domain, port);
    if (verified.ok) {
        console.log(`\x1b[32m✓\x1b[0m nginx reloaded and proxying ${domain} to NTerminal`);
        return true;
    }
    console.log(`\x1b[33m⚠\x1b[0m proxy verification still failing: ${verified.reason}`);
    if (sudoMode !== 'manual' && (await tryRepairNginxRoute(layout, domain, port, sudoMode))) {
        return true;
    }
    // Whether we managed the reload or not, verify via curl-with-Host that
    // the new block is actually serving. This is the only check that proves
    // the config loaded; nginx -t alone just validates syntax.
    return waitForOperator({
        label: `reload nginx and verify it proxies ${domain} to NTerminal`,
        description: '`nginx -t` parses every config under /etc/nginx to confirm the new file is ' +
            'syntactically valid — it changes nothing on disk. ' +
            '`nginx -s reload` then signals the running master process to re-read its ' +
            'configs gracefully: existing connections finish on the old workers while ' +
            'new ones use the new config. No downtime, no kill, no service restart.',
        commands: [`sudo ${layout.binary} -t`, `sudo ${layout.binary} -s reload`],
        verify: () => verifyProxyServing(domain, port),
        prompter
    });
}
async function tryRepairNginxRoute(layout, domain, port, sudoMode) {
    const dump = captureNginxConfigDump(layout, sudoMode);
    if (!dump) {
        return false;
    }
    const activeConfigPath = layout.enabledDir
        ? path.join(layout.enabledDir, `${domain}.conf`)
        : path.join(layout.confDir, `${domain}.conf`);
    const serverNameFiles = findNginxServerNameFiles(dump, domain);
    if (serverNameFiles.length > 0) {
        const loadedWhere = serverNameFiles.map((file) => `  - ${file}`).join('\n');
        console.log(`\x1b[33m⚠\x1b[0m nginx -T already shows server_name ${domain} in:\n${loadedWhere}\n` +
            'NTerminal will not auto-edit existing vhosts. If one of these is an old/default site, disable it or remove the duplicate server_name, then reload nginx.');
        return false;
    }
    if (nginxDumpLoadsPath(dump, activeConfigPath)) {
        return false;
    }
    const fallbackPath = nginxDumpIncludesGlob(dump, '/etc/nginx/conf.d/*.conf')
        ? path.join('/etc/nginx/conf.d', `${domain}.conf`)
        : null;
    if (!fallbackPath || fallbackPath === activeConfigPath || existsSync(fallbackPath)) {
        return false;
    }
    console.log(`\nnginx reloaded, but ${activeConfigPath} is not part of the active nginx config. ` +
        `Writing an active fallback at ${fallbackPath}…`);
    const written = sudoWriteFile(fallbackPath, generateNginxConfig(domain, port));
    if (!written.ok || !existsSync(fallbackPath)) {
        console.log('fallback nginx config write failed.');
        return false;
    }
    const testResult = sudoRun([layout.binary, '-t']);
    if (!testResult.ok) {
        console.log(`\x1b[33m⚠\x1b[0m fallback config was written, but \`sudo ${layout.binary} -t\` failed.`);
        return false;
    }
    const reload = sudoRun([layout.binary, '-s', 'reload']);
    if (!reload.ok) {
        console.log('fallback config was written, but nginx reload failed.');
        return false;
    }
    const verified = await verifyProxyServing(domain, port);
    if (verified.ok) {
        console.log(`\x1b[32m✓\x1b[0m nginx fallback config loaded and proxying ${domain} to NTerminal`);
        return true;
    }
    console.log(`\x1b[33m⚠\x1b[0m fallback config loaded, but proxy verification still failed: ${verified.reason}`);
    return false;
}
function captureNginxConfigDump(layout, sudoMode) {
    const direct = tryRun(layout.binary, ['-T']);
    if (direct.kind === 'ok') {
        return direct.output;
    }
    if (!direct.permission || sudoMode === 'manual') {
        return null;
    }
    const sudo = sudoRunCapture([layout.binary, '-T']);
    return sudo.ok ? sudo.output : null;
}
async function ensureHttpsWorks(domain, prompter, sudoMode) {
    // Quick check first — maybe an earlier onboarding run already issued the
    // cert and we just need to confirm.
    const upfront = await verifyHttpsReachable(domain);
    if (upfront.ok) {
        console.log(`\x1b[32m✓\x1b[0m https://${domain} already working`);
        return true;
    }
    if (sudoMode !== 'manual') {
        // Pre-flight check: missing certbot would surface as a confusing
        // "command not found" mid-flow. Offer to install it ourselves since we
        // already have sudo capability — the operator only sees one extra
        // confirmation instead of bailing back to manual mode for an apt install.
        if (!whichBinary('certbot')) {
            const installer = detectCertbotInstaller();
            if (installer) {
                console.log(`\ncertbot is not installed. Install it via \`sudo ${installer.join(' ')}\`?`);
                const ok = await prompter.confirm('Install certbot now?', true);
                if (ok) {
                    const install = sudoRun(installer);
                    if (!install.ok || !whichBinary('certbot')) {
                        console.log('certbot install failed; falling back to manual instructions.');
                    }
                }
            }
            else {
                console.log('certbot is not installed and no auto-installer is available on this platform.');
            }
        }
    }
    if (sudoMode !== 'manual' && whichBinary('certbot')) {
        // Ask once via our own prompter and pass explicit registration flags so
        // certbot runs fully non-interactive. Blank input continues without an
        // email; Certbot supports that via --register-unsafely-without-email, but
        // it means the operator will not receive expiry / revocation warnings.
        const email = await askCertbotEmail(prompter);
        const certArgs = [
            'certbot',
            '--nginx',
            '-d',
            domain,
            ...certbotRegistrationArgs(email),
            '--agree-tos',
            '--non-interactive',
            '--keep-until-expiring' // skip the "renew?" prompt if a valid cert is already in place
        ];
        console.log(`\nIssuing TLS cert via \`sudo ${certArgs.join(' ')}\`…`);
        const cert = sudoRun(certArgs);
        if (cert.ok) {
            const verified = await verifyHttpsReachable(domain);
            if (verified.ok) {
                console.log(`\x1b[32m✓\x1b[0m https://${domain} is up`);
                return true;
            }
            console.log(`\x1b[33m⚠\x1b[0m certbot exited 0 but HTTPS verify failed: ${verified.reason}`);
        }
        else {
            console.log('certbot run failed; falling back to manual instructions.');
        }
    }
    return waitForOperator({
        label: `issue an HTTPS cert for ${domain}`,
        description: 'certbot contacts Let’s Encrypt, proves you control this domain via an HTTP-01 ' +
            'challenge served over the port 80 nginx site we just set up, downloads a real ' +
            'TLS cert, and edits the same nginx config file in place to add a port-443 ' +
            'server block (with the cert) plus an http→https redirect. It also installs a ' +
            'systemd timer that auto-renews the cert before it expires.',
        commands: [
            `sudo certbot --nginx -d ${domain} --register-unsafely-without-email --agree-tos`,
            `# Or with expiry/revocation notices: sudo certbot --nginx -d ${domain} --email you@example.com --agree-tos --no-eff-email`,
            `# If certbot isn't installed:`,
            `#   Debian/Ubuntu: sudo apt install certbot python3-certbot-nginx`,
            `#   RHEL/Fedora:   sudo dnf install certbot python3-certbot-nginx`
        ],
        verify: () => verifyHttpsReachable(domain),
        prompter
    });
}
// Ask the operator for an optional Let's Encrypt account email and loop on
// invalid input. Blank means "continue without email" rather than bailing out.
async function askCertbotEmail(prompter) {
    for (;;) {
        const raw = (await prompter.ask('Email for Let’s Encrypt cert (optional; Enter = continue without email)')).trim();
        if (!raw) {
            return '';
        }
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) {
            console.log('That does not look like an email. Try again, or press Enter to continue without email.');
            continue;
        }
        return raw;
    }
}
// Check the host firewall and open ports 80/443 when needed. We treat this
// as best-effort: returns silently when we can't detect a firewall config
// we know how to drive (raw iptables, nftables, none) — certbot's challenge
// step itself is the real test, and we don't want to refuse the cert step
// just because we couldn't introspect.
// Open a single TCP port through whichever firewall is active on this host.
// Used by secondary onboarding so a NTerminal agent bound to `0.0.0.0:<port>`
// is actually reachable from the main on first run — without this, the agent
// is invisible until the operator manually edits ufw / firewalld and remembers
// to re-register.
export async function ensureFirewallAllowsPort(port, prompter, sudoMode, options = {}) {
    const restrictedSource = options.source ? normalizeFirewallSource(options.source) : null;
    if (options.source && !restrictedSource) {
        console.log(`Invalid firewall source "${options.source}". Expected one IP or CIDR, e.g. 203.0.113.10 or 203.0.113.0/24.`);
        return;
    }
    if (sudoMode === 'manual') {
        printManualFirewallCommands(port, restrictedSource);
        return;
    }
    if (!ensureFirewallSudoReady(sudoMode, port, restrictedSource)) {
        return;
    }
    if (whichBinary('ufw')) {
        const status = sudoRunCapture(['ufw', 'status', 'numbered'], { nonInteractive: true });
        if (status.ok) {
            const out = status.output;
            if (/Status:\s*inactive/i.test(out)) {
                if (restrictedSource) {
                    console.log(`ufw is installed but inactive, so onboarding will not enable it automatically. Restrict ${port}/tcp to ${restrictedSource} in your cloud/provider firewall if this host is public.`);
                }
                return;
            }
            if (restrictedSource) {
                const restrictedStatus = analyzeUfwRestrictedStatus(out, port, restrictedSource);
                if (restrictedStatus.restricted) {
                    console.log(`\x1b[32m✓\x1b[0m ufw already restricts ${port}/tcp to ${restrictedSource}.`);
                    return;
                }
                const commands = manualFirewallCommands('ufw', port, restrictedSource).slice(1);
                console.log(`\nufw is active. This secondary only needs inbound traffic from the main server.\n` +
                    `Onboarding will insert a source allow for ${restrictedSource}, followed by a deny for other ${port}/tcp traffic.`);
                const ok = await prompter.confirm(`Restrict ${port}/tcp in ufw to ${restrictedSource}?`, true);
                if (!ok)
                    return;
                const allow = sudoRun(['ufw', 'insert', '1', 'allow', 'from', restrictedSource, 'to', 'any', 'port', String(port), 'proto', 'tcp']);
                const deny = allow.ok ? sudoRun(['ufw', 'insert', '2', 'deny', 'to', 'any', 'port', String(port), 'proto', 'tcp']) : { ok: false };
                if (allow.ok && deny.ok) {
                    console.log(`\x1b[32m✓\x1b[0m ufw now restricts ${port}/tcp to ${restrictedSource}.`);
                }
                else {
                    console.log(`ufw restriction failed; run these commands manually before the main tries to reach this agent:\n${commands.map((command) => `  ${command}`).join('\n')}`);
                }
                return;
            }
            // Match either explicit port allow (`<port>/tcp` or bare `<port>`) so a
            // re-run on an already-open host doesn't re-prompt.
            const portPattern = new RegExp(`\\b${port}(/tcp)?\\b`);
            if (portPattern.test(out)) {
                console.log(`\x1b[32m✓\x1b[0m ufw already allows ${port}/tcp.`);
                return;
            }
            console.log(`\nufw is active but ${port}/tcp is not allowed. The main reaches this agent on that port,\n` +
                `so it has to be open or registration looks healthy while every request times out.`);
            const ok = await prompter.confirm(`Open ${port}/tcp in ufw?`, true);
            if (!ok)
                return;
            const allow = sudoRun(['ufw', 'allow', `${port}/tcp`]);
            if (allow.ok) {
                console.log(`\x1b[32m✓\x1b[0m ufw now allows ${port}/tcp.`);
            }
            else {
                console.log(`ufw allow failed; run \`sudo ufw allow ${port}/tcp\` manually before the main tries to reach this agent.`);
            }
            return;
        }
    }
    if (whichBinary('firewall-cmd')) {
        const state = sudoRunCapture(['firewall-cmd', '--state'], { nonInteractive: true });
        if (state.ok && state.output.trim() === 'running') {
            const list = sudoRunCapture(['firewall-cmd', '--list-ports'], { nonInteractive: true });
            if (list.ok) {
                const ports = list.output.split(/\s+/);
                if (restrictedSource) {
                    const richRules = sudoRunCapture(['firewall-cmd', '--list-rich-rules'], { nonInteractive: true });
                    const allowRule = firewalldAllowRichRule(port, restrictedSource);
                    const dropRule = firewalldDropRichRule(port, restrictedSource);
                    const hasBroadPort = ports.includes(`${port}/tcp`);
                    const richOut = richRules.ok ? richRules.output : '';
                    if (!hasBroadPort && richOut.includes(allowRule) && richOut.includes(dropRule)) {
                        console.log(`\x1b[32m✓\x1b[0m firewalld already restricts ${port}/tcp to ${restrictedSource}.`);
                        return;
                    }
                    const commands = [
                        `sudo firewall-cmd --permanent --add-rich-rule='${allowRule}'`,
                        `sudo firewall-cmd --permanent --add-rich-rule='${dropRule}'`,
                        'sudo firewall-cmd --reload',
                        ...(hasBroadPort ? [`sudo firewall-cmd --permanent --remove-port=${port}/tcp`, 'sudo firewall-cmd --reload'] : [])
                    ];
                    console.log(`\nfirewalld is running. This secondary only needs inbound traffic from the main server.\n` +
                        `Onboarding will add rich rules for ${restrictedSource}, then remove any broad ${port}/tcp port rule after they reload successfully.`);
                    const ok = await prompter.confirm(`Restrict ${port}/tcp in firewalld to ${restrictedSource}?`, true);
                    if (!ok)
                        return;
                    const addAllow = sudoRun(['firewall-cmd', '--permanent', `--add-rich-rule=${allowRule}`]);
                    const addDrop = addAllow.ok ? sudoRun(['firewall-cmd', '--permanent', `--add-rich-rule=${dropRule}`]) : { ok: false };
                    const reloadRestrictedRules = addAllow.ok && addDrop.ok ? sudoRun(['firewall-cmd', '--reload']) : { ok: false };
                    if (!addAllow.ok || !addDrop.ok || !reloadRestrictedRules.ok) {
                        console.log(`firewalld restriction failed; run these commands manually before the main tries to reach this agent:\n${commands.map((command) => `  ${command}`).join('\n')}`);
                        return;
                    }
                    if (hasBroadPort) {
                        const removeBroadPort = sudoRun(['firewall-cmd', '--permanent', `--remove-port=${port}/tcp`]);
                        const reloadAfterRemove = removeBroadPort.ok ? sudoRun(['firewall-cmd', '--reload']) : { ok: false };
                        if (!removeBroadPort.ok || !reloadAfterRemove.ok) {
                            console.log(`firewalld rich rules were added, but the broad ${port}/tcp port rule could not be removed. Run:\n` +
                                `  sudo firewall-cmd --permanent --remove-port=${port}/tcp\n` +
                                '  sudo firewall-cmd --reload');
                            return;
                        }
                    }
                    console.log(`\x1b[32m✓\x1b[0m firewalld now restricts ${port}/tcp to ${restrictedSource}.`);
                    return;
                }
                if (ports.includes(`${port}/tcp`)) {
                    console.log(`\x1b[32m✓\x1b[0m firewalld already allows ${port}/tcp.`);
                    return;
                }
                console.log(`\nfirewalld is running but ${port}/tcp is not open — the main needs this port to reach the agent.`);
                const ok = await prompter.confirm(`Open ${port}/tcp in firewalld?`, true);
                if (!ok)
                    return;
                const add = sudoRun(['firewall-cmd', '--permanent', `--add-port=${port}/tcp`]);
                const reload = sudoRun(['firewall-cmd', '--reload']);
                if (add.ok && reload.ok) {
                    console.log(`\x1b[32m✓\x1b[0m firewalld now allows ${port}/tcp.`);
                }
                else {
                    console.log(`firewalld update failed; run the commands above manually.`);
                }
            }
        }
    }
    // No detectable firewall. Raw iptables/nftables setups are operator-managed;
    // for restricted secondary access we still print the provider-firewall action
    // because otherwise the operator can assume the restriction was applied.
    if (restrictedSource) {
        console.log(`No active ufw/firewalld firewall was detected. If this secondary is public, restrict provider firewall access to ${restrictedSource}:${port}/tcp.`);
    }
}
function ensureFirewallSudoReady(sudoMode, port, restrictedSource) {
    if (sudoMode !== 'auto-with-prompt') {
        return true;
    }
    if (canSudoNonInteractive()) {
        return true;
    }
    console.log('\nChecking firewall status needs sudo. Enter your sudo password if prompted.');
    const ready = sudoRun(['true']);
    if (ready.ok && canSudoNonInteractive()) {
        return true;
    }
    const target = restrictedSource ? `${restrictedSource}:${port}/tcp` : `${port}/tcp`;
    console.log(`Could not obtain sudo for firewall inspection. No host firewall changes were made for ${target}.`);
    return false;
}
function printManualFirewallCommands(port, restrictedSource) {
    const sections = [];
    if (whichBinary('ufw')) {
        sections.push(formatManualFirewallSection('ufw', port, restrictedSource));
    }
    if (whichBinary('firewall-cmd')) {
        sections.push(formatManualFirewallSection('firewalld', port, restrictedSource));
    }
    if (sections.length > 0) {
        const intro = sections.length > 1
            ? '\nMultiple firewall tools are installed. Run the block for the firewall that is active on this host.'
            : '';
        console.log(`${intro}\n${sections.join('\n\n')}`);
        return;
    }
    const target = restrictedSource ? `${restrictedSource}:${port}/tcp` : `${port}/tcp`;
    console.log(`\nNo ufw/firewalld binary was detected. Configure your provider firewall manually for ${target}.`);
}
function formatManualFirewallSection(kind, port, restrictedSource) {
    const commands = manualFirewallCommands(kind, port, restrictedSource ?? undefined);
    const label = kind === 'ufw' ? 'ufw' : 'firewalld';
    const detail = restrictedSource
        ? `Restrict ${port}/tcp so only the main server (${restrictedSource}) can reach this secondary.`
        : `Open ${port}/tcp so the main server can reach this secondary.`;
    return `${label} commands. ${detail}\n${commands.map((command) => `  ${command}`).join('\n')}`;
}
function parseUfwRules(output, port, source) {
    const portPattern = new RegExp(`\\b${port}(/tcp)?\\b`);
    const rules = [];
    for (const [lineIndex, rawLine] of output.split(/\r?\n/).entries()) {
        if (!portPattern.test(rawLine)) {
            continue;
        }
        const actionMatch = /\b(ALLOW|DENY)\b(?:\s+(?:IN|OUT))?/i.exec(rawLine);
        if (!actionMatch) {
            continue;
        }
        const action = actionMatch[1].toLowerCase() === 'allow' ? 'allow' : 'deny';
        const numbered = /^\[\s*(\d+)\]/.exec(rawLine);
        const order = numbered ? Number(numbered[1]) : lineIndex + 1;
        const fromField = rawLine.slice(actionMatch.index + actionMatch[0].length).trim();
        const sourceMatch = ufwFromFieldMatchesSource(fromField, source);
        const broad = action === 'allow' && ufwFromFieldIsBroad(fromField);
        rules.push({ order, action, sourceMatch, broad });
    }
    return rules.sort((a, b) => a.order - b.order);
}
function ufwFromFieldMatchesSource(fromField, source) {
    const sourceLower = source.toLowerCase();
    return ufwFromFieldTokens(fromField).some((token) => token === sourceLower);
}
function ufwFromFieldIsBroad(fromField) {
    return ufwFromFieldTokens(fromField).some((token) => token === 'anywhere' || token === '0.0.0.0/0' || token === '::/0');
}
function ufwFromFieldTokens(fromField) {
    return fromField
        .toLowerCase()
        .split(/\s+/)
        .map((token) => token.replace(/^[[(]+|[\]),]+$/g, ''))
        .filter(Boolean);
}
function firewalldRuleFamily(source) {
    const address = source.split('/')[0] ?? '';
    return isIP(address) === 6 ? 'ipv6' : 'ipv4';
}
function firewalldAllowRichRule(port, source) {
    return `rule priority="-100" family="${firewalldRuleFamily(source)}" source address="${source}" port port="${port}" protocol="tcp" accept`;
}
function firewalldDropRichRule(port, source) {
    return `rule priority="100" family="${firewalldRuleFamily(source)}" port port="${port}" protocol="tcp" drop`;
}
// Re-export the up-front sudo mode picker so secondary onboarding can share
// the same "ask once, remember for the rest of the run" semantics.
export async function pickSudoMode(prompter) {
    return chooseSudoMode(prompter);
}
async function ensureFirewallAllowsHttp(prompter, sudoMode) {
    // ufw (Debian/Ubuntu's default friendly frontend over iptables)
    if (whichBinary('ufw')) {
        const status = sudoRunCapture(['ufw', 'status'], { nonInteractive: true });
        // If sudo -n fails here we just skip the introspection — opening the
        // firewall is opportunistic, and the operator can also do it themselves.
        if (status.ok) {
            const out = status.output;
            if (/Status:\s*inactive/i.test(out)) {
                return;
            }
            // ufw is active; check whether port 80 (and ideally 443) are already
            // allowed under any naming. We match liberally because operators write
            // these rules in many shapes: `ufw allow 80`, `ufw allow 80/tcp`,
            // `ufw allow http`, `ufw allow 'Nginx Full'`, etc.
            if (/\b80(\/tcp)?\b/.test(out) || /Nginx Full|Nginx HTTP/.test(out)) {
                return;
            }
            if (sudoMode === 'manual') {
                console.log('\nufw is active but port 80 is not allowed — Let’s Encrypt’s HTTP-01 challenge\n' +
                    'will fail to reach this host from the internet. Run:\n' +
                    "  sudo ufw allow 'Nginx Full'");
                return;
            }
            console.log('\nufw is active but port 80/443 are not allowed. Let’s Encrypt needs port 80\n' +
                'reachable from the internet to issue a cert. Opening via `ufw allow \'Nginx Full\'`\n' +
                'lets both HTTP and HTTPS in.');
            const ok = await prompter.confirm('Open 80 + 443 in ufw?', true);
            if (!ok)
                return;
            const allow = sudoRun(['ufw', 'allow', 'Nginx Full']);
            if (allow.ok) {
                console.log('\x1b[32m✓\x1b[0m ufw now allows Nginx Full (80 + 443).');
            }
            else {
                console.log('ufw allow failed; you can run it manually before certbot below.');
            }
            return;
        }
    }
    // firewalld (RHEL/Fedora/Rocky)
    if (whichBinary('firewall-cmd')) {
        const state = sudoRunCapture(['firewall-cmd', '--state'], { nonInteractive: true });
        if (state.ok && state.output.trim() === 'running') {
            const list = sudoRunCapture(['firewall-cmd', '--list-services'], { nonInteractive: true });
            if (list.ok) {
                const services = list.output.split(/\s+/);
                if (services.includes('http') && services.includes('https')) {
                    return;
                }
                if (sudoMode === 'manual') {
                    console.log('\nfirewalld is running but http/https services are not enabled. Run:\n' +
                        '  sudo firewall-cmd --permanent --add-service=http --add-service=https\n' +
                        '  sudo firewall-cmd --reload');
                    return;
                }
                console.log('\nfirewalld is running but http/https services are not enabled. Let’s Encrypt needs both.');
                const ok = await prompter.confirm('Add http + https to firewalld?', true);
                if (!ok)
                    return;
                const add = sudoRun(['firewall-cmd', '--permanent', '--add-service=http', '--add-service=https']);
                const reload = sudoRun(['firewall-cmd', '--reload']);
                if (add.ok && reload.ok) {
                    console.log('\x1b[32m✓\x1b[0m firewalld now allows http + https.');
                }
                else {
                    console.log('firewalld update failed; you can run the commands manually before certbot.');
                }
            }
        }
    }
    // Unknown firewall config (raw iptables/nftables/none): silently proceed.
}
async function resolvePublicIp(prompter) {
    const detected = await detectPublicIp();
    if (detected) {
        console.log(`Detected public IP: ${detected}`);
        const ok = await prompter.confirm(`Use ${detected} as the IP DNS should point to?`, true);
        if (ok)
            return detected;
    }
    else {
        console.log('Could not auto-detect a public IP from the usual sources.');
    }
    const typed = (await prompter.ask('Enter the public IPv4 DNS should point to (blank to skip)')).trim();
    if (!typed)
        return null;
    if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(typed)) {
        console.log(`"${typed}" is not a valid IPv4.`);
        return null;
    }
    return typed;
}
async function waitForDnsMatch(domain, publicIp, prompter) {
    for (;;) {
        const resolved = await resolveDomainIps(domain);
        if (resolved && resolved.includes(publicIp)) {
            console.log(`\x1b[32m✓\x1b[0m ${domain} resolves to ${publicIp}.`);
            return;
        }
        console.log(`\n${domain} is not pointing to ${publicIp} yet.`);
        if (resolved && resolved.length > 0) {
            console.log(`  Currently resolves to: ${resolved.join(', ')}`);
        }
        else {
            console.log('  No A record found.');
        }
        console.log('\nAt your domain provider, set:');
        console.log('  Type:  A');
        console.log(`  Name:  ${dnsRecordName(domain)}  \x1b[2m(double-check the field your provider uses)\x1b[0m`);
        console.log(`  Value: ${publicIp}`);
        console.log('  TTL:   60–300 (lower TTL while iterating speeds up retries)');
        console.log('\n\x1b[2mOnboarding queries 1.1.1.1 directly, so a local DNS cache won’t hide a fresh');
        console.log('answer here — but your browser still might. If you also test in a browser,');
        console.log('flush the OS cache (Linux: sudo resolvectl flush-caches  /  macOS: sudo');
        console.log('dscacheutil -flushcache; sudo killall -HUP mDNSResponder).\x1b[0m');
        const choice = (await prompter.ask('Press Enter to re-check, or type "skip" to continue without verifying', '')).trim().toLowerCase();
        if (choice === 'skip' || choice === 's') {
            console.log('Skipping DNS verification.');
            return;
        }
    }
}
function printNginxInstallHint() {
    console.log('\nnginx is not installed. Install it first, then re-run onboarding:');
    console.log('  Debian/Ubuntu: sudo apt install nginx');
    console.log('  RHEL/Fedora:   sudo dnf install nginx');
    console.log('  Arch:          sudo pacman -S nginx');
    console.log('  macOS (brew):  brew install nginx');
}
//# sourceMappingURL=proxySetup.js.map