Compare commits

...

13 Commits

Author SHA1 Message Date
Jade Ellis
4a7ad1350b WIP 2025-06-14 20:14:09 +01:00
Jade Ellis
d0f00e6f5c feat: Allow mentioning @room in an admin announcement 2025-06-14 19:09:54 +01:00
Jade Ellis
5d44653e3a fix: Incorrect command descriptions 2025-06-14 16:51:24 +01:00
Jade Ellis
44e60d0ea6 docs: Tiny phrasing changes to the security policy 2025-06-14 16:34:58 +01:00
Jade Ellis
d7514178ab ci: Fix extra bracket in commit shorthash 2025-06-13 14:30:26 +01:00
Jade Ellis
1d45e0b68c feat: Add warning when admin users will be exposed as support contacts 2025-06-13 13:39:50 +01:00
Jade Ellis
3c44dccd65 ci: HACK, disable saving to actions cache 2025-05-26 19:16:50 +01:00
Jade Ellis
b57be072c7 build: Don't rerun on git changes 2025-05-26 19:16:05 +01:00
Jade Ellis
ea5dc8e09d fix: Use correct brand in clap version string 2025-05-26 19:16:05 +01:00
Jade Ellis
b9d60c64e5 ci: Don't specify container for image builder 2025-05-26 19:16:04 +01:00
Jade Ellis
94ae824149 ci: Don't install rustup if it's already there 2025-05-26 19:16:03 +01:00
Jade Ellis
640714922b feat: For knock_restricted rooms, automatically join rooms we meet
restrictions for rather than knocking
2025-05-26 19:16:03 +01:00
Jade Ellis
2b268fdaf3 fix: Allow joining via invite for knock_restricted rooms 2025-05-26 19:16:01 +01:00
22 changed files with 469 additions and 35 deletions

View File

@@ -19,11 +19,20 @@ outputs:
rustc_version:
description: The rustc version installed
value: ${{ steps.rustc-version.outputs.version }}
rustup_version:
description: The rustup version installed
value: ${{ steps.rustup-version.outputs.version }}
runs:
using: composite
steps:
- name: Check if rustup is already installed
shell: bash
id: rustup-version
run: |
echo "version=$(rustup --version)" >> $GITHUB_OUTPUT
- name: Cache rustup toolchains
if: steps.rustup-version.outputs.version == ''
uses: actions/cache@v3
with:
path: |
@@ -33,6 +42,7 @@ runs:
# Requires repo to be cloned if toolchain is not specified
key: ${{ runner.os }}-rustup-${{ inputs.toolchain || hashFiles('**/rust-toolchain.toml') }}
- name: Install Rust toolchain
if: steps.rustup-version.outputs.version == ''
shell: bash
run: |
if ! command -v rustup &> /dev/null ; then

View File

@@ -57,7 +57,6 @@ jobs:
build-image:
runs-on: dind
container: ghcr.io/catthehacker/ubuntu:act-latest
needs: define-variables
permissions:
contents: read
@@ -181,14 +180,14 @@ jobs:
file: "docker/Dockerfile"
build-args: |
GIT_COMMIT_HASH=${{ github.sha }})
GIT_COMMIT_HASH_SHORT=${{ env.COMMIT_SHORT_SHA }})
GIT_COMMIT_HASH_SHORT=${{ env.COMMIT_SHORT_SHA }}
GIT_REMOTE_URL=${{github.event.repository.html_url }}
GIT_REMOTE_COMMIT_URL=${{github.event.head_commit.url }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha
cache-to: type=gha,mode=max
# cache-to: type=gha,mode=max
sbom: true
outputs: type=image,"name=${{ needs.define-variables.outputs.images_list }}",push-by-digest=true,name-canonical=true,push=true
env:
@@ -211,7 +210,6 @@ jobs:
merge:
runs-on: dind
container: ghcr.io/catthehacker/ubuntu:act-latest
needs: [define-variables, build-image]
steps:
- name: Download digests

1
Cargo.lock generated
View File

@@ -987,6 +987,7 @@ dependencies = [
"base64 0.22.1",
"blurhash",
"bytes",
"conduwuit_build_metadata",
"conduwuit_core",
"conduwuit_database",
"const-str",

View File

@@ -20,10 +20,10 @@ ### Responsible Disclosure
We appreciate the efforts of security researchers and the community in identifying and reporting vulnerabilities. To ensure that potential vulnerabilities are addressed properly, please follow these guidelines:
1. Contact members of the team over E2EE private message.
1. **Contact members of the team directly** over E2EE private message.
- [@jade:ellis.link](https://matrix.to/#/@jade:ellis.link)
- [@nex:nexy7574.co.uk](https://matrix.to/#/@nex:nexy7574.co.uk) <!-- ? -->
2. **Email the security team** directly at [security@continuwuity.org](mailto:security@continuwuity.org). This is not E2EE, so don't include sensitive details.
2. **Email the security team** at [security@continuwuity.org](mailto:security@continuwuity.org). This is not E2EE, so don't include sensitive details.
3. **Do not disclose the vulnerability publicly** until it has been addressed
4. **Provide detailed information** about the vulnerability, including:
- A clear description of the issue
@@ -48,7 +48,7 @@ ## Security Update Process
When security vulnerabilities are identified:
1. We will develop and test fixes in a private branch
1. We will develop and test fixes in a private fork
2. Security updates will be released as soon as possible
3. Release notes will include information about the vulnerabilities, avoiding details that could facilitate exploitation where possible
4. Critical security updates may be backported to the previous stable release

View File

@@ -119,6 +119,15 @@
#
#allow_announcements_check = true
# If enabled, continuwuity will send anonymous analytics data periodically
# to help improve development. This includes basic server metadata like
# version, commit hash, and federation status. All requests are signed
# with the server's federation signing key. Data is sent on startup (with
# up to 5 minutes jitter) and every 12 hours thereafter (with up to 30
# minutes jitter) to distribute load.
#
#allow_analytics = true
# Set this to any float value to multiply continuwuity's in-memory LRU
# caches with such as "auth_chain_cache_capacity".
#

View File

@@ -3,7 +3,7 @@
"$id": "https://continwuity.org/schema/announcements.schema.json",
"type": "object",
"properties": {
"updates": {
"announcements": {
"type": "array",
"items": {
"type": "object",
@@ -16,6 +16,10 @@
},
"date": {
"type": "string"
},
"mention_room": {
"type": "boolean",
"description": "Whether to mention the room (@room) when posting this announcement"
}
},
"required": [
@@ -26,6 +30,6 @@
}
},
"required": [
"updates"
"announcements"
]
}

View File

@@ -125,13 +125,13 @@ pub(super) enum DebugCommand {
reset: bool,
},
/// - Verify json signatures
/// - Sign JSON blob
///
/// This command needs a JSON blob provided in a Markdown code block below
/// the command.
SignJson,
/// - Verify json signatures
/// - Verify JSON signatures
///
/// This command needs a JSON blob provided in a Markdown code block below
/// the command.

View File

@@ -145,6 +145,16 @@ pub(super) async fn restart(&self, force: bool) -> Result {
self.write_str("Restarting server...").await
}
#[admin_command]
pub(super) async fn upload_analytics(&self) -> Result {
match self.services.analytics.force_upload().await {
| Ok(()) => self.write_str("Analytics uploaded successfully.").await,
| Err(e) =>
self.write_str(&format!("Failed to upload analytics: {e}"))
.await,
}
}
#[admin_command]
pub(super) async fn shutdown(&self) -> Result {
warn!("shutdown command");

View File

@@ -64,4 +64,7 @@ pub(super) enum ServerCommand {
/// - Shutdown the server
Shutdown,
/// - Upload analytics
UploadAnalytics,
}

View File

@@ -2162,6 +2162,109 @@ async fn knock_room_by_id_helper(
}
}
// For knock_restricted rooms, check if the user meets the restricted conditions
// If they do, attempt to join instead of knock
// This is not mentioned in the spec, but should be allowable (we're allowed to
// auto-join invites to knocked rooms)
let join_rule = services.rooms.state_accessor.get_join_rules(room_id).await;
if let JoinRule::KnockRestricted(restricted) = &join_rule {
let restriction_rooms: Vec<_> = restricted
.allow
.iter()
.filter_map(|a| match a {
| AllowRule::RoomMembership(r) => Some(&r.room_id),
| _ => None,
})
.collect();
// Check if the user is in any of the allowed rooms
let mut user_meets_restrictions = false;
for restriction_room_id in &restriction_rooms {
if services
.rooms
.state_cache
.is_joined(sender_user, restriction_room_id)
.await
{
user_meets_restrictions = true;
break;
}
}
// If the user meets the restrictions, try joining instead
if user_meets_restrictions {
debug_info!(
"{sender_user} meets the restricted criteria in knock_restricted room \
{room_id}, attempting to join instead of knock"
);
// For this case, we need to drop the state lock and get a new one in
// join_room_by_id_helper We need to release the lock here and let
// join_room_by_id_helper acquire it again
drop(state_lock);
match join_room_by_id_helper(
services,
sender_user,
room_id,
reason.clone(),
servers,
None,
&None,
)
.await
{
| Ok(_) => return Ok(knock_room::v3::Response::new(room_id.to_owned())),
| Err(e) => {
debug_warn!(
"Failed to convert knock to join for {sender_user} in {room_id}: {e:?}"
);
// Get a new state lock for the remaining knock logic
let new_state_lock = services.rooms.state.mutex.lock(room_id).await;
let server_in_room = services
.rooms
.state_cache
.server_in_room(services.globals.server_name(), room_id)
.await;
let local_knock = server_in_room
|| servers.is_empty()
|| (servers.len() == 1 && services.globals.server_is_ours(&servers[0]));
if local_knock {
knock_room_helper_local(
services,
sender_user,
room_id,
reason,
servers,
new_state_lock,
)
.boxed()
.await?;
} else {
knock_room_helper_remote(
services,
sender_user,
room_id,
reason,
servers,
new_state_lock,
)
.boxed()
.await?;
}
return Ok(knock_room::v3::Response::new(room_id.to_owned()));
},
}
}
} else if !matches!(join_rule, JoinRule::Knock | JoinRule::KnockRestricted(_)) {
debug_warn!(
"{sender_user} attempted to knock on room {room_id} but its join rule is \
{join_rule:?}, not knock or knock_restricted"
);
}
let server_in_room = services
.rooms
.state_cache
@@ -2209,6 +2312,12 @@ async fn knock_room_helper_local(
return Err!(Request(Forbidden("This room does not support knocking.")));
}
// Verify that this room has a valid knock or knock_restricted join rule
let join_rule = services.rooms.state_accessor.get_join_rules(room_id).await;
if !matches!(join_rule, JoinRule::Knock | JoinRule::KnockRestricted(_)) {
return Err!(Request(Forbidden("This room's join rule does not allow knocking.")));
}
let content = RoomMemberEventContent {
displayname: services.users.displayname(sender_user).await.ok(),
avatar_url: services.users.avatar_url(sender_user).await.ok(),

View File

@@ -79,12 +79,12 @@ fn main() {
// --- Rerun Triggers ---
// TODO: The git rerun triggers seem to always run
// Rerun if the git HEAD changes
println!("cargo:rerun-if-changed=.git/HEAD");
// Rerun if the ref pointed to by HEAD changes (e.g., new commit on branch)
if let Some(ref_path) = run_git_command(&["symbolic-ref", "--quiet", "HEAD"]) {
println!("cargo:rerun-if-changed=.git/{ref_path}");
}
// // Rerun if the git HEAD changes
// println!("cargo:rerun-if-changed=.git/HEAD");
// // Rerun if the ref pointed to by HEAD changes (e.g., new commit on branch)
// if let Some(ref_path) = run_git_command(&["symbolic-ref", "--quiet", "HEAD"])
// { println!("cargo:rerun-if-changed=.git/{ref_path}");
// }
println!("cargo:rerun-if-env-changed=GIT_COMMIT_HASH");
println!("cargo:rerun-if-env-changed=GIT_COMMIT_HASH_SHORT");

View File

@@ -219,6 +219,15 @@ pub fn check(config: &Config) -> Result {
));
}
// Check if support contact information is configured
if config.well_known.support_email.is_none() && config.well_known.support_mxid.is_none() {
warn!(
"No support contact information (support_email or support_mxid) is configured in \
the well_known section. Users in the admin room will be automatically listed as \
support contacts in the /.well-known/matrix/support endpoint."
);
}
if config
.url_preview_domain_contains_allowlist
.contains(&"*".to_owned())

View File

@@ -169,6 +169,18 @@ pub struct Config {
#[serde(alias = "allow_check_for_updates", default = "true_fn")]
pub allow_announcements_check: bool,
/// If enabled, continuwuity will send anonymous analytics data periodically
/// to help improve development. This includes basic server metadata like
/// version, build information and federation status. All requests are
/// signed with the server's federation signing key.
///
/// This is also used to warn about potential problems with federation, if
/// federation is enabled.
///
/// default: true
#[serde(default = "true_fn")]
pub allow_analytics: bool,
/// Set this to any float value to multiply continuwuity's in-memory LRU
/// caches with such as "auth_chain_cache_capacity".
///

View File

@@ -638,7 +638,7 @@ struct GetThirdPartyInvite {
warn!(?target_user_membership_event_id, "Banned user can't join");
false
} else if (join_rules == JoinRule::Invite
|| room_version.allow_knocking && join_rules == JoinRule::Knock)
|| room_version.allow_knocking && (join_rules == JoinRule::Knock || matches!(join_rules, JoinRule::KnockRestricted(_))))
// If the join_rule is invite then allow if membership state is invite or join
&& (target_user_current_membership == MembershipState::Join
|| target_user_current_membership == MembershipState::Invite)

View File

@@ -21,7 +21,10 @@
pub use ::tracing;
pub use config::Config;
pub use error::Error;
pub use info::{rustc_flags_capture, version, version::version};
pub use info::{
rustc_flags_capture, version,
version::{name, version},
};
pub use matrix::{Event, EventTypeExt, PduCount, PduEvent, PduId, RoomVersion, pdu, state_res};
pub use server::Server;
pub use utils::{ctor, dtor, implement, result, result::Result};

View File

@@ -15,7 +15,7 @@
#[clap(
about,
long_about = None,
name = "conduwuit",
name = conduwuit_core::name(),
version = conduwuit_core::version(),
)]
pub(crate) struct Args {

View File

@@ -78,6 +78,7 @@ zstd_compression = [
async-trait.workspace = true
base64.workspace = true
bytes.workspace = true
conduwuit-build-metadata.workspace = true
conduwuit-core.workspace = true
conduwuit-database.workspace = true
const-str.workspace = true

View File

@@ -0,0 +1,245 @@
//! # Analytics service
//!
//! This service is responsible for collecting and uploading anonymous server
//! metadata to help improve continuwuity development.
//!
//! All requests are signed with the server's federation signing key for
//! authentication. This service respects the `allow_analytics` configuration
//! option and is enabled by default.
//!
//! Analytics are sent on startup (with up to 5 minutes jitter) and every 12
//! hours thereafter (with up to 30 minutes jitter) to distribute load.
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use conduwuit::{
Result, Server, debug, err, info,
version::{self, user_agent},
warn,
};
use database::{Deserialized, Map};
use rand::Rng;
use ruma::ServerName;
use serde::{Deserialize, Serialize};
use tokio::{
sync::Notify,
time::{MissedTickBehavior, interval},
};
use crate::{Dep, client, config, federation, globals, server_keys, users};
extern crate conduwuit_build_metadata as build_metadata;
pub struct Service {
interval: Duration,
jitter: Duration,
startup_jitter: Duration,
interrupt: Notify,
db: Arc<Map>,
services: Services,
}
struct Services {
client: Dep<client::Service>,
globals: Dep<globals::Service>,
server_keys: Dep<server_keys::Service>,
federation: Dep<federation::Service>,
users: Dep<users::Service>,
server: Arc<Server>,
config: Dep<config::Service>,
}
#[derive(Debug, Serialize)]
struct AnalyticsPayload {
server_name: String,
version: &'static str,
commit_hash: Option<&'static str>,
user_count: usize,
federation_enabled: bool,
room_creation_allowed: bool,
public_room_directory_over_federation: bool,
build_profile: &'static str,
opt_level: &'static str,
rustc_version: &'static str,
features: Vec<&'static str>,
host: &'static str,
target: &'static str,
// the following can all be derived from the target
target_arch: &'static str,
target_os: &'static str,
target_env: &'static str,
target_family: &'static str,
}
#[derive(Debug, Deserialize)]
struct AnalyticsResponse {
success: bool,
message: Option<String>,
}
const ANALYTICS_URL: &str = "https://analytics.continuwuity.org/api/v1/metrics";
const ANALYTICS_SERVERNAME: &str = "analytics.continuwuity.org";
const ANALYTICS_INTERVAL: u64 = 43200; // 12 hours in seconds
const ANALYTICS_JITTER: u64 = 1800; // 30 minutes in seconds
const ANALYTICS_STARTUP_JITTER: u64 = 300; // 5 minutes in seconds
const LAST_ANALYTICS_TIMESTAMP: &[u8; 21] = b"last_analytics_upload";
#[async_trait]
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
let mut rng = rand::thread_rng();
let jitter_seconds = rng.gen_range(0..=ANALYTICS_JITTER);
let startup_jitter_seconds = rng.gen_range(0..=ANALYTICS_STARTUP_JITTER);
Ok(Arc::new(Self {
interval: Duration::from_secs(ANALYTICS_INTERVAL),
jitter: Duration::from_secs(jitter_seconds),
startup_jitter: Duration::from_secs(startup_jitter_seconds),
interrupt: Notify::new(),
db: args.db["global"].clone(),
services: Services {
globals: args.depend::<globals::Service>("globals"),
client: args.depend::<client::Service>("client"),
config: args.depend::<config::Service>("config"),
server_keys: args.depend::<server_keys::Service>("server_keys"),
users: args.depend::<users::Service>("users"),
federation: args.depend::<federation::Service>("federation"),
server: args.server.clone(),
},
}))
}
#[tracing::instrument(skip_all, name = "analytics", level = "debug")]
async fn worker(self: Arc<Self>) -> Result<()> {
if !self.services.server.config.allow_analytics {
debug!("Analytics collection is disabled");
return Ok(());
}
// Send initial analytics on startup (with shorter jitter)
tokio::time::sleep(self.startup_jitter).await;
if let Err(e) = self.upload_analytics().await {
warn!(%e, "Failed to upload initial analytics");
}
let mut i = interval(self.interval);
i.set_missed_tick_behavior(MissedTickBehavior::Delay);
i.reset_after(self.interval + self.jitter);
loop {
tokio::select! {
() = self.interrupt.notified() => break,
_ = i.tick() => {
if let Err(e) = self.upload_analytics().await {
warn!(%e, "Failed to upload analytics");
}
}
}
}
Ok(())
}
fn interrupt(&self) { self.interrupt.notify_waiters(); }
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
#[tracing::instrument(skip_all)]
async fn upload_analytics(&self) -> Result<()> {
let payload = self.collect_metadata().await;
let json_payload = serde_json::to_vec(&payload)?;
// Create HTTP request
let request = http::Request::builder()
.method("POST")
.uri(ANALYTICS_URL)
.header("Content-Type", "application/json")
.header("User-Agent", user_agent())
.body(json_payload)?;
// Sign the request using federation signing
let reqwest_request = self.services.federation.sign_non_federation_request(
ServerName::parse(ANALYTICS_SERVERNAME).unwrap(),
request,
)?;
// self.sign_analytics_request(&mut request).await?;
let response = self
.services
.client
.default
.execute(reqwest_request)
.await?;
let status = response.status();
if let Ok(analytics_response) =
serde_json::from_str::<AnalyticsResponse>(&response.text().await?)
{
if analytics_response.success {
debug!("Analytics uploaded successfully");
self.update_last_upload_timestamp().await;
}
let msg = analytics_response.message.unwrap_or_default();
warn!("Analytics upload warning: {}", msg);
} else if status.is_success() {
info!("Analytics uploaded successfully (no structured response)");
self.update_last_upload_timestamp().await;
} else {
warn!("Analytics upload failed (no structured response) with status: {}", status);
}
Ok(())
}
async fn collect_metadata(&self) -> AnalyticsPayload {
let config = &self.services.config;
AnalyticsPayload {
server_name: self.services.globals.server_name().to_string(),
version: version::version(),
commit_hash: build_metadata::GIT_COMMIT_HASH,
user_count: self.services.users.count().await,
federation_enabled: config.allow_federation,
room_creation_allowed: config.allow_room_creation,
public_room_directory_over_federation: config
.allow_public_room_directory_over_federation,
build_profile: build_metadata::built::PROFILE,
opt_level: build_metadata::built::OPT_LEVEL,
rustc_version: build_metadata::built::RUSTC_VERSION,
features: build_metadata::built::FEATURES.to_vec(),
host: build_metadata::built::HOST,
target: build_metadata::built::TARGET,
target_arch: build_metadata::built::CFG_TARGET_ARCH,
target_os: build_metadata::built::CFG_OS,
target_env: build_metadata::built::CFG_ENV,
target_family: build_metadata::built::CFG_FAMILY,
}
}
async fn update_last_upload_timestamp(&self) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.db.raw_put(LAST_ANALYTICS_TIMESTAMP, timestamp);
}
pub async fn last_upload_timestamp(&self) -> u64 {
self.db
.get(LAST_ANALYTICS_TIMESTAMP)
.await
.deserialized()
.unwrap_or(0_u64)
}
pub async fn force_upload(&self) -> Result<()> {
if !self.services.config.allow_analytics {
return Err(err!(Config("allow_analytics", "Analytics collection is disabled")));
}
self.upload_analytics().await
}
}

View File

@@ -20,7 +20,7 @@
use async_trait::async_trait;
use conduwuit::{Result, Server, debug, info, warn};
use database::{Deserialized, Map};
use ruma::events::room::message::RoomMessageEventContent;
use ruma::events::{Mentions, room::message::RoomMessageEventContent};
use serde::Deserialize;
use tokio::{
sync::Notify,
@@ -53,6 +53,8 @@ struct CheckForAnnouncementsResponseEntry {
id: u64,
date: Option<String>,
message: String,
#[serde(default, skip_serializing_if = "bool::not")]
mention_room: bool,
}
const CHECK_FOR_ANNOUNCEMENTS_URL: &str =
@@ -139,19 +141,20 @@ async fn handle(&self, announcement: &CheckForAnnouncementsResponseEntry) {
} else {
info!("[announcements] {:#}", announcement.message);
}
let mut message = RoomMessageEventContent::text_markdown(format!(
"### New announcement{}\n\n{}",
announcement
.date
.as_ref()
.map_or_else(String::new, |date| format!(" - `{date}`")),
announcement.message
));
self.services
.admin
.send_message(RoomMessageEventContent::text_markdown(format!(
"### New announcement{}\n\n{}",
announcement
.date
.as_ref()
.map_or_else(String::new, |date| format!(" - `{date}`")),
announcement.message
)))
.await
.ok();
if announcement.mention_room {
message = message.add_mentions(Mentions::with_room_mention());
}
self.services.admin.send_message(message).await.ok();
}
#[inline]

View File

@@ -107,6 +107,20 @@ fn prepare(&self, dest: &ServerName, mut request: http::Request<Vec<u8>>) -> Res
Ok(request)
}
#[implement(super::Service)]
pub fn sign_non_federation_request(
&self,
dest: &ServerName,
mut request: http::Request<Vec<u8>>,
) -> Result<Request> {
self.sign_request(&mut request, dest);
let request = Request::try_from(request)?;
self.services.server.check_running()?;
Ok(request)
}
#[implement(super::Service)]
fn validate_url(&self, url: &Url) -> Result<()> {
if let Some(url_host) = url.host_str() {

View File

@@ -9,6 +9,7 @@
pub mod account_data;
pub mod admin;
pub mod analytics;
pub mod announcements;
pub mod appservice;
pub mod client;

View File

@@ -10,8 +10,8 @@
use tokio::sync::Mutex;
use crate::{
account_data, admin, announcements, appservice, client, config, emergency, federation,
globals, key_backups,
account_data, admin, analytics, announcements, appservice, client, config, emergency,
federation, globals, key_backups,
manager::Manager,
media, moderation, presence, pusher, resolver, rooms, sending, server_keys, service,
service::{Args, Map, Service},
@@ -21,6 +21,7 @@
pub struct Services {
pub account_data: Arc<account_data::Service>,
pub admin: Arc<admin::Service>,
pub analytics: Arc<analytics::Service>,
pub appservice: Arc<appservice::Service>,
pub config: Arc<config::Service>,
pub client: Arc<client::Service>,
@@ -68,6 +69,7 @@ macro_rules! build {
Ok(Arc::new(Self {
account_data: build!(account_data::Service),
admin: build!(admin::Service),
analytics: build!(analytics::Service),
appservice: build!(appservice::Service),
resolver: build!(resolver::Service),
client: build!(client::Service),