Implement declarative appservices. (closes #67)

Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
Jason Volk
2025-06-20 15:26:52 +00:00
parent 2e559a0d3e
commit a84e559640
3 changed files with 217 additions and 14 deletions

View File

@@ -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<String, AppService>,
#[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.<ID>",
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<String>,
/// 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<String>,
/// Events which are sent from certain users.
#[serde(default)]
pub users: Vec<AppServiceNamespace>,
/// Events which are sent in rooms with certain room aliases.
#[serde(default)]
pub aliases: Vec<AppServiceNamespace>,
/// Events which are sent in rooms with certain room IDs.
#[serde(default)]
pub rooms: Vec<AppServiceNamespace>,
/// 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<String>,
/// 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<AppService> 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.<ID>.<users|rooms|aliases>]"
)]
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<AppServiceNamespace> 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 {

View File

@@ -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<sending::Service>,
server: Arc<Server>,
}
struct Data {
@@ -36,6 +37,7 @@ impl crate::Service for Service {
registration_info: RwLock::new(BTreeMap::new()),
services: Services {
sending: args.depend::<sending::Service>("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<Self>) -> 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,

View File

@@ -1778,3 +1778,56 @@
# Bypass validation for diagnostic/debug use only.
#
#validate_signature = true
[global.appservice.<ID>]
# 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.<ID>.<users|rooms|aliases>]]
# Whether this application service has exclusive access to events within
# this namespace.
#
#exclusive = false
# A regular expression defining which values this namespace includes.
#
#regex =