From d1ec6962dd74f5bc0d64d60d9087c6ef2c95578b Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 30 Aug 2026 18:54:22 +0000 Subject: [PATCH] redesign: light project site + live network status from a lusers probe --- Cargo.lock | 1 + Cargo.toml | 2 +- src/main.rs | 38 +++++-- src/stats.rs | 101 +++++++++++++++++ static/favicon.svg | 9 +- static/style.css | 245 +++++++++++++++++++++------------------- templates/base.html | 95 ++++++++-------- templates/connect.html | 65 +++++------ templates/features.html | 111 ++++++++++-------- templates/index.html | 88 +++++++++++---- 10 files changed, 473 insertions(+), 282 deletions(-) create mode 100644 src/stats.rs diff --git a/Cargo.lock b/Cargo.lock index e088c5d..f52d4dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,6 +717,7 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index 5b81ece..2130357 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" [dependencies] axum = "0.7" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "io-util", "time"] } askama = "0.12" tower-http = { version = "0.6", features = ["trace", "compression-gzip"] } tracing = "0.1" diff --git a/src/main.rs b/src/main.rs index e8a922b..515d536 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ -//! The echoIRCd project website: a small Axum server that renders a handful of -//! compile-time Askama templates. Templates and static assets are baked into the -//! binary, so the deployed artifact is fully self-contained. +//! The echoIRCd project website: an Axum server rendering compile-time Askama +//! templates, with a small live "network status" pulled from the running ircd. + +mod stats; use askama::Template; use axum::{ + extract::State, http::{header, StatusCode}, response::{Html, IntoResponse, Response}, routing::get, @@ -12,10 +14,27 @@ use axum::{ use std::net::SocketAddr; use tower_http::{compression::CompressionLayer, trace::TraceLayer}; +/// Display-ready snapshot of the live network state for templates. +struct Status { + online: bool, + users: String, + version: String, +} + +fn view(shared: &stats::Shared) -> Status { + let s = shared.read().ok().map(|g| g.clone()).unwrap_or_default(); + Status { + online: s.online, + users: s.users.map(|n| n.to_string()).unwrap_or_else(|| "—".into()), + version: s.version.unwrap_or_else(|| "5.0.0".into()), + } +} + #[derive(Template)] #[template(path = "index.html")] struct IndexTemplate { active: &'static str, + status: Status, } #[derive(Template)] @@ -30,7 +49,6 @@ struct ConnectTemplate { active: &'static str, } -/// Render a template to an HTML response, or a 500 if rendering fails. fn page(t: T) -> Response { match t.render() { Ok(body) => Html(body).into_response(), @@ -41,8 +59,8 @@ fn page(t: T) -> Response { } } -async fn index() -> Response { - page(IndexTemplate { active: "home" }) +async fn index(State(shared): State) -> Response { + page(IndexTemplate { active: "home", status: view(&shared) }) } async fn features() -> Response { page(FeaturesTemplate { active: "features" }) @@ -66,7 +84,6 @@ async fn favicon() -> impl IntoResponse { async fn health() -> &'static str { "ok" } - async fn not_found() -> Response { ( StatusCode::NOT_FOUND, @@ -84,6 +101,9 @@ async fn main() { ) .init(); + let shared: stats::Shared = Default::default(); + stats::spawn_updater(shared.clone()); + let app = Router::new() .route("/", get(index)) .route("/features", get(features)) @@ -93,7 +113,8 @@ async fn main() { .route("/health", get(health)) .fallback(not_found) .layer(CompressionLayer::new()) - .layer(TraceLayer::new_for_http()); + .layer(TraceLayer::new_for_http()) + .with_state(shared); let addr: SocketAddr = std::env::var("ECHO_WEB_ADDR") .unwrap_or_else(|_| "127.0.0.1:8099".into()) @@ -111,7 +132,6 @@ async fn main() { .expect("server error"); } -/// Resolve on SIGINT (Ctrl-C) or SIGTERM (systemd stop) for a clean shutdown. async fn shutdown() { use tokio::signal::unix::{signal, SignalKind}; let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..8266d83 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,101 @@ +//! Live network status, polled from the running ircd. A background task connects +//! to the local daemon as an ordinary client every minute, runs LUSERS/VERSION, +//! parses the reply, and stores it. If the daemon is unreachable the state simply +//! reads "offline" — it never blocks or breaks page rendering. + +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::time::{sleep, timeout}; + +#[derive(Clone, Default)] +pub struct NetStats { + pub online: bool, + pub users: Option, + pub version: Option, +} + +pub type Shared = Arc>; + +/// Spawn the background poller. Refreshes every 60s. +pub fn spawn_updater(shared: Shared) { + tokio::spawn(async move { + loop { + let next = match timeout(Duration::from_secs(7), probe()).await { + Ok(Some(s)) => s, + _ => NetStats::default(), + }; + if let Ok(mut g) = shared.write() { + *g = next; + } + sleep(Duration::from_secs(60)).await; + } + }); +} + +async fn probe() -> Option { + let addr = std::env::var("ECHO_IRCD_ADDR").unwrap_or_else(|_| "127.0.0.1:6667".into()); + let stream = TcpStream::connect(&addr).await.ok()?; + let (rd, mut wr) = stream.into_split(); + wr.write_all(b"NICK echostat\r\nUSER echostat 0 * :echo website status\r\n") + .await + .ok()?; + + let mut lines = BufReader::new(rd).lines(); + let (mut users, mut version, mut asked) = (None, None, false); + + for _ in 0..100 { + let line = match lines.next_line().await { + Ok(Some(l)) => l, + _ => break, + }; + let line = line.trim_end_matches('\r'); + if let Some(rest) = line.strip_prefix("PING ") { + let _ = wr.write_all(format!("PONG {rest}\r\n").as_bytes()).await; + continue; + } + match numeric(line) { + Some("001") if !asked => { + let _ = wr.write_all(b"LUSERS\r\nVERSION\r\n").await; + asked = true; + } + Some("251") => users = users.or_else(|| parse_users(line)), + Some("351") => version = version.or_else(|| parse_version(line)), + _ => {} + } + if users.is_some() && version.is_some() { + break; + } + } + let _ = wr.write_all(b"QUIT :bye\r\n").await; + Some(NetStats { online: true, users, version }) +} + +/// The three-digit numeric of an IRC line (`:src 251 nick …`), if any. +fn numeric(line: &str) -> Option<&str> { + let mut it = line.split(' '); + let first = it.next()?; + let code = if first.starts_with(':') { it.next()? } else { first }; + (code.len() == 3 && code.bytes().all(|b| b.is_ascii_digit())).then_some(code) +} + +/// RPL_LUSERCLIENT trailing: "There are 5 users on 1 server" → first number. +fn parse_users(line: &str) -> Option { + line.splitn(2, " :") + .nth(1)? + .split_whitespace() + .find_map(|w| w.parse::().ok()) +} + +/// RPL_VERSION trailing: "echoircd 5.0.0 · …" → "5.0.0". +fn parse_version(line: &str) -> Option { + let tail = line.splitn(2, " :").nth(1)?; + let mut it = tail.split_whitespace(); + let a = it.next()?; + if a.eq_ignore_ascii_case("echoircd") { + it.next().map(|v| v.to_string()) + } else { + Some(a.to_string()) + } +} diff --git a/static/favicon.svg b/static/favicon.svg index bce7842..f478e75 100644 --- a/static/favicon.svg +++ b/static/favicon.svg @@ -1,5 +1,6 @@ - - - - + + + + + diff --git a/static/style.css b/static/style.css index 5dfbcb6..270b672 100644 --- a/static/style.css +++ b/static/style.css @@ -1,126 +1,141 @@ -/* echoIRCd — the site as an IRC client. Monospace, warm phosphor, real IRC grammar. */ +/* echoIRCd — a friendly, light project site. Teal is echo's own colour. */ :root{ - --bg:#08090c; --win:#0d0e12; --panel:#14161c; --panel2:#0f1116; - --edge:#22252e; --edge2:#2d313c; - --fg:#c8ccd4; --dim:#767b85; --dimmer:#4a4e58; - --amber:#e2a25a; --green:#5fbf6b; --blue:#5f9bef; --magenta:#c58af0; --cyan:#4fcccf; --red:#ec6d6d; - --link:#6aa6f2; --ok:#5fbf6b; - --mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code",Menlo,Consolas,"DejaVu Sans Mono",monospace; + --teal:#12a594; --teal-d:#0e8577; --teal-dd:#0a6a5f; --teal-t:#e7f6f3; + --ink:#1b2733; --muted:#5c6773; --faint:#8b95a0; + --line:#e4e8eb; --line2:#d8dee2; --bg:#f5f6f7; --card:#ffffff; + --code-bg:#132430; --code-fg:#e7edf0; --cmd:#6fe0cf; + --amber:#e8a13c; --green:#22b573; --red:#e0604f; + --sans:"Rubik",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; + --mono:ui-monospace,"SF Mono",Menlo,Consolas,"DejaVu Sans Mono",monospace; + --wrap:1080px; } *{box-sizing:border-box} html{-webkit-text-size-adjust:100%} body{ - margin:0;background:var(--bg);color:var(--fg); - font:14.5px/1.65 var(--mono); - background-image:radial-gradient(1200px 600px at 50% -10%,rgba(226,162,90,.05),transparent 70%); + margin:0;color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.65; + background-color:var(--bg); + background-image:radial-gradient(rgba(18,165,148,.06) 1px,transparent 1px); + background-size:24px 24px;background-position:-12px -12px; -webkit-font-smoothing:antialiased; } -a{color:var(--link);text-decoration:none} +h1,h2,h3{line-height:1.15;margin:0 0 .5rem;color:var(--ink);font-weight:700;letter-spacing:-.01em} +p{margin:0 0 1rem} +a{color:var(--teal-d);text-decoration:none} a:hover{text-decoration:underline} -b{color:var(--fg);font-weight:600} -.dim{color:var(--dim)} -.kw{color:var(--amber)} -.ok{color:var(--ok)} -.i{font-style:italic} -.n-amber{color:var(--amber)} .n-green{color:var(--green)} .n-blue{color:var(--blue)} -.n-magenta{color:var(--magenta)} .n-cyan{color:var(--cyan)} .n-red{color:var(--red)} +code{font-family:var(--mono);font-size:.9em;background:rgba(18,165,148,.09);color:var(--teal-dd);padding:.08em .38em;border-radius:5px} +.wrap{max-width:var(--wrap);margin:0 auto;padding:0 1.25rem} +.mono-line{font-family:var(--mono);color:var(--muted)} -/* the client window */ -.client{ - max-width:1060px;margin:1.6rem auto;background:var(--win); - border:1px solid var(--edge);border-radius:9px;overflow:hidden; - box-shadow:0 24px 60px -30px rgba(0,0,0,.8),0 0 0 1px rgba(255,255,255,.01); +/* buttons */ +.btn{display:inline-block;font-weight:500;font-size:.98rem;padding:.62rem 1.15rem;border-radius:9px;border:1.5px solid transparent;transition:transform .05s ease} +.btn:hover{text-decoration:none;transform:translateY(-1px)} +.btn-primary{background:var(--teal);color:#fff;box-shadow:0 8px 18px -8px rgba(18,165,148,.7)} +.btn-primary:hover{background:var(--teal-d)} +.btn-ghost{border-color:var(--line2);color:var(--ink);background:#fff} +.btn-ghost:hover{border-color:var(--teal);color:var(--teal-d)} + +/* top bar */ +.topbar{background:var(--teal);color:#fff;position:sticky;top:0;z-index:20;box-shadow:0 2px 12px -6px rgba(0,0,0,.35)} +.bar-in{max-width:var(--wrap);margin:0 auto;padding:.7rem 1.25rem;display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap} +.brand{display:flex;align-items:center;gap:.55rem;color:#fff;font-weight:700;font-size:1.25rem} +.brand:hover{text-decoration:none;opacity:.95} +.mark{width:26px;height:26px;color:#fff;flex:none} +.mainnav{margin-left:auto;display:flex;gap:1.4rem;align-items:center;flex-wrap:wrap} +.mainnav a{color:#eafffb;font-weight:500;font-size:.96rem;opacity:.9;padding:.15rem 0;border-bottom:2px solid transparent} +.mainnav a:hover{opacity:1;text-decoration:none} +.mainnav a.on{opacity:1;border-bottom-color:#fff} +.mainnav a.ext{opacity:.8} + +/* hero */ +.hero{padding:3.4rem 0 2.6rem} +.hero-in{max-width:var(--wrap);margin:0 auto;padding:0 1.25rem;display:grid;grid-template-columns:1.4fr 1fr;gap:2.5rem;align-items:center} +.eyebrow{color:var(--teal-d);font-weight:600;font-size:.82rem;text-transform:uppercase;letter-spacing:.14em;margin:0 0 .8rem} +.hero h1{font-size:clamp(2.4rem,5.4vw,3.7rem);margin-bottom:1rem} +.hero-sub{color:var(--muted);font-size:1.12rem;max-width:34rem;margin-bottom:1.6rem} +.cta{display:flex;gap:.7rem;flex-wrap:wrap} + +/* status card */ +.statuscard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.2rem 1.3rem;box-shadow:0 20px 40px -28px rgba(20,40,60,.4)} +.sc-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem} +.sc-title{font-size:.72rem;text-transform:uppercase;letter-spacing:.15em;color:var(--faint);font-weight:600} +.dot{width:9px;height:9px;border-radius:50%} +.dot.on{background:var(--green);box-shadow:0 0 0 0 rgba(34,181,115,.6);animation:pulse 2s infinite} +.dot.off{background:var(--faint)} +@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,181,115,.5)}70%{box-shadow:0 0 0 7px rgba(34,181,115,0)}100%{box-shadow:0 0 0 0 rgba(34,181,115,0)}} +.sc-rows{margin:0} +.sc-rows>div{display:flex;justify-content:space-between;align-items:baseline;padding:.42rem 0;border-bottom:1px dashed var(--line)} +.sc-rows>div:last-child{border-bottom:0} +.sc-rows dt{color:var(--muted);font-size:.9rem} +.sc-rows dd{margin:0;font-weight:500;font-family:var(--mono);font-size:.9rem;color:var(--ink)} +.sc-rows .live{color:var(--green)} .sc-rows .down{color:var(--red)} +.sc-foot{margin-top:.7rem;font-size:.78rem;color:var(--faint)} + +/* feature band */ +.band{background:linear-gradient(180deg,#fff,#fbfcfc);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:3.2rem 0} +.h-center{text-align:center} +.band h2{font-size:1.9rem;margin-bottom:2rem} +.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.1rem} +.fcard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:1.3rem 1.4rem;transition:border-color .15s,box-shadow .15s} +.fcard:hover{border-color:var(--teal);box-shadow:0 16px 30px -24px rgba(18,165,148,.6)} +.fcard h3{font-size:1.12rem;color:var(--teal-dd)} +.fcard p{margin:0;color:var(--muted);font-size:.95rem} +.more{margin-top:1.8rem}.more a{font-weight:600} + +/* latest / news */ +.latest{padding:3rem 1.25rem} +.latest h2{font-size:1.6rem;margin-bottom:1.2rem} +.news{list-style:none;margin:0;padding:0} +.news li{display:flex;gap:1.2rem;padding:1rem 0;border-top:1px solid var(--line)} +.news li:last-child{border-bottom:1px solid var(--line)} +.news-date{flex:none;width:6.5rem;color:var(--faint);font-size:.85rem;font-family:var(--mono);padding-top:.15rem} +.news div{color:var(--muted)} + +/* cta band */ +.ctaband{background:var(--teal-t);border-top:1px solid #cdeae4} +.ctaband-in{display:flex;align-items:center;justify-content:space-between;gap:1.5rem;padding:2.3rem 1.25rem;flex-wrap:wrap} +.ctaband h2{color:var(--teal-dd);margin-bottom:.2rem;font-size:1.5rem} +.ctaband .mono-line{color:var(--teal-d)} + +/* page head + doc pages */ +.pagehead{background:linear-gradient(180deg,#fff,#fbfcfc);border-bottom:1px solid var(--line);padding:2.6rem 0 2rem} +.pagehead h1{font-size:2.3rem} +.pagehead p{color:var(--muted);margin:0;max-width:44rem} +.doc{padding:2.4rem 1.25rem} +.doc-block{margin:0 0 2.2rem} +.doc-block h2{font-size:1.3rem;color:var(--ink);padding-bottom:.5rem;border-bottom:2px solid var(--teal-t);margin-bottom:.9rem} +.doc-block ul{margin:.3rem 0;padding-left:1.2rem} +.doc-block li{margin:.4rem 0;color:var(--muted)} +.doc-block li b{color:var(--ink)} +.chips{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.6rem} +.chips span{font-family:var(--mono);font-size:.8rem;background:#fff;border:1px solid var(--line2);color:var(--muted);border-radius:6px;padding:.22rem .5rem} +.detail{border-collapse:collapse;width:100%;max-width:34rem;margin:.4rem 0 1rem} +.detail th{text-align:left;font-weight:500;color:var(--muted);padding:.5rem .9rem .5rem 0;border-bottom:1px solid var(--line);white-space:nowrap;width:1%;vertical-align:top} +.detail td{padding:.5rem 0;border-bottom:1px solid var(--line)} +.note{color:var(--faint);font-size:.9rem} +.webchat{margin:.6rem 0 0} +.code{background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:.9rem 1.1rem;overflow-x:auto; + font-family:var(--mono);font-size:.88rem;line-height:1.7;white-space:pre;margin:.5rem 0} + +/* footer */ +.site-foot{background:#0f1a22;color:#aeb8c0;margin-top:1rem} +.foot-in{max-width:var(--wrap);margin:0 auto;padding:2rem 1.25rem;display:flex;align-items:center;gap:1rem 1.5rem;flex-wrap:wrap} +.foot-brand{display:flex;align-items:center;gap:.5rem;color:#fff;font-weight:700} +.foot-brand .mark{width:22px;height:22px;color:var(--teal)} +.foot-tag{margin:0;color:#8994a0;font-size:.9rem} +.foot-tag code{background:rgba(255,255,255,.08);color:#cfe3dd} +.foot-links{margin-left:auto;display:flex;gap:1.2rem;flex-wrap:wrap} +.foot-links a{color:#aeb8c0;font-size:.9rem} +.foot-links a:hover{color:#fff} + +@media(max-width:820px){ + .hero-in{grid-template-columns:1fr;gap:1.6rem} + .statuscard{max-width:26rem} + .cards{grid-template-columns:1fr 1fr} } - -/* title bar */ -.titlebar{display:flex;align-items:center;gap:.6rem;padding:.55rem .9rem;background:var(--panel);border-bottom:1px solid var(--edge)} -.conn{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);flex:none} -.tb-name{color:var(--fg);font-weight:600} -.tb-server{margin-left:auto;color:var(--dim);font-size:.82rem} - -/* tabs */ -.tabs{display:flex;gap:.15rem;padding:0 .5rem;background:var(--panel);border-bottom:1px solid var(--edge);overflow-x:auto} -.tab{padding:.5rem .8rem;color:var(--dim);border-bottom:2px solid transparent;white-space:nowrap} -.tab:hover{color:var(--fg);text-decoration:none} -.tab.on{color:var(--amber);border-bottom-color:var(--amber)} -.tab.ext{margin-left:auto;color:var(--dimmer)} - -/* topic */ -.topic{padding:.5rem .95rem;background:var(--panel2);border-bottom:1px solid var(--edge);color:var(--dim);font-size:.85rem} -.topic .tp{color:var(--amber);margin-right:.35rem} - -/* workspace = buffer + nicklist */ -.workspace{display:flex;align-items:stretch} -.buffer{flex:1;min-width:0;padding:1.1rem 1.1rem 1.4rem} -.nicklist{width:13.5rem;flex:none;background:var(--panel2);border-left:1px solid var(--edge);padding:1rem .85rem;font-size:.86rem} -.nl-head{color:var(--dim);text-transform:uppercase;letter-spacing:.12em;font-size:.72rem;margin-bottom:.6rem} -.nicklist ul{list-style:none;margin:0;padding:0} -.nicklist li{display:flex;align-items:center;gap:.15rem;padding:.14rem 0;white-space:nowrap} -.nicklist .s{display:inline-block;width:1.1em;text-align:center;color:var(--dim)} -.bf{margin-left:.35rem;font-size:.62rem;color:var(--dimmer);border:1px solid var(--edge2);border-radius:3px;padding:0 .25em} - -/* buffer lines — [time] [gutter] [message] */ -.ln{display:grid;grid-template-columns:3.1em 7em 1fr;column-gap:.75em;align-items:baseline;padding:.09rem 0} -.ln .t{color:var(--dimmer);font-size:.82em} -.ln .g{text-align:right;white-space:nowrap;color:var(--dim)} -.ln .m{min-width:0;word-wrap:break-word;overflow-wrap:anywhere} -.ln .m a{color:var(--link)} - -/* banner / wordmark */ -.banner{padding:.6rem 0 1.4rem;border-bottom:1px dashed var(--edge);margin-bottom:1rem} -.wordmark{font-size:clamp(2.3rem,7vw,3.7rem);font-weight:700;letter-spacing:-2px;color:var(--fg);line-height:1} -.wordmark .w-hi{color:var(--amber)} -.w-sub{color:var(--dim);margin-top:.5rem;font-size:.95rem} -.cur{display:inline-block;width:.55em;color:var(--amber);animation:blink 1.1s steps(1) infinite} -.cur-lg{width:.5em;margin-left:.06em} -@keyframes blink{50%{opacity:0}} -@media(prefers-reduced-motion:reduce){.cur{animation:none}} - -/* quickfacts */ -.quickfacts{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1.3rem} -.qf{border:1px solid var(--edge);border-radius:6px;padding:.4rem .7rem;color:var(--dim);background:var(--panel2);font-size:.85rem} -.qf:hover{border-color:var(--edge2);color:var(--fg);text-decoration:none} -.qf .qf-k{color:var(--amber)} - -/* feature groups */ -.grp{margin:1.5rem 0} -.grp-h{color:var(--amber);font-size:.82rem;text-transform:uppercase;letter-spacing:.14em;padding-bottom:.4rem;margin-bottom:.7rem;border-bottom:1px solid var(--edge);position:relative} -.grp-h::before{content:"── ";color:var(--dimmer)} -.bul{list-style:none;margin:0;padding:0} -.bul li{padding:.22rem 0 .22rem 1.3em;position:relative;color:var(--fg)} -.bul li::before{content:"·";position:absolute;left:.3em;color:var(--amber)} -.srvline{display:grid;grid-template-columns:7em 1fr;column-gap:.75em;align-items:baseline;margin:.2rem 0 .8rem} -.srvline .g{text-align:right} -.capline{color:var(--dim);word-break:break-word;line-height:1.7} - -/* connect page: tables + code */ -.detail{width:100%;border-collapse:collapse;margin:.7rem 0 1rem;font-size:.9rem} -.detail th{text-align:left;color:var(--dim);font-weight:400;padding:.4rem .8rem .4rem 0;border-bottom:1px solid var(--edge);white-space:nowrap;vertical-align:top;width:1%} -.detail td{padding:.4rem 0;border-bottom:1px solid var(--edge);color:var(--fg)} -.note{color:var(--dim);margin:.2rem 0 .6rem;font-size:.9rem} -.code{background:var(--panel2);border:1px solid var(--edge);border-left:2px solid var(--amber);border-radius:6px; - padding:.8rem 1rem;overflow-x:auto;color:var(--green);font-size:.88rem;line-height:1.75;white-space:pre;margin:.4rem 0} -.webchat{display:flex;align-items:center;gap:.8rem;flex-wrap:wrap;margin:1.1rem 0} -.btn{display:inline-block;border:1px solid var(--edge2);background:var(--panel);color:var(--fg);padding:.4rem .85rem;border-radius:6px;font-size:.88rem} -.btn:hover{border-color:var(--amber);color:var(--amber);text-decoration:none} - -/* input + status bars */ -.inputbar{display:flex;align-items:baseline;gap:.5rem;padding:.55rem .95rem;background:var(--panel);border-top:1px solid var(--edge);font-size:.9rem;overflow:hidden} -.ib-ch{color:var(--dim)} -.ib-prompt{color:var(--amber)} -.ib-text{color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.statusbar{display:flex;align-items:center;gap:.8rem;padding:.4rem .95rem;background:var(--panel2);border-top:1px solid var(--edge);font-size:.74rem;color:var(--dim)} -.statusbar .sb-m{margin:0 auto;color:var(--amber);opacity:.75} -.statusbar a{color:var(--dim)} -.statusbar a:hover{color:var(--fg)} - -@media(max-width:720px){ - .client{margin:0;border:0;border-radius:0;min-height:100vh} - .workspace{flex-direction:column} - .nicklist{width:auto;border-left:0;border-top:1px solid var(--edge);order:2} - .nicklist ul{display:flex;flex-wrap:wrap;gap:.1rem .9rem} - .ln{grid-template-columns:5.5em 1fr} - .ln .t{display:none} - .srvline{grid-template-columns:5.5em 1fr} - .tb-server{display:none} +@media(max-width:560px){ + .cards{grid-template-columns:1fr} + .mainnav{gap:1rem} + .news li{flex-direction:column;gap:.2rem} + .news-date{width:auto} + .ctaband-in{flex-direction:column;align-items:flex-start} } diff --git a/templates/base.html b/templates/base.html index 9eb6f3d..a3a052b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,67 +3,60 @@ - -{% block title %}echoIRCd{% endblock %} - +{% block title %}echoIRCd — a from-scratch IRC server in Rust{% endblock %} + - + + + + -
- -
- - echoircd.org - irc.echoircd.org · +6697 · TLS1.3 +
+ +
- +
+{% block content %}{% endblock %} +
-
» {% block topic %}echoIRCd — a native-Rust IRC daemon & services{% endblock %}
- -
-
- {% block buffer %}{% endblock %} +
+
+
+ + echoIRCd
- +

A native-Rust IRC daemon & services. No forks, no C, no unsafe.

+
- -
- [{% block chan %}#home{% endblock %}] - - {% block cmd %}/connect{% endblock %} -
- -
- echoIRCd 5.0.0 - #![forbid(unsafe_code)] - services · website -
- -
+ diff --git a/templates/connect.html b/templates/connect.html index 5f12a80..d2beaf3 100644 --- a/templates/connect.html +++ b/templates/connect.html @@ -1,54 +1,55 @@ {% extends "base.html" %} -{% block title %}#connect — echoIRCd{% endblock %} -{% block topic %}#connect — point any client at irc.echoircd.org, or use the browser{% endblock %} -{% block chan %}#connect{% endblock %} -{% block cmd %}/server irc.echoircd.org +6697{% endblock %} -{% block buffer %} -
19:33<you>/server irc.echoircd.org +6697
-
19:33-!-connecting to irc.echoircd.org:6697 … TLS1.3 up (X25519MLKEM768)
-
19:33-!-Welcome to the echoiRCd IRC Network
+{% block title %}Connect — echoIRCd{% endblock %} +{% block content %} +
+
+

Connect

+

Point any IRC client at the network — or open it right in your browser.

+
+
- - - - - -
serverirc.echoircd.org
tls (recommended)6697
plaintext6667
networkechoiRCd
- -
- prefer the browser? - open the web client ↗ +
+
+ + + + + +
Serverirc.echoircd.org
TLS (recommended)6697
Plaintext6667
NetworkechoiRCd
+

Open the web client ↗

-
-
client quick-start
-

most clients take a one-line server string; the leading + means TLS.

+
+

Client quick-start

+

Most clients take a one-line server string; the leading + on the port means TLS.

/server irc.echoircd.org +6697
 /join #echoircd
- - - - + + + +
HexChatadd irc.echoircd.org/+6697 · tick “Use SSL”
WeeChat/server add echo irc.echoircd.org/6697 -tls
irssi/connect -tls irc.echoircd.org 6697
mIRC/server irc.echoircd.org +6697
HexChatadd irc.echoircd.org/+6697 · tick “Use SSL”
WeeChat/server add echo irc.echoircd.org/6697 -tls
irssi/connect -tls irc.echoircd.org 6697
mIRC/server irc.echoircd.org +6697
-
-
register your nick
-

keep your nick, found channels, and log in with SASL.

+
+

Register your nick

+

Registering lets you keep your nickname, found channels, and log in with SASL.

/msg NickServ REGISTER <password> <email>
 /msg NickServ IDENTIFY <password>
-
-
log in with a key (ecdsa)
-

sign a server challenge with a NIST P-256 key instead of sending a password — nothing secret crosses the wire.

+
+

Log in with a key (ECDSA)

+

Sign a server challenge with a NIST P-256 key instead of sending a password — nothing + secret crosses the wire.

# generate a key, read its public half
 ecdsatool keygen ~/.ecdsa.pem
 ecdsatool pubkey ~/.ecdsa.pem
 
 # store it, then pick ECDSA-NIST256P-CHALLENGE for SASL
 /msg NickServ SET PUBKEY <printed-public-key>
-

point your client's SASL settings at the same key file.

+

Point your client's SASL settings at the same key file.

+
{% endblock %} diff --git a/templates/features.html b/templates/features.html index 6aaed2f..106ab63 100644 --- a/templates/features.html +++ b/templates/features.html @@ -1,66 +1,85 @@ {% extends "base.html" %} -{% block title %}#features — echoIRCd{% endblock %} -{% block topic %}#features — what's inside the daemon & services{% endblock %} -{% block chan %}#features{% endblock %} -{% block cmd %}/msg echoircd help{% endblock %} -{% block buffer %} -
19:31<you>/msg echoircd what can you do
-
19:31<echoircd>plenty — here's the short tour. all of it is original, native Rust.
+{% block title %}Features — echoIRCd{% endblock %} +{% block content %} +
+
+

Features

+

Everything below is original, native Rust. The InspIRCd protocol is a reference for wire + compatibility — never a source of code.

+
+
-
-
the daemon
-
    -
  • safe by construction#![forbid(unsafe_code)] across the tree, enforced in CI
  • -
  • reactor core — single core thread + mio/epoll pool, zero-copy broadcast, parallel channel fan-out
  • -
  • full mode set — every standard channel/user mode, host cloaking, custom prefixes
  • -
  • operators — a privilege model: command grants, named privs, per-type mode allow-list
  • -
  • the edge — connection classes, per-class flood/fakelag, RFC1413 ident, PROXY v1/v2
  • -
  • observability — a metrics endpoint, JSON logging, native syslog
  • +
    +
    +

    The daemon

    +
      +
    • Safe by construction#![forbid(unsafe_code)] across the tree, enforced in CI.
    • +
    • Reactor core — a single core thread with a mio/epoll pool; zero-copy broadcast and parallel channel fan-out.
    • +
    • Full mode set — every standard channel and user mode, host cloaking, custom prefixes.
    • +
    • Operators — a privilege model: command grants, named privileges, and a per-type mode allow-list.
    • +
    • The edge — connection classes, per-class flood/fakelag, RFC 1413 ident, PROXY protocol v1/v2.
    • +
    • Observability — a metrics endpoint, structured JSON logging, and native syslog.
    -
    -
    transport & tls
    -
      -
    • 6697 direct TLS · 7799 WebSocket (wss) · 6667 plaintext
    • -
    • two backends — OpenSSL and rustls — both serving TLS 1.3
    • -
    • with OpenSSL 3.5 the handshake negotiates post-quantum X25519MLKEM768
    • -
    • per-host SNI certificates, reloaded live on rehash
    • +
      +

      Transport & TLS

      +
        +
      • 6697 direct TLS · 7799 WebSocket (wss) · 6667 plaintext.
      • +
      • Two interchangeable backends — OpenSSL and rustls — both serving TLS 1.3.
      • +
      • With OpenSSL 3.5 the handshake negotiates post-quantum X25519MLKEM768.
      • +
      • Per-host SNI certificates, reloaded live on rehash.
      -
      -
      ircv3
      -
      <echoircd>CAP * LS :server-time message-tags account-tag account-notify extended-join chghost multi-prefix away-notify invite-notify setname echo-message userhost-in-names batch labeled-response standard-replies extended-monitor draft/chathistory draft/event-playback draft/message-redaction draft/multiline draft/metadata-2 draft/read-marker draft/webpush draft/account-registration sts
      +
      +

      IRCv3

      +

      Capabilities advertised to clients:

      +
      + server-timemessage-tagsaccount-tagaccount-notify + extended-joinchghostmulti-prefixaway-notify + invite-notifysetnameecho-messageuserhost-in-names + batchlabeled-responsestandard-repliesextended-monitor + draft/chathistorydraft/event-playbackdraft/message-redaction + draft/multilinedraft/metadata-2draft/read-marker + draft/webpushdraft/account-registrationsts +
      -
      -
      sasl
      -
      <echoircd>sasl=PLAIN,EXTERNAL,SCRAM-SHA-256,ECDSA-NIST256P-CHALLENGE
      -
        -
      • EXTERNAL — authenticate by your TLS client-certificate fingerprint
      • -
      • SCRAM-SHA-256 & ECDSA-NIST256P-CHALLENGE — challenge/response, no secret on the wire
      • +
        +

        SASL

        +
          +
        • PLAIN — classic username / password.
        • +
        • EXTERNAL — authenticate by your TLS client-certificate fingerprint.
        • +
        • SCRAM-SHA-256 — challenge / response, no password on the wire.
        • +
        • ECDSA-NIST256P-CHALLENGE — sign a challenge with a NIST P-256 key; the private key never leaves your client.
        -
        -
        services
        -
          -
        • NickServ — registration, grouped nicks, certs, public keys, vhosts, profiles
        • -
        • ChanServ — founder/access, auto-op, akick, topic & mode locks
        • -
        • OperServ · MemoServ · more — network admin, offline messages, games
        • -
        • event-sourced store; SASL relayed to services mechanism-agnostically
        • +
          +

          Services

          +
            +
          • NickServ — registration, grouped nicks, certificates, public keys, vhosts, profiles.
          • +
          • ChanServ — founder/access, auto-op, akick, topic and mode locks.
          • +
          • OperServ · MemoServ · more — network administration, offline messaging, and games.
          • +
          • Event-sourced store; SASL relayed to services mechanism-agnostically.
          -
          -
          security
          -
            -
          • a native anti-abuse engine in the core — connection/nick-flood and mass-join screening
          • -
          • behavioral and content heuristics with computed-pattern mining
          • -
          • a DEFCON state machine plus DNSBL / MX screening via a native async resolver
          • +
            +

            Security

            +
              +
            • A native anti-abuse engine in the core — connection/nick-flood and mass-join screening.
            • +
            • Behavioral and content heuristics with computed-pattern mining.
            • +
            • A DEFCON state machine plus DNSBL / MX screening via a native async resolver.
            +
    -
    19:32*echoircd nods toward #connect
    +
    +
    +

    Try it

    irc.echoircd.org · +6697 · TLS 1.3

    + Connect & register +
    +
    {% endblock %} diff --git a/templates/index.html b/templates/index.html index e17e782..2323c4e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,28 +1,68 @@ {% extends "base.html" %} -{% block title %}echoIRCd — a native-Rust IRC network{% endblock %} -{% block topic %}#home — echoIRCd, a native-Rust IRC daemon & services · no fork · no C · no unsafe{% endblock %} -{% block chan %}#home{% endblock %} -{% block cmd %}/connect{% endblock %} -{% block buffer %} - +{% block title %}echoIRCd — a from-scratch IRC server in Rust{% endblock %} +{% block content %} +
    +
    +
    +

    IRC daemon & services · written in Rust

    +

    The from-scratch
    IRC server.

    +

    echoIRCd is a modern IRC daemon and a full services suite, built from the + ground up in Rust — full IRCv3, modern TLS, and SASL. No forks. No C. No unsafe.

    + +
    -
    19:28-!-Now talking on #home
    -
    19:28-!-echoIRCd/5.0.0 · irc.echoircd.org · network echoiRCd · +6697 TLS1.3
    -
    19:28-->you (~guest@echoircd.org) has joined #home
    -
    19:29<echoircd>hey — you're looking at an IRC network written from the ground up in Rust.
    -
    19:29<echoircd>original code: no InspIRCd fork, no C, and #![forbid(unsafe_code)] across the whole tree.
    -
    19:29<echoircd>the daemon speaks full IRCv3 and modern TLS; services handle accounts, channels and SASL.
    -
    19:30*echoircd slides the tabs your way — #features for the tech, #connect to join
    -
    19:30<you>nice. how do I get on?
    -
    19:30<echoircd>/connect — or open the web client right in your browser.
    - -
    - tls 1.3 · post-quantum - ircv3 full cap set - sasl incl. ecdsa - unsafe 0 lines +
    +
    + +
    +
    +

    Why echoIRCd

    +
    +

    Safe by construction

    #![forbid(unsafe_code)] across the whole tree, enforced in CI. Original code — not a fork of anything.

    +

    Full IRCv3

    server-time, message-tags, account-tag, batch, labeled-response, chathistory, multiline, standard-replies and more.

    +

    Modern TLS

    Direct TLS and WebSocket, OpenSSL & rustls backends, TLS 1.3 with post-quantum X25519MLKEM768, per-host SNI.

    +

    SASL, incl. ECDSA

    PLAIN, EXTERNAL, SCRAM-SHA-256, and ECDSA-NIST256P-CHALLENGE — sign a challenge, no password on the wire.

    +

    Native services

    NickServ, ChanServ, OperServ, MemoServ and more over S2S — accounts, channels, vhosts, an event-sourced store.

    +

    Anti-abuse built in

    A native security engine: connection & nick-flood detection, behavioral/content heuristics, DEFCON, DNSBL/MX screening.

    +
    +

    Everything that's inside →

    +
    +
    + +
    +

    Latest

    +
      +
    • + Aug 2026 +
      echoIRCd 5.0 — SASL ECDSA-NIST256P-CHALLENGE, post-quantum TLS 1.3, the operator privilege model, and a native anti-abuse engine.
      +
    • +
    • + Aug 2026 +
      echo services — key-based login (SET PUBKEY), event-sourced accounts, and SASL relayed over the server link.
      +
    • +
    +
    + +
    +
    +

    Ready to connect?

    irc.echoircd.org · +6697 · TLS 1.3

    + Get connected +
    +
    {% endblock %}