From a84e559640c3476669be1e20420d8360bc56c8e4 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 20 Jun 2025 15:26:52 +0000 Subject: [PATCH] Implement declarative appservices. (closes #67) Signed-off-by: Jason Volk --- src/core/config/mod.rs | 127 +++++++++++++++++++++++++++++++++- src/service/appservice/mod.rs | 51 ++++++++++---- tuwunel-example.toml | 53 ++++++++++++++ 3 files changed, 217 insertions(+), 14 deletions(-) diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index bb217803..09ff74b3 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -25,7 +25,11 @@ use url::Url; use self::proxy::ProxyConfig; pub use self::{check::check, manager::Manager}; -use crate::{Result, err, error::Error, utils::sys}; +use crate::{ + Result, err, + error::Error, + utils::{string::EMPTY, sys}, +}; /// All the config options for tuwunel. #[allow(clippy::struct_excessive_bools)] @@ -52,7 +56,8 @@ use crate::{Result, err, error::Error, utils::sys}; ### For more information, see: ### https://tuwunel.chat/configuration.html "#, - ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates ldap jwt" + ignore = "catchall well_known tls blurhashing allow_invalid_tls_certificates ldap jwt \ + appservice" )] pub struct Config { /// The server_name is the pretty name of this server. It is used as a @@ -1818,6 +1823,10 @@ pub struct Config { #[serde(default)] pub jwt: JwtConfig, + // external structure; separate section + #[serde(default)] + pub appservice: BTreeMap, + #[serde(flatten)] #[allow(clippy::zero_sized_map_values)] // this is a catchall, the map shouldn't be zero at runtime @@ -2088,6 +2097,120 @@ pub struct JwtConfig { pub validate_signature: bool, } +#[derive(Clone, Debug, Default, Deserialize)] +#[config_example_generator( + filename = "tuwunel-example.toml", + section = "global.appservice.", + ignore = "id users aliases rooms" +)] +pub struct AppService { + #[serde(default)] + pub id: String, + + /// The URL for the application service. + /// + /// Optionally set to `null` if no traffic is required. + pub url: Option, + + /// A unique token for application services to use to authenticate requests + /// to Homeservers. + pub as_token: String, + + /// A unique token for Homeservers to use to authenticate requests to + /// application services. + pub hs_token: String, + + /// The localpart of the user associated with the application service. + pub sender_localpart: Option, + + /// Events which are sent from certain users. + #[serde(default)] + pub users: Vec, + + /// Events which are sent in rooms with certain room aliases. + #[serde(default)] + pub aliases: Vec, + + /// Events which are sent in rooms with certain room IDs. + #[serde(default)] + pub rooms: Vec, + + /// Whether requests from masqueraded users are rate-limited. + /// + /// The sender is excluded. + #[serde(default)] + pub rate_limited: bool, + + /// The external protocols which the application service provides (e.g. + /// IRC). + /// + /// default: [] + #[serde(default)] + pub protocols: Vec, + + /// Whether the application service wants to receive ephemeral data. + /// + /// default: false + #[serde(default)] + pub receive_ephemeral: bool, + + /// Whether the application service wants to do device management, as part + /// of MSC4190. + /// + /// default: false + #[serde(default)] + pub device_management: bool, +} + +impl From for ruma::api::appservice::Registration { + fn from(conf: AppService) -> Self { + use ruma::api::appservice::Namespaces; + + Self { + id: conf.id, + url: conf.url, + as_token: conf.as_token, + hs_token: conf.hs_token, + receive_ephemeral: conf.receive_ephemeral, + device_management: conf.device_management, + protocols: conf.protocols.into(), + rate_limited: conf.rate_limited.into(), + sender_localpart: conf + .sender_localpart + .unwrap_or_else(|| EMPTY.into()), + namespaces: Namespaces { + users: conf.users.into_iter().map(Into::into).collect(), + aliases: conf.aliases.into_iter().map(Into::into).collect(), + rooms: conf.rooms.into_iter().map(Into::into).collect(), + }, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[config_example_generator( + filename = "tuwunel-example.toml", + section = "[global.appservice..]" +)] +pub struct AppServiceNamespace { + /// Whether this application service has exclusive access to events within + /// this namespace. + #[serde(default)] + pub exclusive: bool, + + /// A regular expression defining which values this namespace includes. + pub regex: String, +} + +impl From for ruma::api::appservice::Namespace { + fn from(conf: AppServiceNamespace) -> Self { + Self { + exclusive: conf.exclusive, + regex: conf.regex, + } + } +} + #[derive(Deserialize, Clone, Debug)] #[serde(transparent)] struct ListeningPort { diff --git a/src/service/appservice/mod.rs b/src/service/appservice/mod.rs index cf7b8c90..970af394 100644 --- a/src/service/appservice/mod.rs +++ b/src/service/appservice/mod.rs @@ -4,10 +4,10 @@ mod registration_info; use std::{collections::BTreeMap, iter::IntoIterator, sync::Arc}; use async_trait::async_trait; -use futures::{Future, FutureExt, Stream, TryStreamExt}; +use futures::{Future, FutureExt, Stream, StreamExt, TryStreamExt}; use ruma::{RoomAliasId, RoomId, UserId, api::appservice::Registration}; use tokio::sync::{RwLock, RwLockReadGuard}; -use tuwunel_core::{Result, err, utils::stream::IterStream}; +use tuwunel_core::{Err, Result, Server, debug, err, utils::stream::IterStream}; use tuwunel_database::Map; pub use self::{namespace_regex::NamespaceRegex, registration_info::RegistrationInfo}; @@ -21,6 +21,7 @@ pub struct Service { struct Services { sending: Dep, + server: Arc, } struct Data { @@ -36,6 +37,7 @@ impl crate::Service for Service { registration_info: RwLock::new(BTreeMap::new()), services: Services { sending: args.depend::("sending"), + server: args.server.clone(), }, db: Data { id_appserviceregistrations: args.db["id_appserviceregistrations"].clone(), @@ -44,23 +46,48 @@ impl crate::Service for Service { } async fn worker(self: Arc) -> Result { - // Inserting registrations into cache - self.iter_db_ids() - .try_for_each(async |appservice| { - self.registration_info - .write() - .await - .insert(appservice.0, appservice.1.try_into()?); + self.init_registrations().await?; - Ok(()) - }) - .await + Ok(()) } fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } } impl Service { + #[tracing::instrument(name = "init", skip(self))] + async fn init_registrations(&self) -> Result { + // Registrations from configuration file + let confs = self + .services + .server + .config + .appservice + .clone() + .into_iter() + .stream() + .map(|(id, mut reg)| { + reg.id.clone_from(&id); + reg.sender_localpart + .get_or_insert_with(|| id.clone()); + + Ok((id, reg)) + }); + + // Registrations from database + self.iter_db_ids() + .chain(confs.map_ok(|(id, reg)| (id, reg.into()))) + .try_for_each(async |(id, reg): (_, Registration)| { + debug!(?id, ?reg, "appservice registration"); + self.registration_info + .write() + .await + .insert(id.clone(), reg.try_into()?) + .map_or(Ok(()), |_| Err!("Conflicting Appservice ID: {id:?}")) + }) + .await + } + /// Registers an appservice and returns the ID to the caller pub async fn register_appservice( &self, diff --git a/tuwunel-example.toml b/tuwunel-example.toml index 08ca97d0..afd24c03 100644 --- a/tuwunel-example.toml +++ b/tuwunel-example.toml @@ -1778,3 +1778,56 @@ # Bypass validation for diagnostic/debug use only. # #validate_signature = true + +[global.appservice.] + +# The URL for the application service. +# +# Optionally set to `null` if no traffic is required. +# +#url = + +# A unique token for application services to use to authenticate requests +# to Homeservers. +# +#as_token = + +# A unique token for Homeservers to use to authenticate requests to +# application services. +# +#hs_token = + +# The localpart of the user associated with the application service. +# +#sender_localpart = + +# Whether requests from masqueraded users are rate-limited. +# +# The sender is excluded. +# +#rate_limited = false + +# The external protocols which the application service provides (e.g. +# IRC). +# +#protocols = [] + +# Whether the application service wants to receive ephemeral data. +# +#receive_ephemeral = false + +# Whether the application service wants to do device management, as part +# of MSC4190. +# +#device_management = false + +[[global.appservice..]] + +# Whether this application service has exclusive access to events within +# this namespace. +# +#exclusive = false + +# A regular expression defining which values this namespace includes. +# +#regex =