1261 lines
40 KiB
HTML
1261 lines
40 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>UnrealIRCd 6 Config Generator</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/argon2-browser/1.18.0/argon2-bundled.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #0d0f14;
|
|
--bg2: #13161e;
|
|
--bg3: #1a1e29;
|
|
--border: #252a38;
|
|
--border-hi: #3a4257;
|
|
--accent: #4f8ef7;
|
|
--accent2: #7c3aed;
|
|
--accent-g: linear-gradient(135deg, #4f8ef7, #7c3aed);
|
|
--text: #e2e8f0;
|
|
--text-dim: #64748b;
|
|
--text-med: #94a3b8;
|
|
--good: #22c55e;
|
|
--warn: #f59e0b;
|
|
--bad: #ef4444;
|
|
--mono: 'JetBrains Mono', monospace;
|
|
--sans: 'DM Sans', sans-serif;
|
|
--radius: 8px;
|
|
--radius-lg: 14px;
|
|
}
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
font-family: var(--sans);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* ── Layout ── */
|
|
.shell {
|
|
display: grid;
|
|
grid-template-columns: 260px 1fr;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* ── Sidebar ── */
|
|
.sidebar {
|
|
background: var(--bg2);
|
|
border-right: 1px solid var(--border);
|
|
padding: 32px 0;
|
|
position: sticky;
|
|
top: 0;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.sidebar-logo {
|
|
padding: 0 24px 28px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 24px;
|
|
}
|
|
.sidebar-logo h1 {
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
}
|
|
.sidebar-logo p {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
margin-top: 4px;
|
|
font-family: var(--mono);
|
|
}
|
|
|
|
.nav-steps { flex: 1; padding: 0 12px; }
|
|
|
|
.nav-step {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 12px;
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
margin-bottom: 2px;
|
|
border: none;
|
|
background: none;
|
|
width: 100%;
|
|
text-align: left;
|
|
color: var(--text-dim);
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
}
|
|
.nav-step:hover { background: var(--bg3); color: var(--text-med); }
|
|
.nav-step.active { background: var(--bg3); color: var(--text); }
|
|
.nav-step.done .step-num { background: var(--good); color: #fff; }
|
|
.nav-step.active .step-num { background: var(--accent); color: #fff; }
|
|
|
|
.step-num {
|
|
width: 22px; height: 22px;
|
|
border-radius: 50%;
|
|
background: var(--bg3);
|
|
border: 1px solid var(--border-hi);
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
flex-shrink: 0;
|
|
transition: background 0.2s;
|
|
}
|
|
.nav-step.done .step-num::after { content: '✓'; }
|
|
|
|
/* ── Main content ── */
|
|
.main {
|
|
padding: 48px 56px;
|
|
max-width: 860px;
|
|
}
|
|
|
|
.step-panel { display: none; }
|
|
.step-panel.active { display: block; animation: fadeIn 0.2s ease; }
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(6px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.step-header { margin-bottom: 36px; }
|
|
.step-header h2 {
|
|
font-family: var(--mono);
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
margin-bottom: 6px;
|
|
}
|
|
.step-header p { color: var(--text-med); font-size: 14px; }
|
|
|
|
/* ── Form elements ── */
|
|
.field-group { margin-bottom: 28px; }
|
|
.field-group label {
|
|
display: block;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: var(--text-med);
|
|
margin-bottom: 8px;
|
|
font-family: var(--mono);
|
|
}
|
|
.field-group .hint {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
margin-top: 6px;
|
|
font-family: var(--mono);
|
|
}
|
|
.field-group .hint a { color: var(--accent); text-decoration: none; }
|
|
.field-group .hint a:hover { text-decoration: underline; }
|
|
|
|
input[type="text"],
|
|
input[type="password"],
|
|
input[type="number"],
|
|
input[type="email"],
|
|
select,
|
|
textarea {
|
|
width: 100%;
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
color: var(--text);
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
padding: 10px 14px;
|
|
outline: none;
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
appearance: none;
|
|
}
|
|
input:focus, select:focus, textarea:focus {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px rgba(79,142,247,0.12);
|
|
}
|
|
input::placeholder { color: var(--text-dim); }
|
|
select option { background: var(--bg2); }
|
|
textarea { resize: vertical; }
|
|
|
|
.input-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.input-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
|
|
|
/* ── Cloak key row ── */
|
|
.cloak-key-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: stretch;
|
|
margin-bottom: 8px;
|
|
}
|
|
.cloak-key-row input { flex: 1; }
|
|
.cloak-key-row button {
|
|
flex-shrink: 0;
|
|
padding: 10px 14px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* ── Oper blocks ── */
|
|
.oper-card {
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 20px;
|
|
margin-bottom: 16px;
|
|
position: relative;
|
|
}
|
|
.oper-card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.oper-card-header h3 {
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
}
|
|
|
|
/* ── Listen ports ── */
|
|
.port-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 10px 14px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.port-row input[type="number"] { width: 90px; flex-shrink: 0; }
|
|
.port-row .port-label { font-family: var(--mono); font-size: 12px; color: var(--text-dim); flex: 1; }
|
|
.port-check { display: flex; gap: 16px; }
|
|
.port-check label {
|
|
display: flex; align-items: center; gap: 6px;
|
|
font-family: var(--mono); font-size: 12px; color: var(--text-med);
|
|
cursor: pointer; text-transform: none; letter-spacing: 0;
|
|
}
|
|
input[type="checkbox"] {
|
|
width: 14px; height: 14px; accent-color: var(--accent); cursor: pointer;
|
|
}
|
|
|
|
/* ── Buttons ── */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 10px 20px;
|
|
border-radius: var(--radius);
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
transition: opacity 0.15s, transform 0.1s;
|
|
}
|
|
.btn:hover { opacity: 0.85; }
|
|
.btn:active { transform: scale(0.98); }
|
|
.btn-primary {
|
|
background: var(--accent-g);
|
|
color: #fff;
|
|
}
|
|
.btn-secondary {
|
|
background: var(--bg3);
|
|
color: var(--text-med);
|
|
border: 1px solid var(--border-hi);
|
|
}
|
|
.btn-danger {
|
|
background: rgba(239,68,68,0.12);
|
|
color: var(--bad);
|
|
border: 1px solid rgba(239,68,68,0.2);
|
|
}
|
|
.btn-good {
|
|
background: rgba(34,197,94,0.12);
|
|
color: var(--good);
|
|
border: 1px solid rgba(34,197,94,0.2);
|
|
}
|
|
.btn-sm { padding: 6px 12px; font-size: 11px; }
|
|
|
|
.btn-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 36px;
|
|
align-items: center;
|
|
}
|
|
|
|
/* ── Password hash field ── */
|
|
.hash-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
}
|
|
.hash-dot {
|
|
width: 8px; height: 8px; border-radius: 50%;
|
|
background: var(--text-dim);
|
|
flex-shrink: 0;
|
|
}
|
|
.hash-dot.hashing { background: var(--warn); animation: pulse 1s infinite; }
|
|
.hash-dot.done { background: var(--good); }
|
|
.hash-dot.error { background: var(--bad); }
|
|
@keyframes pulse {
|
|
0%,100% { opacity: 1; } 50% { opacity: 0.3; }
|
|
}
|
|
|
|
/* ── Output ── */
|
|
#output-area {
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 24px;
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
line-height: 1.8;
|
|
white-space: pre;
|
|
overflow-x: auto;
|
|
color: #a0aec0;
|
|
max-height: 600px;
|
|
overflow-y: auto;
|
|
}
|
|
#output-area .c-comment { color: #4a5568; }
|
|
#output-area .c-key { color: #63b3ed; }
|
|
#output-area .c-value { color: #f6ad55; }
|
|
#output-area .c-block { color: #68d391; font-weight: 600; }
|
|
#output-area .c-string { color: #fbb6ce; }
|
|
|
|
/* ── Notice box ── */
|
|
.notice {
|
|
background: rgba(79,142,247,0.08);
|
|
border: 1px solid rgba(79,142,247,0.2);
|
|
border-radius: var(--radius);
|
|
padding: 12px 16px;
|
|
font-size: 13px;
|
|
color: var(--text-med);
|
|
margin-bottom: 20px;
|
|
font-family: var(--mono);
|
|
}
|
|
.notice.warn {
|
|
background: rgba(245,158,11,0.08);
|
|
border-color: rgba(245,158,11,0.2);
|
|
color: #fcd34d;
|
|
}
|
|
|
|
/* ── Section divider ── */
|
|
.section-divider {
|
|
border: none;
|
|
border-top: 1px solid var(--border);
|
|
margin: 28px 0;
|
|
}
|
|
|
|
/* ── Responsive ── */
|
|
@media (max-width: 768px) {
|
|
.shell { grid-template-columns: 1fr; }
|
|
.sidebar { display: none; }
|
|
.main { padding: 24px 20px; }
|
|
.input-row, .input-row-3 { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
|
|
<!-- Sidebar -->
|
|
<nav class="sidebar">
|
|
<div class="sidebar-logo">
|
|
<h1>UnrealIRCd 6</h1>
|
|
<p>// config generator</p>
|
|
</div>
|
|
<div class="nav-steps" id="nav-steps"></div>
|
|
</nav>
|
|
|
|
<!-- Main -->
|
|
<main class="main" id="main-content"></main>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
const state = {
|
|
me: { name: 'irc.example.org', info: 'ExampleNET Server', sid: '001' },
|
|
admin: { line1: 'Your Name', line2: 'yourhandle', line3: 'admin@example.org' },
|
|
network: {
|
|
name: 'ExampleNET',
|
|
defaultServer: 'irc.example.org',
|
|
servicesServer: 'services.example.org',
|
|
helpChannel: '#Help',
|
|
cloakPrefix: 'Clk',
|
|
klineAddress: 'admin@example.org',
|
|
cloakKey1: '', cloakKey2: '', cloakKey3: '',
|
|
},
|
|
listen: [
|
|
{ port: 6667, tls: false, serversonly: false },
|
|
{ port: 6697, tls: true, serversonly: false },
|
|
{ port: 6900, tls: true, serversonly: true },
|
|
],
|
|
opers: [
|
|
{ name: 'admin', mask: '*@*', password: '', hash: '', operclass: 'netadmin', vhost: '', swhois: 'is a Network Administrator' }
|
|
],
|
|
services: {
|
|
link: false,
|
|
hostname: 'services.example.org',
|
|
mask: '127.0.0.1',
|
|
password: '',
|
|
ulines: true,
|
|
},
|
|
extras: {
|
|
dronebl: true,
|
|
efnetrbl: true,
|
|
irccloud: true,
|
|
anope: true,
|
|
},
|
|
};
|
|
|
|
// ── Steps definition ───────────────────────────────────────────────────────
|
|
const STEPS = [
|
|
{ id: 'server', label: 'Server Identity' },
|
|
{ id: 'network', label: 'Network Settings' },
|
|
{ id: 'listen', label: 'Listen Ports' },
|
|
{ id: 'opers', label: 'IRC Operators' },
|
|
{ id: 'services', label: 'Services Link' },
|
|
{ id: 'extras', label: 'Extras & Blacklists' },
|
|
{ id: 'output', label: 'Generate Config' },
|
|
];
|
|
|
|
let currentStep = 0;
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
function genCloakKey() {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
const arr = new Uint8Array(80);
|
|
crypto.getRandomValues(arr);
|
|
return Array.from(arr).map(b => chars[b % chars.length]).join('');
|
|
}
|
|
|
|
function v(id) { return document.getElementById(id); }
|
|
|
|
// Argon2id hash — matches UnrealIRCd mkpasswd exactly (m=8192, t=3, p=2)
|
|
async function hashPassword(pass) {
|
|
if (!pass) return '';
|
|
const saltBytes = new Uint8Array(16);
|
|
crypto.getRandomValues(saltBytes);
|
|
const result = await argon2.hash({
|
|
pass,
|
|
salt: saltBytes,
|
|
time: 3,
|
|
mem: 8192,
|
|
parallelism: 2,
|
|
hashLen: 32,
|
|
type: argon2.ArgonType.Argon2id,
|
|
});
|
|
return result.encoded;
|
|
}
|
|
|
|
// ── Render sidebar ─────────────────────────────────────────────────────────
|
|
function renderSidebar() {
|
|
const nav = document.getElementById('nav-steps');
|
|
nav.innerHTML = STEPS.map((s, i) => `
|
|
<button class="nav-step ${i === currentStep ? 'active' : ''} ${i < currentStep ? 'done' : ''}"
|
|
onclick="goStep(${i})">
|
|
<span class="step-num">${i < currentStep ? '' : i + 1}</span>
|
|
${s.label}
|
|
</button>
|
|
`).join('');
|
|
}
|
|
|
|
function goStep(i) {
|
|
// save current step's data before navigating away
|
|
saveCurrentStep();
|
|
currentStep = i;
|
|
renderSidebar();
|
|
renderStep();
|
|
}
|
|
|
|
// ── Step renderers ─────────────────────────────────────────────────────────
|
|
function renderStep() {
|
|
const main = document.getElementById('main-content');
|
|
const s = STEPS[currentStep];
|
|
const renderers = { server, network, listen, opers, services, extras, output };
|
|
main.innerHTML = `<div class="step-panel active">${renderers[s.id]()}</div>`;
|
|
attachListeners();
|
|
}
|
|
|
|
function navButtons(backLabel = 'Back', nextLabel = 'Next →') {
|
|
return `
|
|
<div class="btn-row">
|
|
${currentStep > 0 ? `<button class="btn btn-secondary" onclick="goStep(${currentStep - 1})">${backLabel}</button>` : ''}
|
|
${currentStep < STEPS.length - 1
|
|
? `<button class="btn btn-primary" onclick="goStep(${currentStep + 1})">${nextLabel}</button>`
|
|
: ''}
|
|
</div>`;
|
|
}
|
|
|
|
// Step 1: Server Identity
|
|
function server() {
|
|
const m = state.me; const a = state.admin;
|
|
return `
|
|
<div class="step-header">
|
|
<h2>// server identity</h2>
|
|
<p>Basic identity of this IRC server — what users and /MAP will see.</p>
|
|
</div>
|
|
|
|
<div class="field-group">
|
|
<label>Server Name</label>
|
|
<input type="text" id="me-name" value="${m.name}" placeholder="irc.example.org">
|
|
<div class="hint">Must be a valid FQDN. Used in server-to-server links and shown to clients.</div>
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<div class="field-group">
|
|
<label>Server Info / MOTD Line</label>
|
|
<input type="text" id="me-info" value="${m.info}" placeholder="My IRC Server">
|
|
</div>
|
|
<div class="field-group">
|
|
<label>Server ID (SID)</label>
|
|
<input type="text" id="me-sid" value="${m.sid}" placeholder="001" maxlength="3">
|
|
<div class="hint">Digit + 2 chars. Unique per server on network.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="section-divider">
|
|
|
|
<div class="step-header" style="margin-bottom:20px">
|
|
<h2>// admin contact</h2>
|
|
<p>Shown when users type /ADMIN.</p>
|
|
</div>
|
|
|
|
<div class="field-group">
|
|
<label>Admin Name</label>
|
|
<input type="text" id="admin-line1" value="${a.line1}" placeholder="Bob Smith">
|
|
</div>
|
|
<div class="input-row">
|
|
<div class="field-group">
|
|
<label>Admin Handle / Nick</label>
|
|
<input type="text" id="admin-line2" value="${a.line2}" placeholder="bob">
|
|
</div>
|
|
<div class="field-group">
|
|
<label>Admin Email / URL</label>
|
|
<input type="text" id="admin-line3" value="${a.line3}" placeholder="admin@example.org">
|
|
</div>
|
|
</div>
|
|
${navButtons('', 'Network Settings →')}`;
|
|
}
|
|
|
|
// Step 2: Network Settings
|
|
function network() {
|
|
const n = state.network;
|
|
return `
|
|
<div class="step-header">
|
|
<h2>// network settings</h2>
|
|
<p>Network-wide configuration — must match across all servers.</p>
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<div class="field-group">
|
|
<label>Network Name</label>
|
|
<input type="text" id="net-name" value="${n.name}" placeholder="ExampleNET">
|
|
</div>
|
|
<div class="field-group">
|
|
<label>Default Server</label>
|
|
<input type="text" id="net-default" value="${n.defaultServer}" placeholder="irc.example.org">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<div class="field-group">
|
|
<label>Services Server</label>
|
|
<input type="text" id="net-services" value="${n.servicesServer}" placeholder="services.example.org">
|
|
</div>
|
|
<div class="field-group">
|
|
<label>Help Channel</label>
|
|
<input type="text" id="net-help" value="${n.helpChannel}" placeholder="#Help">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<div class="field-group">
|
|
<label>Cloak Prefix</label>
|
|
<input type="text" id="net-cloak-prefix" value="${n.cloakPrefix}" placeholder="Clk">
|
|
<div class="hint">Prefix for cloaked hostnames, e.g. <code>Clk-abc123.example.org</code></div>
|
|
</div>
|
|
<div class="field-group">
|
|
<label>K-line Address</label>
|
|
<input type="text" id="net-kline" value="${n.klineAddress}" placeholder="admin@example.org">
|
|
<div class="hint">Shown to banned users — email or URL.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="section-divider">
|
|
|
|
<div class="step-header" style="margin-bottom:16px">
|
|
<h2>// cloak keys</h2>
|
|
<p>3 random strings of 80 a-zA-Z0-9 characters. Must be identical on all servers. Keep secret.</p>
|
|
</div>
|
|
|
|
<div class="notice warn">⚠ These keys are generated fresh each time you click Generate. Copy them into all servers on your network — they cannot be changed later without breaking cloaks.</div>
|
|
|
|
${[1,2,3].map(i => `
|
|
<div class="field-group">
|
|
<label>Cloak Key ${i}</label>
|
|
<div class="cloak-key-row">
|
|
<input type="text" id="cloak-key-${i}" value="${n['cloakKey'+i] || ''}" placeholder="Click Generate or paste your own">
|
|
<button class="btn btn-secondary btn-sm" onclick="genOneCloak(${i})">Generate</button>
|
|
</div>
|
|
</div>`).join('')}
|
|
|
|
<button class="btn btn-secondary" onclick="genAllCloaks()" style="margin-bottom:8px">⚡ Generate All 3 Keys</button>
|
|
|
|
${navButtons('← Server Identity', 'Listen Ports →')}`;
|
|
}
|
|
|
|
// Step 3: Listen ports
|
|
function listen() {
|
|
const ports = state.listen;
|
|
return `
|
|
<div class="step-header">
|
|
<h2>// listen ports</h2>
|
|
<p>Define which ports UnrealIRCd listens on. Bind IP defaults to <code>*</code> (all interfaces).</p>
|
|
</div>
|
|
|
|
<div class="notice">Port 6667 (plaintext) is only for testing. Use TLS 6697 for production.</div>
|
|
|
|
<div id="port-list">
|
|
${ports.map((p, i) => portRow(p, i)).join('')}
|
|
</div>
|
|
|
|
<button class="btn btn-secondary" onclick="addPort()" style="margin-top:8px">+ Add Port</button>
|
|
|
|
${navButtons('← Network Settings', 'IRC Operators →')}`;
|
|
}
|
|
|
|
function portRow(p, i) {
|
|
return `
|
|
<div class="port-row" id="port-row-${i}">
|
|
<span class="port-label">Port</span>
|
|
<input type="number" id="port-num-${i}" value="${p.port}" min="1" max="65535"
|
|
onchange="state.listen[${i}].port = parseInt(this.value)||6667">
|
|
<div class="port-check">
|
|
<label><input type="checkbox" id="port-tls-${i}" ${p.tls ? 'checked' : ''}
|
|
onchange="state.listen[${i}].tls = this.checked"> TLS</label>
|
|
<label><input type="checkbox" id="port-srv-${i}" ${p.serversonly ? 'checked' : ''}
|
|
onchange="state.listen[${i}].serversonly = this.checked"> Servers only</label>
|
|
</div>
|
|
${state.listen.length > 1
|
|
? `<button class="btn btn-danger btn-sm" onclick="removePort(${i})">✕</button>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
// Step 4: Opers
|
|
function opers() {
|
|
return `
|
|
<div class="step-header">
|
|
<h2>// irc operators</h2>
|
|
<p>Passwords are hashed with Argon2id (m=8192, t=3, p=2) — identical to <code>./unrealircd mkpasswd</code>.</p>
|
|
</div>
|
|
|
|
<div id="oper-list">
|
|
${state.opers.map((o, i) => operCard(o, i)).join('')}
|
|
</div>
|
|
|
|
<button class="btn btn-secondary" onclick="addOper()">+ Add Oper</button>
|
|
|
|
${navButtons('← Listen Ports', 'Services Link →')}`;
|
|
}
|
|
|
|
function operCard(o, i) {
|
|
return `
|
|
<div class="oper-card" id="oper-card-${i}">
|
|
<div class="oper-card-header">
|
|
<h3>oper { ${o.name || 'unnamed'} }</h3>
|
|
${state.opers.length > 1 ? `<button class="btn btn-danger btn-sm" onclick="removeOper(${i})">Remove</button>` : ''}
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<div class="field-group" style="margin-bottom:0">
|
|
<label>Oper Name</label>
|
|
<input type="text" id="oper-name-${i}" value="${o.name}"
|
|
oninput="state.opers[${i}].name = this.value; document.querySelector('#oper-card-${i} h3').textContent = 'oper { ' + (this.value||'unnamed') + ' }'">
|
|
</div>
|
|
<div class="field-group" style="margin-bottom:0">
|
|
<label>Mask</label>
|
|
<input type="text" id="oper-mask-${i}" value="${o.mask}" placeholder="*@*">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field-group" style="margin-top:16px">
|
|
<label>Password</label>
|
|
<input type="password" id="oper-pass-${i}" value="${o.password}"
|
|
placeholder="Enter password — will be hashed with Argon2id"
|
|
oninput="scheduleHash(${i}, this.value)">
|
|
<div class="hash-status">
|
|
<div class="hash-dot" id="hash-dot-${i}"></div>
|
|
<span id="hash-status-${i}" style="color:var(--text-dim)">
|
|
${o.hash ? 'Hashed ✓' : 'Enter a password to hash'}
|
|
</span>
|
|
</div>
|
|
${o.hash ? `<div style="font-family:var(--mono);font-size:10px;color:var(--text-dim);margin-top:6px;word-break:break-all">${o.hash}</div>` : ''}
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<div class="field-group" style="margin-bottom:0">
|
|
<label>Operclass</label>
|
|
<select id="oper-class-${i}" onchange="state.opers[${i}].operclass = this.value">
|
|
${['netadmin','netadmin-with-override','coadmin','admin','services-admin','globop','helpop','locop'].map(c =>
|
|
`<option value="${c}" ${o.operclass === c ? 'selected' : ''}>${c}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="field-group" style="margin-bottom:0">
|
|
<label>vHost (optional)</label>
|
|
<input type="text" id="oper-vhost-${i}" value="${o.vhost}" placeholder="netadmin.example.org">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field-group" style="margin-top:16px;margin-bottom:0">
|
|
<label>swhois (optional)</label>
|
|
<input type="text" id="oper-swhois-${i}" value="${o.swhois}" placeholder="is a Network Administrator">
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Step 5: Services
|
|
function services() {
|
|
const sv = state.services;
|
|
return `
|
|
<div class="step-header">
|
|
<h2>// services link</h2>
|
|
<p>Optional: configure a link block for your services (Anope, Atheme, etc.).</p>
|
|
</div>
|
|
|
|
<div class="field-group">
|
|
<label><input type="checkbox" id="sv-enable" ${sv.link ? 'checked' : ''}
|
|
onchange="state.services.link = this.checked; renderStep()">
|
|
Include services link block</label>
|
|
</div>
|
|
|
|
${sv.link ? `
|
|
<div class="input-row">
|
|
<div class="field-group">
|
|
<label>Services Hostname</label>
|
|
<input type="text" id="sv-host" value="${sv.hostname}" placeholder="services.example.org">
|
|
</div>
|
|
<div class="field-group">
|
|
<label>Incoming Mask</label>
|
|
<input type="text" id="sv-mask" value="${sv.mask}" placeholder="127.0.0.1">
|
|
<div class="hint">IP the services daemon connects from.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field-group">
|
|
<label>Link Password (plaintext in link block)</label>
|
|
<input type="password" id="sv-pass" value="${sv.password}" placeholder="changemeplease">
|
|
<div class="hint">⚠ Services link passwords are plaintext in the config by convention.</div>
|
|
</div>
|
|
|
|
<div class="field-group">
|
|
<label><input type="checkbox" id="sv-ulines" ${sv.ulines ? 'checked' : ''}
|
|
onchange="state.services.ulines = this.checked">
|
|
Add ulines { } block for this services server</label>
|
|
</div>
|
|
|
|
<div class="notice">Make sure <code>aliases/anope.conf</code> (or the equivalent) is included — the generator includes it by default.</div>
|
|
` : `<div class="notice">No services link block will be generated. You can add one manually later.</div>`}
|
|
|
|
${navButtons('← IRC Operators', 'Extras →')}`;
|
|
}
|
|
|
|
// Step 6: Extras
|
|
function extras() {
|
|
const e = state.extras;
|
|
const checks = [
|
|
['dronebl', 'DroneBL blacklist', 'Popular proxy/drone DNSBL — glines matching IPs for 24h'],
|
|
['efnetrbl', 'EFnet RBL blacklist', 'Proxy/TOR detection — glines matching IPs for 24h'],
|
|
['irccloud', 'IRCCloud exempt ban', 'Exempts *.irccloud.com from maxperip & connect-flood'],
|
|
['anope', 'Include anope.conf', 'Loads service command aliases (NS, CS, HS, etc.)'],
|
|
];
|
|
return `
|
|
<div class="step-header">
|
|
<h2>// extras & blacklists</h2>
|
|
<p>Optional but recommended additions. All have safe defaults.</p>
|
|
</div>
|
|
|
|
${checks.map(([key, label, hint]) => `
|
|
<div class="field-group">
|
|
<label><input type="checkbox" id="ex-${key}" ${e[key] ? 'checked' : ''}
|
|
onchange="state.extras['${key}'] = this.checked">
|
|
${label}</label>
|
|
<div class="hint">${hint}</div>
|
|
</div>`).join('')}
|
|
|
|
${navButtons('← Services Link', '⚡ Generate Config →')}`;
|
|
}
|
|
|
|
// Step 7: Output
|
|
function output() {
|
|
return `
|
|
<div class="step-header">
|
|
<h2>// generated config</h2>
|
|
<p>Review the output below, then download. Copy to <code>conf/unrealircd.conf</code>.</p>
|
|
</div>
|
|
|
|
<div class="notice warn" id="output-warnings"></div>
|
|
|
|
<div style="display:flex;gap:12px;margin-bottom:16px">
|
|
<button class="btn btn-primary" onclick="downloadConf()">⬇ Download unrealircd.conf</button>
|
|
<button class="btn btn-secondary" onclick="copyConf()">Copy to clipboard</button>
|
|
</div>
|
|
|
|
<pre id="output-area">Generating...</pre>`;
|
|
}
|
|
|
|
// ── Config generator ───────────────────────────────────────────────────────
|
|
function buildConfig() {
|
|
const m = state.me;
|
|
const a = state.admin;
|
|
const n = state.network;
|
|
const sv = state.services;
|
|
const e = state.extras;
|
|
|
|
const warnings = [];
|
|
if (!n.cloakKey1 || !n.cloakKey2 || !n.cloakKey3) warnings.push('⚠ Cloak keys are empty — generate them in the Network Settings step.');
|
|
state.opers.forEach((o, i) => {
|
|
if (!o.hash) warnings.push(`⚠ Oper "${o.name || i}" has no password hash — enter a password in the IRC Operators step.`);
|
|
});
|
|
|
|
const w = document.getElementById('output-warnings');
|
|
if (w) {
|
|
w.style.display = warnings.length ? 'block' : 'none';
|
|
w.innerHTML = warnings.join('<br>');
|
|
}
|
|
|
|
const listenBlocks = state.listen.map(p => {
|
|
const opts = [p.tls && 'tls', p.serversonly && 'serversonly'].filter(Boolean);
|
|
return `listen {\n\tip *;\n\tport ${p.port};${opts.length ? `\n\toptions { ${opts.join('; ')}; }` : ''}\n}`;
|
|
}).join('\n\n');
|
|
|
|
const operBlocks = state.opers.map(o => {
|
|
const hash = o.hash || '"REPLACE_WITH_MKPASSWD_OUTPUT"';
|
|
return [
|
|
`oper ${o.name} {`,
|
|
`\tclass opers;`,
|
|
`\tmask ${o.mask};`,
|
|
`\tpassword "${hash}";`,
|
|
`\toperclass ${o.operclass};`,
|
|
o.swhois ? `\tswhois "${o.swhois}";` : null,
|
|
o.vhost ? `\tvhost ${o.vhost};` : null,
|
|
`}`,
|
|
].filter(l => l !== null).join('\n');
|
|
}).join('\n\n');
|
|
|
|
const servicesBlock = sv.link ? [
|
|
`link ${sv.hostname}`,
|
|
`{`,
|
|
`\tincoming {`,
|
|
`\t\tmask ${sv.mask};`,
|
|
`\t}`,
|
|
``,
|
|
`\tpassword "${sv.password || 'changemeplease'}";`,
|
|
``,
|
|
`\tclass servers;`,
|
|
`}`,
|
|
sv.ulines ? `\nulines {\n\t${sv.hostname};\n}` : '',
|
|
].join('\n') : `/* No services link configured */`;
|
|
|
|
const dronebl = e.dronebl ? `blacklist dronebl {
|
|
\tdns {
|
|
\t\tname dnsbl.dronebl.org;
|
|
\t\ttype record;
|
|
\t\treply { 3; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; }
|
|
\t}
|
|
\taction gline;
|
|
\tban-time 24h;
|
|
\treason "Proxy/Drone detected. Check https://dronebl.org/lookup?ip=$ip for details.";
|
|
}` : '';
|
|
|
|
const efnetrbl = e.efnetrbl ? `blacklist efnetrbl {
|
|
\tdns {
|
|
\t\tname rbl.efnetrbl.org;
|
|
\t\ttype record;
|
|
\t\treply { 1; 4; 5; }
|
|
\t}
|
|
\taction gline;
|
|
\tban-time 24h;
|
|
\treason "Proxy/Drone/TOR detected. Check https://rbl.efnetrbl.org/?i=$ip for details.";
|
|
}` : '';
|
|
|
|
const irccloud = e.irccloud ? `except ban {
|
|
\tmask *.irccloud.com;
|
|
\ttype { maxperip; connect-flood; }
|
|
}` : '';
|
|
|
|
const now = new Date().toISOString().slice(0, 10);
|
|
|
|
return `/* UnrealIRCd 6 Configuration
|
|
* Generated by UnrealIRCd Config Generator
|
|
* Date: ${now}
|
|
*
|
|
* Documentation: https://www.unrealircd.org/docs/UnrealIRCd_6_documentation
|
|
*/
|
|
|
|
/* ── Modules ─────────────────────────────────────────── */
|
|
include "modules.default.conf";
|
|
include "help/help.conf";
|
|
include "badwords.conf";
|
|
include "operclass.default.conf";
|
|
include "snomasks.default.conf";
|
|
${e.anope ? 'include "aliases/anope.conf";' : '/* include "aliases/anope.conf"; */'}
|
|
|
|
loadmodule "cloak_sha256";
|
|
|
|
/* ── Server Identity ─────────────────────────────────── */
|
|
me {
|
|
\tname "${m.name}";
|
|
\tinfo "${m.info}";
|
|
\tsid "${m.sid}";
|
|
}
|
|
|
|
admin {
|
|
\t"${a.line1}";
|
|
\t"${a.line2}";
|
|
\t"${a.line3}";
|
|
}
|
|
|
|
/* ── Connection Classes ──────────────────────────────── */
|
|
class clients {
|
|
\tpingfreq 90;
|
|
\tmaxclients 1000;
|
|
\tsendq 200k;
|
|
\trecvq 8000;
|
|
}
|
|
|
|
class opers {
|
|
\tpingfreq 90;
|
|
\tmaxclients 50;
|
|
\tsendq 1M;
|
|
\trecvq 8000;
|
|
}
|
|
|
|
class servers {
|
|
\tpingfreq 60;
|
|
\tconnfreq 15;
|
|
\tmaxclients 10;
|
|
\tsendq 20M;
|
|
}
|
|
|
|
/* ── Allow Blocks ────────────────────────────────────── */
|
|
allow {
|
|
\tmask *;
|
|
\tclass clients;
|
|
\tmaxperip 3;
|
|
}
|
|
|
|
/* ── Listen Ports ────────────────────────────────────── */
|
|
${listenBlocks}
|
|
|
|
/* ── IRC Operators ───────────────────────────────────── */
|
|
${operBlocks}
|
|
|
|
/* ── Die/Restart Passwords ───────────────────────────── */
|
|
drpass {
|
|
\trestart "restart";
|
|
\tdie "die";
|
|
}
|
|
|
|
/* ── Logging ─────────────────────────────────────────── */
|
|
log {
|
|
\tsource {
|
|
\t\tall;
|
|
\t\t!debug;
|
|
\t\t!join.LOCAL_CLIENT_JOIN;
|
|
\t\t!join.REMOTE_CLIENT_JOIN;
|
|
\t\t!part.LOCAL_CLIENT_PART;
|
|
\t\t!part.REMOTE_CLIENT_PART;
|
|
\t\t!kick.LOCAL_CLIENT_KICK;
|
|
\t\t!kick.REMOTE_CLIENT_KICK;
|
|
\t}
|
|
\tdestination {
|
|
\t\tfile "ircd.log" { maxsize 100M; }
|
|
\t}
|
|
}
|
|
|
|
/* ── Services Link ───────────────────────────────────── */
|
|
${servicesBlock}
|
|
|
|
/* ── Blacklists ──────────────────────────────────────── */
|
|
${dronebl}
|
|
${efnetrbl ? '\n' + efnetrbl : ''}
|
|
|
|
/* ── Ban Exceptions ──────────────────────────────────── */
|
|
${irccloud}
|
|
|
|
/* ── Network Configuration ───────────────────────────── */
|
|
set {
|
|
\tnetwork-name\t\t"${n.name}";
|
|
\tdefault-server\t\t"${n.defaultServer}";
|
|
\tservices-server\t\t"${n.servicesServer}";
|
|
|
|
\thelp-channel\t\t"${n.helpChannel}";
|
|
\tcloak-prefix\t\t"${n.cloakPrefix}";
|
|
\tprefix-quit\t\t"Quit";
|
|
|
|
\tcloak-keys {
|
|
\t\t"${n.cloakKey1 || 'REPLACE_ME_GENERATE_WITH_unrealircd_gencloak_1'}";
|
|
\t\t"${n.cloakKey2 || 'REPLACE_ME_GENERATE_WITH_unrealircd_gencloak_2'}";
|
|
\t\t"${n.cloakKey3 || 'REPLACE_ME_GENERATE_WITH_unrealircd_gencloak_3'}";
|
|
\t}
|
|
}
|
|
|
|
/* ── Server Configuration ────────────────────────────── */
|
|
set {
|
|
\tkline-address\t"${n.klineAddress}";
|
|
|
|
\tmodes-on-connect\t"+ixw";
|
|
\tmodes-on-oper\t\t"+xws";
|
|
\tmodes-on-join\t\t"+nt";
|
|
\toper-auto-join\t\t"#opers";
|
|
|
|
\toptions {
|
|
\t\thide-ulines;
|
|
\t\tshow-connect-info;
|
|
\t}
|
|
|
|
\tmaxchannelsperuser 10;
|
|
\tanti-spam-quit-message-time 10s;
|
|
|
|
\tanti-flood {
|
|
\t\tchannel {
|
|
\t\t\tdefault-profile normal;
|
|
\t\t}
|
|
\t}
|
|
|
|
\tspamfilter {
|
|
\t\tban-time 1d;
|
|
\t\tban-reason "Spam/Advertising";
|
|
\t\tvirus-help-channel "${n.helpChannel}";
|
|
\t}
|
|
|
|
\trestrict-commands {
|
|
\t\tlist {
|
|
\t\t\texcept {
|
|
\t\t\t\tconnect-time 60;
|
|
\t\t\t\tidentified yes;
|
|
\t\t\t\treputation-score 24;
|
|
\t\t\t}
|
|
\t\t}
|
|
\t\tinvite {
|
|
\t\t\texcept {
|
|
\t\t\t\tconnect-time 120;
|
|
\t\t\t\tidentified yes;
|
|
\t\t\t\treputation-score 24;
|
|
\t\t\t}
|
|
\t\t}
|
|
\t}
|
|
}
|
|
|
|
/* ── Connection Throttling ───────────────────────────── */
|
|
set {
|
|
\tconnthrottle {
|
|
\t\texcept {
|
|
\t\t\treputation-score 24;
|
|
\t\t\tidentified yes;
|
|
\t\t}
|
|
\t\tnew-users {
|
|
\t\t\tlocal-throttle 20:60;
|
|
\t\t\tglobal-throttle 30:60;
|
|
\t\t}
|
|
\t\tdisabled-when {
|
|
\t\t\treputation-gathering 1w;
|
|
\t\t\tstart-delay 3m;
|
|
\t\t}
|
|
\t}
|
|
}
|
|
`;
|
|
}
|
|
|
|
// ── Save step data ─────────────────────────────────────────────────────────
|
|
function saveCurrentStep() {
|
|
const id = STEPS[currentStep].id;
|
|
|
|
if (id === 'server') {
|
|
state.me.name = v('me-name')?.value || state.me.name;
|
|
state.me.info = v('me-info')?.value || state.me.info;
|
|
state.me.sid = v('me-sid')?.value || state.me.sid;
|
|
state.admin.line1 = v('admin-line1')?.value || state.admin.line1;
|
|
state.admin.line2 = v('admin-line2')?.value || state.admin.line2;
|
|
state.admin.line3 = v('admin-line3')?.value || state.admin.line3;
|
|
}
|
|
|
|
if (id === 'network') {
|
|
state.network.name = v('net-name')?.value || state.network.name;
|
|
state.network.defaultServer = v('net-default')?.value || state.network.defaultServer;
|
|
state.network.servicesServer= v('net-services')?.value || state.network.servicesServer;
|
|
state.network.helpChannel = v('net-help')?.value || state.network.helpChannel;
|
|
state.network.cloakPrefix = v('net-cloak-prefix')?.value || state.network.cloakPrefix;
|
|
state.network.klineAddress = v('net-kline')?.value || state.network.klineAddress;
|
|
state.network.cloakKey1 = v('cloak-key-1')?.value || state.network.cloakKey1;
|
|
state.network.cloakKey2 = v('cloak-key-2')?.value || state.network.cloakKey2;
|
|
state.network.cloakKey3 = v('cloak-key-3')?.value || state.network.cloakKey3;
|
|
}
|
|
|
|
if (id === 'listen') {
|
|
state.listen.forEach((p, i) => {
|
|
p.port = parseInt(v(`port-num-${i}`)?.value) || p.port;
|
|
p.tls = v(`port-tls-${i}`)?.checked ?? p.tls;
|
|
p.serversonly = v(`port-srv-${i}`)?.checked ?? p.serversonly;
|
|
});
|
|
}
|
|
|
|
if (id === 'opers') {
|
|
state.opers.forEach((o, i) => {
|
|
o.name = v(`oper-name-${i}`)?.value || o.name;
|
|
o.mask = v(`oper-mask-${i}`)?.value || o.mask;
|
|
o.vhost = v(`oper-vhost-${i}`)?.value || '';
|
|
o.swhois = v(`oper-swhois-${i}`)?.value || '';
|
|
o.operclass= v(`oper-class-${i}`)?.value || o.operclass;
|
|
});
|
|
}
|
|
|
|
if (id === 'services') {
|
|
if (state.services.link) {
|
|
state.services.hostname = v('sv-host')?.value || state.services.hostname;
|
|
state.services.mask = v('sv-mask')?.value || state.services.mask;
|
|
state.services.password = v('sv-pass')?.value || '';
|
|
state.services.ulines = v('sv-ulines')?.checked ?? true;
|
|
}
|
|
}
|
|
|
|
if (id === 'extras') {
|
|
['dronebl','efnetrbl','irccloud','anope'].forEach(k => {
|
|
state.extras[k] = v(`ex-${k}`)?.checked ?? state.extras[k];
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Port management ────────────────────────────────────────────────────────
|
|
function addPort() {
|
|
saveCurrentStep();
|
|
state.listen.push({ port: 6668, tls: false, serversonly: false });
|
|
renderStep();
|
|
}
|
|
function removePort(i) {
|
|
saveCurrentStep();
|
|
state.listen.splice(i, 1);
|
|
renderStep();
|
|
}
|
|
|
|
// ── Oper management ────────────────────────────────────────────────────────
|
|
function addOper() {
|
|
saveCurrentStep();
|
|
state.opers.push({ name: 'newoper', mask: '*@*', password: '', hash: '', operclass: 'netadmin', vhost: '', swhois: '' });
|
|
renderStep();
|
|
}
|
|
function removeOper(i) {
|
|
saveCurrentStep();
|
|
state.opers.splice(i, 1);
|
|
renderStep();
|
|
}
|
|
|
|
// ── Cloak key helpers ──────────────────────────────────────────────────────
|
|
function genOneCloak(n) {
|
|
const el = v(`cloak-key-${n}`);
|
|
if (el) el.value = genCloakKey();
|
|
}
|
|
function genAllCloaks() {
|
|
[1,2,3].forEach(n => genOneCloak(n));
|
|
}
|
|
|
|
// ── Argon2 hashing with debounce ───────────────────────────────────────────
|
|
const hashTimers = {};
|
|
function scheduleHash(i, pass) {
|
|
state.opers[i].password = pass;
|
|
clearTimeout(hashTimers[i]);
|
|
const dot = v(`hash-dot-${i}`);
|
|
const status = v(`hash-status-${i}`);
|
|
if (!pass) {
|
|
state.opers[i].hash = '';
|
|
dot.className = 'hash-dot';
|
|
status.textContent = 'Enter a password to hash';
|
|
return;
|
|
}
|
|
dot.className = 'hash-dot hashing';
|
|
status.textContent = 'Waiting…';
|
|
hashTimers[i] = setTimeout(async () => {
|
|
dot.className = 'hash-dot hashing';
|
|
status.textContent = 'Hashing with Argon2id…';
|
|
try {
|
|
const hash = await hashPassword(pass);
|
|
state.opers[i].hash = hash;
|
|
dot.className = 'hash-dot done';
|
|
status.textContent = 'Hashed ✓';
|
|
// show the hash
|
|
const card = v(`oper-card-${i}`);
|
|
let hashDiv = card.querySelector('.hash-preview');
|
|
if (!hashDiv) {
|
|
hashDiv = document.createElement('div');
|
|
hashDiv.className = 'hash-preview';
|
|
hashDiv.style.cssText = 'font-family:var(--mono);font-size:10px;color:var(--text-dim);margin-top:6px;word-break:break-all';
|
|
v(`hash-status-${i}`).parentElement.after(hashDiv);
|
|
}
|
|
hashDiv.textContent = hash;
|
|
} catch(err) {
|
|
dot.className = 'hash-dot error';
|
|
status.textContent = 'Hashing failed: ' + err.message;
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
// ── Output helpers ─────────────────────────────────────────────────────────
|
|
let _lastConf = '';
|
|
|
|
function renderOutput() {
|
|
const area = v('output-area');
|
|
if (!area) return;
|
|
_lastConf = buildConfig();
|
|
area.textContent = _lastConf;
|
|
}
|
|
|
|
function downloadConf() {
|
|
_lastConf = buildConfig();
|
|
const blob = new Blob([_lastConf], { type: 'text/plain' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'unrealircd.conf';
|
|
a.click();
|
|
}
|
|
|
|
function copyConf() {
|
|
_lastConf = buildConfig();
|
|
navigator.clipboard.writeText(_lastConf).then(() => {
|
|
const btn = event.target;
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => btn.textContent = 'Copy to clipboard', 2000);
|
|
});
|
|
}
|
|
|
|
// ── Attach extra listeners for output step ─────────────────────────────────
|
|
function attachListeners() {
|
|
if (STEPS[currentStep].id === 'output') {
|
|
renderOutput();
|
|
}
|
|
}
|
|
|
|
// ── Boot ───────────────────────────────────────────────────────────────────
|
|
renderSidebar();
|
|
renderStep();
|
|
</script>
|
|
</body>
|
|
</html>
|