Scaffold the federated services daemon
Protocol-agnostic core: a Protocol trait maps raw server-to-server lines to a normalized event/action model, so the engine never touches a raw line and a new ircd is one new module. Adds event-log state (state is a fold over the log) and a service framework. First protocol impl links to an InspIRCd (insp4) uplink — completes the CAPAB/SERVER handshake, bursts, and introduces NickServ.
This commit is contained in:
commit
f82ab0c70a
15 changed files with 890 additions and 0 deletions
34
src/config.rs
Normal file
34
src/config.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub uplink: Uplink,
|
||||
pub server: Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Uplink {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Server {
|
||||
pub name: String,
|
||||
pub sid: String,
|
||||
pub description: String,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub protocol: u32,
|
||||
}
|
||||
|
||||
fn default_protocol() -> u32 {
|
||||
1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3)
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &str) -> anyhow::Result<Self> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&raw)?)
|
||||
}
|
||||
}
|
||||
61
src/engine/mod.rs
Normal file
61
src/engine/mod.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
pub mod service;
|
||||
pub mod state;
|
||||
|
||||
use crate::proto::{NetAction, NetEvent};
|
||||
use service::{Service, ServiceCtx};
|
||||
use state::{EventLog, Network};
|
||||
|
||||
pub struct Engine {
|
||||
services: Vec<Box<dyn Service>>,
|
||||
#[allow(dead_code)]
|
||||
network: Network,
|
||||
#[allow(dead_code)]
|
||||
log: EventLog,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(services: Vec<Box<dyn Service>>) -> Self {
|
||||
Self {
|
||||
services,
|
||||
network: Network::default(),
|
||||
log: EventLog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Sent right after the SERVER line: burst, introduce every service, endburst.
|
||||
pub fn startup_actions(&self) -> Vec<NetAction> {
|
||||
let mut out = vec![NetAction::Burst];
|
||||
for svc in &self.services {
|
||||
out.push(NetAction::IntroduceUser {
|
||||
uid: svc.uid().to_string(),
|
||||
nick: svc.nick().to_string(),
|
||||
ident: "services".to_string(),
|
||||
host: svc.host().to_string(),
|
||||
gecos: svc.gecos().to_string(),
|
||||
});
|
||||
}
|
||||
out.push(NetAction::EndBurst);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> {
|
||||
match event {
|
||||
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
||||
NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// Route a PRIVMSG addressed to a service (by uid or nick) into that service.
|
||||
fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
|
||||
let mut ctx = ServiceCtx::default();
|
||||
for svc in self.services.iter_mut() {
|
||||
if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) {
|
||||
let args: Vec<&str> = text.split_whitespace().collect();
|
||||
svc.on_command(from, &args, &mut ctx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.actions
|
||||
}
|
||||
}
|
||||
29
src/engine/service.rs
Normal file
29
src/engine/service.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use crate::proto::NetAction;
|
||||
|
||||
// A pseudo-client (NickServ, ChanServ, OperServ, ...). Introduced at burst,
|
||||
// receives the commands users message it, and pushes actions onto the ctx.
|
||||
pub trait Service: Send {
|
||||
fn nick(&self) -> &str;
|
||||
fn uid(&self) -> &str;
|
||||
fn host(&self) -> &str {
|
||||
"services.local"
|
||||
}
|
||||
fn gecos(&self) -> &str;
|
||||
fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx);
|
||||
}
|
||||
|
||||
// What a service can do in response to a command, collected for the link to flush.
|
||||
#[derive(Default)]
|
||||
pub struct ServiceCtx {
|
||||
pub actions: Vec<NetAction>,
|
||||
}
|
||||
|
||||
impl ServiceCtx {
|
||||
pub fn notice(&mut self, from: &str, to: &str, text: impl Into<String>) {
|
||||
self.actions.push(NetAction::Notice {
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
text: text.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
39
src/engine/state.rs
Normal file
39
src/engine/state.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
// Live network view the services reason about.
|
||||
#[derive(Default)]
|
||||
pub struct Network {
|
||||
pub users: HashMap<String, User>, // keyed by UID
|
||||
pub channels: HashMap<String, Channel>,
|
||||
}
|
||||
|
||||
pub struct User {
|
||||
pub uid: String,
|
||||
pub nick: String,
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Channel {
|
||||
pub name: String,
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
// The Sable-inspired core: every persistent change is an Event, and state is a
|
||||
// fold over the log. Single-node today; replicating this log across service
|
||||
// nodes is what turns it federated later, without rewriting the services.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
AccountRegistered { account: String, uid: String },
|
||||
ChannelRegistered { channel: String, founder: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EventLog {
|
||||
pub events: Vec<Event>,
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
pub fn append(&mut self, event: Event) {
|
||||
self.events.push(event);
|
||||
}
|
||||
}
|
||||
43
src/link.rs
Normal file
43
src/link.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use anyhow::Result;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::engine::Engine;
|
||||
use crate::proto::Protocol;
|
||||
|
||||
// One uplink session: connect, handshake + burst, then translate lines forever.
|
||||
pub async fn run(mut proto: Box<dyn Protocol>, mut engine: Engine, addr: &str) -> Result<()> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut lines = BufReader::new(read).lines();
|
||||
|
||||
for line in proto.handshake() {
|
||||
send(&mut write, &line).await?;
|
||||
}
|
||||
for action in engine.startup_actions() {
|
||||
for line in proto.serialize(&action) {
|
||||
send(&mut write, &line).await?;
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
tracing::debug!(dir = "<<", %line);
|
||||
for event in proto.parse(&line) {
|
||||
for action in engine.handle(event) {
|
||||
for out in proto.serialize(&action) {
|
||||
send(&mut write, &out).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!("uplink closed the connection");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send(write: &mut (impl AsyncWriteExt + Unpin), line: &str) -> Result<()> {
|
||||
tracing::debug!(dir = ">>", %line);
|
||||
write.write_all(line.as_bytes()).await?;
|
||||
write.write_all(b"\r\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
43
src/main.rs
Normal file
43
src/main.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
mod config;
|
||||
mod engine;
|
||||
mod link;
|
||||
mod proto;
|
||||
mod services;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use engine::Engine;
|
||||
use proto::inspircd::InspIrcd;
|
||||
use services::nickserv::NickServ;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "fedserv=debug".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string());
|
||||
let cfg = config::Config::load(&path)?;
|
||||
let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
|
||||
let proto = Box::new(InspIrcd::new(
|
||||
cfg.server.name.clone(),
|
||||
cfg.server.sid.clone(),
|
||||
cfg.uplink.password.clone(),
|
||||
cfg.server.protocol,
|
||||
ts,
|
||||
));
|
||||
|
||||
let services: Vec<Box<dyn engine::service::Service>> = vec![Box::new(NickServ {
|
||||
uid: format!("{}AAAAAA", cfg.server.sid),
|
||||
})];
|
||||
let engine = Engine::new(services);
|
||||
|
||||
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
|
||||
tracing::info!(server = %cfg.server.name, %addr, "linking to uplink");
|
||||
link::run(proto, engine, &addr).await
|
||||
}
|
||||
104
src/proto/inspircd.rs
Normal file
104
src/proto/inspircd.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// InspIRCd spanning-tree link protocol. Handshake + UID + PING mirror the
|
||||
// sequence in Network-Links (protocols/inspircd.py); mode/burst details get
|
||||
// firmed up against a live insp4 uplink.
|
||||
use super::{NetAction, NetEvent, Protocol};
|
||||
|
||||
pub struct InspIrcd {
|
||||
sid: String,
|
||||
name: String,
|
||||
password: String,
|
||||
protocol: u32,
|
||||
ts: u64,
|
||||
}
|
||||
|
||||
impl InspIrcd {
|
||||
pub fn new(name: String, sid: String, password: String, protocol: u32, ts: u64) -> Self {
|
||||
Self { sid, name, password, protocol, ts }
|
||||
}
|
||||
|
||||
fn from_us(&self, cmd: String) -> String {
|
||||
format!(":{} {}", self.sid, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
impl Protocol for InspIrcd {
|
||||
fn handshake(&mut self) -> Vec<String> {
|
||||
vec![
|
||||
format!("CAPAB START {}", self.protocol),
|
||||
format!("CAPAB CAPABILITIES :PROTOCOL={}", self.protocol),
|
||||
"CAPAB END".to_string(),
|
||||
format!("SERVER {} {} {} :{}", self.name, self.password, self.sid, "Federated Services"),
|
||||
]
|
||||
}
|
||||
|
||||
fn parse(&mut self, line: &str) -> Vec<NetEvent> {
|
||||
let (source, rest) = match line.strip_prefix(':') {
|
||||
Some(s) => {
|
||||
let mut it = s.splitn(2, ' ');
|
||||
(Some(it.next().unwrap_or("").to_string()), it.next().unwrap_or(""))
|
||||
}
|
||||
None => (None, line),
|
||||
};
|
||||
let mut tokens = rest.split(' ');
|
||||
let cmd = match tokens.next() {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
match cmd.to_ascii_uppercase().as_str() {
|
||||
"SERVER" => vec![NetEvent::Registered],
|
||||
"ENDBURST" => vec![NetEvent::EndBurst],
|
||||
"PING" => {
|
||||
let token = tokens
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches(':')
|
||||
.to_string();
|
||||
vec![NetEvent::Ping { token, from: source }]
|
||||
}
|
||||
"PRIVMSG" => {
|
||||
let to = tokens.next().unwrap_or("").to_string();
|
||||
vec![NetEvent::Privmsg {
|
||||
from: source.unwrap_or_default(),
|
||||
to,
|
||||
text: trailing(rest),
|
||||
}]
|
||||
}
|
||||
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
|
||||
_ => vec![NetEvent::Unknown { line: line.to_string() }],
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize(&mut self, action: &NetAction) -> Vec<String> {
|
||||
match action {
|
||||
NetAction::Burst => vec![self.from_us(format!("BURST {}", self.ts))],
|
||||
NetAction::EndBurst => vec![self.from_us("ENDBURST".to_string())],
|
||||
NetAction::Pong { token, from } => {
|
||||
let dest = from.clone().unwrap_or_else(|| self.sid.clone());
|
||||
vec![self.from_us(format!("PONG {} {}", dest, token))]
|
||||
}
|
||||
NetAction::IntroduceUser { uid, nick, ident, host, gecos } => vec![self.from_us(format!(
|
||||
"UID {uid} {ts} {nick} {host} {host} {ident} 0.0.0.0 {ts} +ioS :{gecos}",
|
||||
uid = uid, ts = self.ts, nick = nick, host = host, ident = ident, gecos = gecos
|
||||
))],
|
||||
NetAction::Privmsg { from, to, text } => {
|
||||
vec![format!(":{} PRIVMSG {} :{}", from, to, text)]
|
||||
}
|
||||
NetAction::Notice { from, to, text } => {
|
||||
vec![format!(":{} NOTICE {} :{}", from, to, text)]
|
||||
}
|
||||
NetAction::Raw(s) => vec![s.clone()],
|
||||
}
|
||||
}
|
||||
|
||||
fn sid(&self) -> &str {
|
||||
&self.sid
|
||||
}
|
||||
}
|
||||
|
||||
// The IRC "trailing" argument: everything after the first " :", else the last word.
|
||||
fn trailing(rest: &str) -> String {
|
||||
match rest.find(" :") {
|
||||
Some(i) => rest[i + 2..].to_string(),
|
||||
None => rest.rsplit(' ').next().unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
38
src/proto/mod.rs
Normal file
38
src/proto/mod.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Protocol abstraction. The engine only ever sees NetEvent / NetAction; raw
|
||||
// server-to-server lines live entirely behind a Protocol impl. Adding another
|
||||
// ircd later means one new module, engine untouched.
|
||||
pub mod inspircd;
|
||||
|
||||
// Normalized inbound facts, translated from the uplink's raw lines.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NetEvent {
|
||||
Registered,
|
||||
EndBurst,
|
||||
Ping { token: String, from: Option<String> },
|
||||
Privmsg { from: String, to: String, text: String },
|
||||
Quit { uid: String },
|
||||
Unknown { line: String },
|
||||
}
|
||||
|
||||
// Normalized outbound intents the engine wants performed on the network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NetAction {
|
||||
Burst,
|
||||
EndBurst,
|
||||
Pong { token: String, from: Option<String> },
|
||||
IntroduceUser { uid: String, nick: String, ident: String, host: String, gecos: String },
|
||||
Privmsg { from: String, to: String, text: String },
|
||||
Notice { from: String, to: String, text: String },
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
pub trait Protocol: Send {
|
||||
/// Lines to send immediately on connect (auth / capability negotiation).
|
||||
fn handshake(&mut self) -> Vec<String>;
|
||||
/// One raw inbound line -> zero or more normalized events.
|
||||
fn parse(&mut self, line: &str) -> Vec<NetEvent>;
|
||||
/// One normalized action -> the raw line(s) that realise it.
|
||||
fn serialize(&mut self, action: &NetAction) -> Vec<String>;
|
||||
/// Our own server id, used as the source prefix for server-sourced lines.
|
||||
fn sid(&self) -> &str;
|
||||
}
|
||||
1
src/services/mod.rs
Normal file
1
src/services/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod nickserv;
|
||||
25
src/services/nickserv.rs
Normal file
25
src/services/nickserv.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
use crate::engine::service::{Service, ServiceCtx};
|
||||
|
||||
pub struct NickServ {
|
||||
pub uid: String,
|
||||
}
|
||||
|
||||
impl Service for NickServ {
|
||||
fn nick(&self) -> &str {
|
||||
"NickServ"
|
||||
}
|
||||
fn uid(&self) -> &str {
|
||||
&self.uid
|
||||
}
|
||||
fn gecos(&self) -> &str {
|
||||
"Nickname Services"
|
||||
}
|
||||
|
||||
fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx) {
|
||||
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("HELP") => ctx.notice(self.uid(), from, "Commands: REGISTER, IDENTIFY (coming soon)."),
|
||||
Some(other) => ctx.notice(self.uid(), from, format!("Unknown command: {}. Try HELP.", other)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue