From 3929aca1ec114102bee9ed84bd0e41824de5806f Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Mon, 30 Mar 2026 18:13:58 +0100 Subject: [PATCH 01/13] Add snapcraft.yaml to produce snap package, with tuwunel-init to capture essential config initialization info on first use. --- .gitignore | 3 + Cargo.lock | 162 ++++++++++++++++++++++++++------ snap/local/check-config.sh | 11 +++ snap/snapcraft.yaml | 68 ++++++++++++++ src/init/Cargo.toml | 12 +++ src/init/src/main.rs | 187 +++++++++++++++++++++++++++++++++++++ 6 files changed, 413 insertions(+), 30 deletions(-) create mode 100755 snap/local/check-config.sh create mode 100644 snap/snapcraft.yaml create mode 100644 src/init/Cargo.toml create mode 100644 src/init/src/main.rs diff --git a/.gitignore b/.gitignore index a7a7de644f..b0855379bb 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ tests/complement/logs.jsonl # Editor crashfiles *.save *.swp + +# Snaps packed +tuwunel_*_*.snap diff --git a/Cargo.lock b/Cargo.lock index 368e6fc567..b72191c16f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -473,7 +473,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -491,6 +491,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -581,6 +587,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -871,7 +883,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" dependencies = [ - "crossterm", + "crossterm 0.29.0", ] [[package]] @@ -973,7 +985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" dependencies = [ "crokey-proc_macros", - "crossterm", + "crossterm 0.29.0", "once_cell", "serde", "strict", @@ -985,7 +997,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" dependencies = [ - "crossterm", + "crossterm 0.29.0", "proc-macro2", "quote", "strict", @@ -1048,18 +1060,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", "futures-core", - "mio", + "mio 1.1.1", "parking_lot", "rustix", "signal-hook", @@ -1236,7 +1264,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", ] @@ -1281,6 +1309,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ed25519" version = "2.2.3" @@ -1641,6 +1675,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2164,6 +2216,23 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.11.0", + "crossterm 0.25.0", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "insta" version = "1.46.3" @@ -2694,6 +2763,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -2739,13 +2820,22 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2757,7 +2847,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2919,7 +3009,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -2940,7 +3030,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -2951,7 +3041,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.11.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -2984,7 +3074,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3002,7 +3092,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -3015,7 +3105,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] @@ -3026,7 +3116,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3038,7 +3128,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags", + "bitflags 2.11.0", "block2", "objc2", "objc2-cloud-kit", @@ -3333,7 +3423,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -3442,7 +3532,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14104c5a24d9bcf7eb2c24753e0f49fe14555d8bd565ea3d38e4b4303267259d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "memchr", "pulldown-cmark-escape", "unicase", @@ -3687,7 +3777,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -4092,7 +4182,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -4187,7 +4277,7 @@ name = "rustyline-async" version = "0.4.6" source = "git+https://github.com/matrix-construct/rustyline-async?rev=ba743cae940659e95d7b8604bbd6fdd755171c8c#ba743cae940659e95d7b8604bbd6fdd755171c8c" dependencies = [ - "crossterm", + "crossterm 0.29.0", "futures-util", "pin-project", "thingbuf", @@ -4250,7 +4340,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -4349,7 +4439,7 @@ version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e200860daf76e09f9ad111bce25928f96bedbb84bc5934b37f05bb445727c70e" dependencies = [ - "bitflags", + "bitflags 2.11.0", "log", "sentry-core", ] @@ -4384,7 +4474,7 @@ version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b07eefe04486316c57aba08ab53dd44753c25102d1d3fe05775cc93a13262d9" dependencies = [ - "bitflags", + "bitflags 2.11.0", "sentry-backtrace", "sentry-core", "tracing-core", @@ -4582,7 +4672,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.1.1", "signal-hook", ] @@ -4966,7 +5057,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "signal-hook-registry", "socket2 0.6.3", @@ -5168,7 +5259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -5324,6 +5415,17 @@ dependencies = [ "webpki-root-certs", ] +[[package]] +name = "tuwunel-init" +version = "1.5.1" +dependencies = [ + "anyhow", + "clap", + "inquire", + "regex", + "tuwunel_core", +] + [[package]] name = "tuwunel_admin" version = "1.5.1" @@ -5872,7 +5974,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -6366,7 +6468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/snap/local/check-config.sh b/snap/local/check-config.sh new file mode 100755 index 0000000000..0e1872b75f --- /dev/null +++ b/snap/local/check-config.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +if [ ! -f "$SNAP_COMMON/tuwunel.toml" ]; then + echo "Configuration file not found at $SNAP_COMMON/tuwunel.toml" + echo "Please initialize the configuration by running:" + echo " sudo tuwunel.init" + sleep 60 + exit 1 +fi + +exec "$@" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000000..ab78b44dbb --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,68 @@ +name: tuwunel +base: core24 # Using core24 as the base for a modern Rust toolchain (1.94.0+) +version: 'git' +summary: High Performance Matrix Homeserver in Rust! +description: | + Tuwunel is a featureful Matrix homeserver you can use instead of Synapse + with your favorite client, bridge or bot. It is written entirely in Rust to be + a scalable, low-cost, enterprise-ready, community-driven alternative, fully + implementing the Matrix Specification. + +grade: stable +confinement: strict + +apps: + tuwunel: + command: tuwunel + command-chain: [bin/check-config.sh] + daemon: simple # This creates the background service + install-mode: enable # Automatically start the service when the snap is installed + plugs: + - network + - network-bind + environment: + # By default, Snap services are heavily sandboxed. + # You can point Tuwunel to look for its configuration inside the snap's data directory. + TUWUNEL_CONFIG: $SNAP_COMMON/tuwunel.toml + + tuwunel-cli: + command: tuwunel + plugs: + - network + - network-bind + environment: + # By default, Snap services are heavily sandboxed. + # You can point Tuwunel to look for its configuration inside the snap's data directory. + TUWUNEL_CONFIG: $SNAP_COMMON/tuwunel.toml + + init: + command: tuwunel-init + plugs: + - network + - network-bind + environment: + TUWUNEL_CONFIG: $SNAP_COMMON/tuwunel.toml + +parts: + wrapper: + plugin: dump + source: snap/local + + tuwunel: + after: [wrapper] + plugin: rust + source: https://github.com/matrix-construct/tuwunel.git + source-type: git + build-packages: + - build-essential + - pkg-config + - libssl-dev + - liburing-dev + - clang + - libclang-dev + - cmake + - make + stage-packages: + - liburing2 + # The rust plugin automatically runs `cargo build --release` + # and installs the resulting binaries into the snap. diff --git a/src/init/Cargo.toml b/src/init/Cargo.toml new file mode 100644 index 0000000000..d9c3a1f58d --- /dev/null +++ b/src/init/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tuwunel-init" +version = "1.5.1" +edition = "2024" +authors.workspace = true + +[dependencies] +tuwunel_core = { path = "../core" } +clap = { workspace = true } +inquire = "0.7" +anyhow = "1.0" +regex = { workspace = true } diff --git a/src/init/src/main.rs b/src/init/src/main.rs new file mode 100644 index 0000000000..a6ea6d9c36 --- /dev/null +++ b/src/init/src/main.rs @@ -0,0 +1,187 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use inquire::{Text, Password, PasswordDisplayMode, validator::Validation}; +use regex::Regex; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Parser)] +#[command(name = "tuwunel-init")] +#[command(about = "Initialize tuwunel configuration and create the first user")] +struct Cli { + #[arg(short, long)] + path: Option, +} + +fn estimate_entropy(password: &str) -> f64 { + let mut pool = 0; + if password.chars().any(|c| c.is_ascii_lowercase()) { pool += 26; } + if password.chars().any(|c| c.is_ascii_uppercase()) { pool += 26; } + if password.chars().any(|c| c.is_ascii_digit()) { pool += 10; } + if password.chars().any(|c| !c.is_ascii_alphanumeric()) { pool += 32; } + + if pool == 0 { return 0.0; } + (pool as f64).log2() * (password.chars().count() as f64) +} + +fn classify_entropy(entropy: f64) -> &'static str { + if entropy < 40.0 { "Poor" } + else if entropy < 60.0 { "Ok" } + else if entropy < 80.0 { "Good" } + else { "Excellent" } +} + +fn main() -> Result<()> { + let args = Cli::parse(); + + let config_path = args.path.or_else(|| { + env::var("TUWUNEL_CONFIG").ok().map(PathBuf::from) + }).unwrap_or_else(|| PathBuf::from("tuwunel.toml")); + + if config_path.exists() { + println!("Configuration file already exists at {}. Exiting gracefully.", config_path.display()); + return Ok(()); + } + + if let Some(parent) = config_path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + fs::create_dir_all(parent).context("Failed to create parent directories")?; + } + } + + println!("Welcome to Tuwunel initialization."); + println!("We will now set up the minimum required configuration items and create an initial admin user.\n"); + + let domain_regex = Regex::new(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$").unwrap(); + + let server_name = Text::new("Server name (e.g., matrix.example.com):") + .with_help_message("This must be identical to the domain used for reverse proxying.") + .with_validator(move |input: &str| { + if input.trim().is_empty() { + Ok(Validation::Invalid("Server name cannot be empty.".into())) + } else if !domain_regex.is_match(input.trim()) { + Ok(Validation::Invalid("This does not look like a proper domain name. Please enter a valid fully qualified domain name.".into())) + } else { + Ok(Validation::Valid) + } + }) + .prompt() + .context("Failed to read server name")?; + + let server_name = server_name.trim(); + + let registration_token = Password::new("Registration token:") + .with_display_mode(PasswordDisplayMode::Masked) + .with_help_message("A static registration token users will have to provide when creating an account.") + .with_validator(move |input: &str| { + if input.trim().is_empty() { + return Ok(Validation::Invalid("Registration token cannot be empty.".into())); + } + let ent = estimate_entropy(input.trim()); + let class = classify_entropy(ent); + if ent < 40.0 { + Ok(Validation::Invalid("Registration token entropy is Poor. Please choose a stronger secure token.".into())) + } else { + Ok(Validation::Valid) // Only valid if Ok, Good or Excellent + } + }) + .prompt() + .context("Failed to read registration token")?; + + let registration_token = registration_token.trim(); + println!("Registration token entropy: {}", classify_entropy(estimate_entropy(registration_token))); + + println!("\nNow we will create the initial server administrator."); + let admin_username = Text::new("Admin username (without domain portion):") + .with_validator(|input: &str| { + if input.trim().is_empty() { + Ok(Validation::Invalid("Admin username cannot be empty.".into())) + } else if input.contains(':') || input.contains('@') { + Ok(Validation::Invalid("Just the local username, no @ or domain.".into())) + } else { + Ok(Validation::Valid) + } + }) + .prompt() + .context("Failed to read admin username")?; + + let admin_username = admin_username.trim(); + + let admin_password = Password::new("Admin password:") + .with_display_mode(PasswordDisplayMode::Masked) + .with_validator(move |input: &str| { + if input.trim().is_empty() { + return Ok(Validation::Invalid("Admin password cannot be empty.".into())); + } + let ent = estimate_entropy(input.trim()); + if ent < 40.0 { + Ok(Validation::Invalid(format!("Admin password entropy is {}. Please choose a stronger password.", classify_entropy(ent)).into())) + } else { + Ok(Validation::Valid) + } + }) + .prompt() + .context("Failed to read admin password")?; + + let admin_password = admin_password.trim(); + println!("Admin password entropy: {}", classify_entropy(estimate_entropy(admin_password))); + + // Read the build-time generated config example + let mut config_text = include_str!("../../tuwunel-example.toml").to_string(); + + config_text = config_text.replace( + "#server_name =\n", + &format!("server_name = \"{}\"\n", server_name) + ); + config_text = config_text.replace( + "#registration_token =\n", + &format!("registration_token = \"{}\"\n", registration_token) + ); + + fs::write(&config_path, config_text).context("Failed to write tuwunel config file")?; + println!("\nConfiguration file successfully written to {}", config_path.display()); + + // Attempt to invoke the Daemon binary to create the admin user + // We expect `tuwunel` binary to be situated in the exact same directory as this tool. + let mut tuwunel_bin = PathBuf::from("tuwunel"); + if let Ok(current_exe) = env::current_exe() { + if let Some(parent) = current_exe.parent() { + let adjacent = parent.join("tuwunel"); + if adjacent.exists() { + tuwunel_bin = adjacent; + } + } + } + + // Pass the config path directly to tuwunel as --config + println!("Initializing database and creating the first administrator..."); + let status = Command::new(&tuwunel_bin) + .arg("--config") + .arg(&config_path) + .arg("--maintenance") + .arg("--execute") + .arg(format!("user create {} {}", admin_username, admin_password)) + .arg("--execute") + .arg(format!("user make-user-admin @{}:{}", admin_username, server_name)) + .arg("--execute") + .arg("server shutdown") + .status() + .with_context(|| format!("Failed to evaluate tuwunel binary: {}", tuwunel_bin.display()))?; + + if status.success() { + println!("\nInitialization Complete! \u{1F389}"); + println!("The daemon can now be started. If using snap, the service will pick up the config momentarily."); + println!("You can sign into the server using:"); + println!(" Homeserver URL: https://{server_name}"); + println!(" Username: @{admin_username}:{server_name}"); + println!(" Password: "); + } else { + println!("\nUser creation failed unexpectedly (exited with: {status})."); + println!("You can attempt to retry creating the admin manually by running:"); + println!(" {} --maintenance --execute 'user create ...'", tuwunel_bin.display()); + } + + Ok(()) +} From a390ba979bb738b3c676568007d3b793e41344fe Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Mon, 30 Mar 2026 19:24:22 +0100 Subject: [PATCH 02/13] Build local source, without warnings --- snap/snapcraft.yaml | 5 +++-- src/init/src/main.rs | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index ab78b44dbb..c32676bdc1 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -47,12 +47,13 @@ parts: wrapper: plugin: dump source: snap/local + organize: + check-config.sh: bin/check-config.sh tuwunel: after: [wrapper] plugin: rust - source: https://github.com/matrix-construct/tuwunel.git - source-type: git + source: . build-packages: - build-essential - pkg-config diff --git a/src/init/src/main.rs b/src/init/src/main.rs index a6ea6d9c36..0afc5f314e 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -80,7 +80,6 @@ fn main() -> Result<()> { return Ok(Validation::Invalid("Registration token cannot be empty.".into())); } let ent = estimate_entropy(input.trim()); - let class = classify_entropy(ent); if ent < 40.0 { Ok(Validation::Invalid("Registration token entropy is Poor. Please choose a stronger secure token.".into())) } else { @@ -128,8 +127,8 @@ fn main() -> Result<()> { let admin_password = admin_password.trim(); println!("Admin password entropy: {}", classify_entropy(estimate_entropy(admin_password))); - // Read the build-time generated config example - let mut config_text = include_str!("../../tuwunel-example.toml").to_string(); + // Read the build-time generated config example (this embeds the file directly into the binary at compile time!) + let mut config_text = include_str!("../../../tuwunel-example.toml").to_string(); config_text = config_text.replace( "#server_name =\n", From 9044d7aa2e8a84e703067060c1a165ef0fb3caf9 Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Mon, 30 Mar 2026 19:36:42 +0100 Subject: [PATCH 03/13] If writing the initial config for a snap, put the database in --- snap/snapcraft.yaml | 4 ++-- src/init/src/main.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index c32676bdc1..870cca8c80 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -21,7 +21,7 @@ apps: - network - network-bind environment: - # By default, Snap services are heavily sandboxed. + # By default, snap services are heavily sandboxed. # You can point Tuwunel to look for its configuration inside the snap's data directory. TUWUNEL_CONFIG: $SNAP_COMMON/tuwunel.toml @@ -31,7 +31,7 @@ apps: - network - network-bind environment: - # By default, Snap services are heavily sandboxed. + # By default, snap services are heavily sandboxed. # You can point Tuwunel to look for its configuration inside the snap's data directory. TUWUNEL_CONFIG: $SNAP_COMMON/tuwunel.toml diff --git a/src/init/src/main.rs b/src/init/src/main.rs index 0afc5f314e..7211bd6370 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -139,6 +139,14 @@ fn main() -> Result<()> { &format!("registration_token = \"{}\"\n", registration_token) ); + // If running inside a Snap, we must change the default database path to the writable SNAP_COMMON directory + if let Ok(snap_common) = env::var("SNAP_COMMON") { + config_text = config_text.replace( + "#database_path = \"/var/lib/tuwunel\"", + &format!("database_path = \"{snap_common}\"") + ); + } + fs::write(&config_path, config_text).context("Failed to write tuwunel config file")?; println!("\nConfiguration file successfully written to {}", config_path.display()); From cf6aa3813d84d6dfc499b3435fa23bd305fa6414 Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Mon, 30 Mar 2026 20:31:58 +0100 Subject: [PATCH 04/13] Consider alternate names for the daemon and the CLI --- snap/snapcraft.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 870cca8c80..e40eac5ed1 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -12,7 +12,7 @@ grade: stable confinement: strict apps: - tuwunel: + tuwuneld: command: tuwunel command-chain: [bin/check-config.sh] daemon: simple # This creates the background service @@ -25,7 +25,7 @@ apps: # You can point Tuwunel to look for its configuration inside the snap's data directory. TUWUNEL_CONFIG: $SNAP_COMMON/tuwunel.toml - tuwunel-cli: + tuwunel: command: tuwunel plugs: - network From 2e15900a91e61dc2a9dca1c12ac96f44c11c627b Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Mon, 30 Mar 2026 20:45:28 +0100 Subject: [PATCH 05/13] Try a better sequence to setup the initial admin user --- snap/snapcraft.yaml | 2 +- src/init/src/main.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index e40eac5ed1..53814e3556 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -12,7 +12,7 @@ grade: stable confinement: strict apps: - tuwuneld: + daemon: command: tuwunel command-chain: [bin/check-config.sh] daemon: simple # This creates the background service diff --git a/src/init/src/main.rs b/src/init/src/main.rs index 7211bd6370..25b0dfcbbc 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -169,9 +169,9 @@ fn main() -> Result<()> { .arg(&config_path) .arg("--maintenance") .arg("--execute") - .arg(format!("user create {} {}", admin_username, admin_password)) + .arg(format!("users create_user @{}:{} {}", admin_username, server_name, admin_password)) .arg("--execute") - .arg(format!("user make-user-admin @{}:{}", admin_username, server_name)) + .arg(format!("users make_user_admin {}", admin_username)) .arg("--execute") .arg("server shutdown") .status() From f008bc2374db7054ceb399a14a793c983f1388cc Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Tue, 31 Mar 2026 07:50:25 +0100 Subject: [PATCH 06/13] No need to make first user admin, that happens automatically --- src/init/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/init/src/main.rs b/src/init/src/main.rs index 25b0dfcbbc..f5b9c1d917 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -170,8 +170,8 @@ fn main() -> Result<()> { .arg("--maintenance") .arg("--execute") .arg(format!("users create_user @{}:{} {}", admin_username, server_name, admin_password)) - .arg("--execute") - .arg(format!("users make_user_admin {}", admin_username)) + //.arg("--execute") + //.arg(format!("users make_user_admin {}", admin_username)) .arg("--execute") .arg("server shutdown") .status() From 277a64862c72c0d7b5b2d65e4da4474f8bb394ca Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Tue, 31 Mar 2026 10:50:10 +0100 Subject: [PATCH 07/13] Make first user creation optional in tuwunel-init --- src/init/src/main.rs | 112 ++++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 38 deletions(-) diff --git a/src/init/src/main.rs b/src/init/src/main.rs index f5b9c1d917..324b6407de 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -51,8 +51,8 @@ fn main() -> Result<()> { } } - println!("Welcome to Tuwunel initialization."); - println!("We will now set up the minimum required configuration items and create an initial admin user.\n"); + println!("Tuwunel initialization."); + println!("We will create a minimal configuration file for your matrix server.\n"); let domain_regex = Regex::new(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$").unwrap(); @@ -62,7 +62,7 @@ fn main() -> Result<()> { if input.trim().is_empty() { Ok(Validation::Invalid("Server name cannot be empty.".into())) } else if !domain_regex.is_match(input.trim()) { - Ok(Validation::Invalid("This does not look like a proper domain name. Please enter a valid fully qualified domain name.".into())) + Ok(Validation::Invalid("Please enter a valid fully qualified domain name.".into())) } else { Ok(Validation::Valid) } @@ -81,9 +81,9 @@ fn main() -> Result<()> { } let ent = estimate_entropy(input.trim()); if ent < 40.0 { - Ok(Validation::Invalid("Registration token entropy is Poor. Please choose a stronger secure token.".into())) + Ok(Validation::Invalid("Registration is insecure. Please choose a more secure token.".into())) } else { - Ok(Validation::Valid) // Only valid if Ok, Good or Excellent + Ok(Validation::Valid) } }) .prompt() @@ -92,40 +92,66 @@ fn main() -> Result<()> { let registration_token = registration_token.trim(); println!("Registration token entropy: {}", classify_entropy(estimate_entropy(registration_token))); - println!("\nNow we will create the initial server administrator."); - let admin_username = Text::new("Admin username (without domain portion):") + let create_user_decision = Text::new("Would you like to create an initial admin user? [Yes/No/Quit]:") .with_validator(|input: &str| { - if input.trim().is_empty() { - Ok(Validation::Invalid("Admin username cannot be empty.".into())) - } else if input.contains(':') || input.contains('@') { - Ok(Validation::Invalid("Just the local username, no @ or domain.".into())) - } else { - Ok(Validation::Valid) + let s = input.trim().to_lowercase(); + match s.as_str() { + "y" | "yes" | "n" | "no" | "q" | "quit" | "" => Ok(Validation::Valid), + _ => Ok(Validation::Invalid("Please enter yes, no, or quit.".into())) } }) .prompt() - .context("Failed to read admin username")?; + .context("Failed to read user creation decision")?; - let admin_username = admin_username.trim(); + let decision = create_user_decision.trim().to_lowercase(); + if decision == "q" || decision == "quit" { + println!("Exiting without writing configuration."); + return Ok(()); + } - let admin_password = Password::new("Admin password:") - .with_display_mode(PasswordDisplayMode::Masked) - .with_validator(move |input: &str| { - if input.trim().is_empty() { - return Ok(Validation::Invalid("Admin password cannot be empty.".into())); - } - let ent = estimate_entropy(input.trim()); - if ent < 40.0 { - Ok(Validation::Invalid(format!("Admin password entropy is {}. Please choose a stronger password.", classify_entropy(ent)).into())) - } else { - Ok(Validation::Valid) - } - }) - .prompt() - .context("Failed to read admin password")?; - - let admin_password = admin_password.trim(); - println!("Admin password entropy: {}", classify_entropy(estimate_entropy(admin_password))); + let create_user = decision == "y" || decision == "yes" || decision == ""; + + let mut admin_username_opt = None; + let mut admin_password_opt = None; + + if create_user { + let admin_username = Text::new("Admin username (without domain portion, eg. 'tom'):") + .with_validator(|input: &str| { + if input.trim().is_empty() { + Ok(Validation::Invalid("Admin username cannot be empty.".into())) + } else if input.contains(':') || input.contains('@') { + Ok(Validation::Invalid("Just the local username, no @ or domain.".into())) + } else { + Ok(Validation::Valid) + } + }) + .prompt() + .context("Failed to read admin username")?; + + let admin_username = admin_username.trim().to_string(); + + let admin_password = Password::new("Admin password:") + .with_display_mode(PasswordDisplayMode::Masked) + .with_validator(move |input: &str| { + if input.trim().is_empty() { + return Ok(Validation::Invalid("Admin password cannot be empty.".into())); + } + let ent = estimate_entropy(input.trim()); + if ent < 40.0 { + Ok(Validation::Invalid(format!("Admin password entropy is {}. Please choose a stronger password.", classify_entropy(ent)).into())) + } else { + Ok(Validation::Valid) + } + }) + .prompt() + .context("Failed to read admin password")?; + + let admin_password = admin_password.trim().to_string(); + println!("Admin password security: {}", classify_entropy(estimate_entropy(&admin_password))); + + admin_username_opt = Some(admin_username); + admin_password_opt = Some(admin_password); + } // Read the build-time generated config example (this embeds the file directly into the binary at compile time!) let mut config_text = include_str!("../../../tuwunel-example.toml").to_string(); @@ -149,6 +175,12 @@ fn main() -> Result<()> { fs::write(&config_path, config_text).context("Failed to write tuwunel config file")?; println!("\nConfiguration file successfully written to {}", config_path.display()); + + if !create_user { + println!("\nInitialization Complete! \u{1F389}"); + println!("The daemon can now be started. If using snap, the service will pick up the config momentarily."); + return Ok(()); + } // Attempt to invoke the Daemon binary to create the admin user // We expect `tuwunel` binary to be situated in the exact same directory as this tool. @@ -163,15 +195,15 @@ fn main() -> Result<()> { } // Pass the config path directly to tuwunel as --config - println!("Initializing database and creating the first administrator..."); + println!("Initializing database and to create the first administrator..."); + let admin_username = admin_username_opt.unwrap(); + let admin_password = admin_password_opt.unwrap(); let status = Command::new(&tuwunel_bin) .arg("--config") .arg(&config_path) .arg("--maintenance") .arg("--execute") .arg(format!("users create_user @{}:{} {}", admin_username, server_name, admin_password)) - //.arg("--execute") - //.arg(format!("users make_user_admin {}", admin_username)) .arg("--execute") .arg("server shutdown") .status() @@ -179,8 +211,12 @@ fn main() -> Result<()> { if status.success() { println!("\nInitialization Complete! \u{1F389}"); - println!("The daemon can now be started. If using snap, the service will pick up the config momentarily."); - println!("You can sign into the server using:"); + println!("The daemon can now be started, or will start automatically if you installed the Tuwunel snap."); + println!("It will by default listen only on 127.0.0.1:8008, you will probably want to direct traffic"); + println!("there by reverse proxy. See the docs for more information."); + println!(""); + println!("You can sign into the server at:"); + println!(""); println!(" Homeserver URL: https://{server_name}"); println!(" Username: @{admin_username}:{server_name}"); println!(" Password: "); From 47b79ee31a0be7536aefc4530bd01e2142ad94ca Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Tue, 31 Mar 2026 10:57:56 +0100 Subject: [PATCH 08/13] For snaps use /db for database --- src/init/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/init/src/main.rs b/src/init/src/main.rs index 324b6407de..924d334317 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -169,7 +169,7 @@ fn main() -> Result<()> { if let Ok(snap_common) = env::var("SNAP_COMMON") { config_text = config_text.replace( "#database_path = \"/var/lib/tuwunel\"", - &format!("database_path = \"{snap_common}\"") + &format!("database_path = \"{snap_common}/db\"") ); } From cf8a7cc92fe1394cff87ea49499f802dc21b6258 Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Tue, 31 Mar 2026 11:09:26 +0100 Subject: [PATCH 09/13] tuwunel-init checks that config file can be written --- src/init/src/main.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/init/src/main.rs b/src/init/src/main.rs index 924d334317..d05cb8329c 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -47,7 +47,22 @@ fn main() -> Result<()> { if let Some(parent) = config_path.parent() { if !parent.as_os_str().is_empty() && !parent.exists() { - fs::create_dir_all(parent).context("Failed to create parent directories")?; + fs::create_dir_all(parent).map_err(|e| { + anyhow::anyhow!("Unable to write to {}, perhaps try sudo. ({})", config_path.display(), e) + })?; + } + } + + match std::fs::OpenOptions::new().write(true).create_new(true).open(&config_path) { + Ok(_) => { + let _ = std::fs::remove_file(&config_path); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + println!("Configuration file already exists at {}. Exiting gracefully.", config_path.display()); + return Ok(()); + } + Err(e) => { + return Err(anyhow::anyhow!("Unable to write to {}, perhaps try sudo. ({})", config_path.display(), e)); } } From e84eae7ea3d28920e072bcfa7ae78662875db668 Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Tue, 31 Mar 2026 11:24:38 +0100 Subject: [PATCH 10/13] In a snap, set the initial config backup database location to be the current data directory, which is preserved on upgrade --- src/init/src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/init/src/main.rs b/src/init/src/main.rs index d05cb8329c..7e19e6a34e 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -188,6 +188,13 @@ fn main() -> Result<()> { ); } + if let Ok(snap_data) = env::var("SNAP_DATA") { + config_text = config_text.replace( + "#database_backup_path =\n", + &format!("database_backup_path = \"{snap_data}/db.bak\"\n") + ); + } + fs::write(&config_path, config_text).context("Failed to write tuwunel config file")?; println!("\nConfiguration file successfully written to {}", config_path.display()); From 0891bee69037701597506d347385ed6b2a8cc3dd Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Tue, 31 Mar 2026 12:09:38 +0100 Subject: [PATCH 11/13] Add snapcraft.yaml metadata for completeness --- snap/snapcraft.yaml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 53814e3556..affbc85afa 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,12 +1,21 @@ name: tuwunel +title: Tuwunel Matrix Server base: core24 # Using core24 as the base for a modern Rust toolchain (1.94.0+) version: 'git' -summary: High Performance Matrix Homeserver in Rust! +summary: High performance Matrix homeserver in Rust description: | - Tuwunel is a featureful Matrix homeserver you can use instead of Synapse - with your favorite client, bridge or bot. It is written entirely in Rust to be - a scalable, low-cost, enterprise-ready, community-driven alternative, fully - implementing the Matrix Specification. + Tuwunel is a featureful Matrix homeserver you can use with your favorite + client, bridge or bot. It is written entirely in Rust to be scalable, + low-cost, enterprise-ready, and community-driven. + + Install the snap and run `sudo tuwunel.init`, then direct traffic to the + server which is usually listening on 127.0.0.1:8008 +website: https://tuwunel.chat/ +source-code: https://github.com/matrix-construct/tuwunel +issues: https://github.com/matrix-construct/tuwunel/issues +contact: https://github.com/matrix-construct/tuwunel/discussions +license: Apache-2.0 +donation: https://github.com/matrix-construct/tuwunel/discussions grade: stable confinement: strict From 638f3c03812e99e30edcbcbfa9df2207f9c389e0 Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Tue, 31 Mar 2026 12:10:50 +0100 Subject: [PATCH 12/13] Set the initial snap database backup path to the 'current' data directory rather than the first revision-specific one --- src/init/src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/init/src/main.rs b/src/init/src/main.rs index 7e19e6a34e..942917ab16 100644 --- a/src/init/src/main.rs +++ b/src/init/src/main.rs @@ -189,9 +189,14 @@ fn main() -> Result<()> { } if let Ok(snap_data) = env::var("SNAP_DATA") { + let backup_path = std::path::Path::new(&snap_data) + .parent() + .map(|p| p.join("current").join("db.bak").display().to_string()) + .unwrap_or_else(|| format!("{snap_data}/db.bak")); + config_text = config_text.replace( "#database_backup_path =\n", - &format!("database_backup_path = \"{snap_data}/db.bak\"\n") + &format!("database_backup_path = \"{backup_path}\"\n") ); } From 196bf71196097d766d26902b37f46836b3520c53 Mon Sep 17 00:00:00 2001 From: Mark Shuttleworth Date: Thu, 2 Apr 2026 09:02:14 +0100 Subject: [PATCH 13/13] Remove unused library from snap to clean lint --- snap/snapcraft.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index affbc85afa..2a0a9f7f95 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -74,5 +74,7 @@ parts: - make stage-packages: - liburing2 + prime: + - "-usr/lib/*/liburing-ffi.so*" # The rust plugin automatically runs `cargo build --release` # and installs the resulting binaries into the snap.