Files
code/unrealircd-config-generator.html
T
2026-04-11 13:14:50 +02:00

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()">
&nbsp;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">
&nbsp;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 &amp; 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">
&nbsp;${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>