From 1e0b9ac1667155137092fb0fe0e6b8ac9a365a57 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sun, 20 Sep 2026 13:47:46 +0200 Subject: [PATCH 1/4] fix: Properly load/save DB and stop creating dummy users --- src/db/filesystem.rs | 41 +++++++++++++++++++++++++++-------------- src/db/interface.rs | 1 - src/db/memory.rs | 1 + src/main.rs | 37 +++++++++---------------------------- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/src/db/filesystem.rs b/src/db/filesystem.rs index 9074822..4c805ec 100644 --- a/src/db/filesystem.rs +++ b/src/db/filesystem.rs @@ -1,5 +1,4 @@ use camino::{Utf8Path, Utf8PathBuf}; -use serde::{Deserialize, Serialize}; use serde_json::Error as JsonError; use std::fmt; @@ -35,7 +34,7 @@ impl fmt::Display for FilesystemDatabaseError { impl std::error::Error for FilesystemDatabaseError {} -#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[derive(Clone, Debug, Default)] pub struct FilesystemDatabase { // TODO: Once we have common validation steps in place across DB backends, we can reduce cloning. // For now, we clone the DB on every write operation, and update it when saving to disk @@ -59,10 +58,14 @@ impl FilesystemDatabase { path: path.to_path_buf(), }; - db.save().await?; + tracing::info!("Initializing empty database in file {path}. Checking permissions..."); + db.save_self().await?; + tracing::info!("Database successfully created"); + return Ok(Database::new(db)); } + tracing::info!("Loading database from file {path}"); let s = tokio::fs::read(path) .await .map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?; @@ -74,13 +77,29 @@ impl FilesystemDatabase { })) } - pub async fn save(&mut self) -> Result<(), BoxedError> { - let s = serde_json::to_string(&self) + /// Attempts to save a new state of the DB, effectively switching to the new state + /// only if the save is successful. + pub async fn save(&mut self, new_db: MemoryDatabase) -> Result<(), BoxedError> { + self.save_inner(&new_db).await?; + self.inner = new_db; + Ok(()) + } + + /// Saves an in-memory DB to disk. Cannot be used directly to observe exclusive access. + async fn save_inner(&self, inner: &MemoryDatabase) -> Result<(), BoxedError> { + let s = serde_json::to_string(inner) .map_err(|e| Box::new(FilesystemDatabaseError::WriteFileJson(self.path.clone(), e)))?; Ok(tokio::fs::write(&self.path, &s) .await .map_err(|e| Box::new(FilesystemDatabaseError::WriteFileIO(self.path.clone(), e)))?) } + + /// Save the database without switching to a new state. + /// + /// Used when initializing the DB, to check for permissions. + pub async fn save_self(&mut self) -> Result<(), BoxedError> { + self.save_inner(&self.inner).await + } } #[async_trait::async_trait] @@ -95,9 +114,7 @@ impl DatabaseInterface for FilesystemDatabase { return Ok(Err(e)); } - self.save().await?; - self.inner = new_db; - + self.save(new_db).await?; Ok(Ok(())) } @@ -119,9 +136,7 @@ impl DatabaseInterface for FilesystemDatabase { return Ok(Err(e)); } - self.save().await?; - self.inner = new_db; - + self.save(new_db).await?; Ok(Ok(())) } @@ -136,9 +151,7 @@ impl DatabaseInterface for FilesystemDatabase { return Ok(Err(e)); } - self.save().await?; - self.inner = new_db; - + self.save(new_db).await?; Ok(Ok(())) } diff --git a/src/db/interface.rs b/src/db/interface.rs index 6115f39..36d6266 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -70,7 +70,6 @@ pub trait DatabaseInterface: std::fmt::Debug + Send + Sync + 'static { ) -> Result, BoxedError>; async fn list_all_domains(&self) -> Result, BoxedError>; - #[expect(unused)] async fn list_all_users(&self) -> Result, BoxedError>; /// List users on a specific domain. diff --git a/src/db/memory.rs b/src/db/memory.rs index bae3c8e..76a53be 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -14,6 +14,7 @@ pub struct MemoryDatabase { impl MemoryDatabase { #[allow(clippy::new_ret_no_self)] pub fn new() -> Database { + tracing::warn!("Using in-memory database. Data will not be saved across restarts!"); Database::new(Self::default()) } } diff --git a/src/main.rs b/src/main.rs index ac42c60..69487e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,36 +18,16 @@ use ldap::ldap_handler; use listener::ListenerPath; async fn create_dummy_users(db: &mut Database) { - db.create_user(User { - username: "admin".to_string(), - domain: None, - password: "adminadmin".to_string(), - // TODO: what should we put here? - mail: "admin".to_string(), - role: Role::Admin, - }) - .await - .unwrap() - .unwrap(); - for letter in &["a", "b", "c"] { - let domain = format!("{letter}.localhost"); - db.create_domain(&domain).await.unwrap().unwrap(); + // TODO: customize admin password + if db.list_all_users().await.unwrap().is_empty() { + tracing::info!("Creating admin account with default `adminadmin` password"); db.create_user(User { - username: letter.to_string(), - domain: Some(domain.clone()), + username: "admin".to_string(), + domain: None, password: "adminadmin".to_string(), - mail: format!("{letter}@{domain}"), - role: Role::DomainAdmin(domain.clone()), - }) - .await - .unwrap() - .unwrap(); - db.create_user(User { - username: format!("user{letter}"), - domain: Some(domain.clone()), - password: "adminadmin".to_string(), - mail: format!("user{letter}@{domain}"), - role: Role::User, + // TODO: what should we put here? + mail: "admin".to_string(), + role: Role::Admin, }) .await .unwrap() @@ -74,6 +54,7 @@ async fn main() -> Result<(), GlobalError> { } else { MemoryDatabase::new() }; + tracing::info!("Database loaded successfully"); create_dummy_users(&mut db).await; #[cfg(feature = "http")] From c28b25d8d9f45091db8ded6ff1c4e3247eabcff9 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sun, 20 Sep 2026 13:52:30 +0200 Subject: [PATCH 2/4] logs: Reduce debug log spam (don't replicate the whole message every line) --- src/ldap/handler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ldap/handler.rs b/src/ldap/handler.rs index 084cfa5..5b7336c 100644 --- a/src/ldap/handler.rs +++ b/src/ldap/handler.rs @@ -44,7 +44,7 @@ pub async fn ldap_handler(mut stream: LdapStream, mut db: Database) { } /// Return true to keep the connection going, false to close it. -#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream))] +#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream, msg))] pub async fn ldap_handler_inner( stream: &mut LdapStream, msg: LdapMsg, From 63e55f814fae128b47ab5f7d85a366fbd575c514 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sun, 20 Sep 2026 18:13:59 +0200 Subject: [PATCH 3/4] feat: Redirect to requested URL after login --- Cargo.lock | 9 ++++- Cargo.toml | 1 + src/http/login.rs | 19 +++++---- src/http/logout.rs | 9 ++++- src/http/mod.rs | 2 + src/http/redirect.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++ src/http/session.rs | 23 ++++++++--- templates/login.html | 2 +- 8 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 src/http/redirect.rs diff --git a/Cargo.lock b/Cargo.lock index bd10531..efe035e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.1" @@ -512,7 +518,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "ldap3_lber", "nom", @@ -537,6 +543,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "base64 0.23.1", "camino", "dn_escape", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 07c7928..827b2dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ argh = "0.1.19" async-trait = "0.1.92" axum = { version = "0.8.9", optional = true, features = ["macros"] } axum-extra = { version = "0.12.6", features = ["cookie"], optional = true } +base64 = "0.23.1" camino = { version = "1.2.5", features = ["serde1"] } dn_escape = { path = "vendor/dn_escape" } futures-util = { version = "0.3.34", features = ["sink"] } diff --git a/src/http/login.rs b/src/http/login.rs index 578b5ff..7a65998 100644 --- a/src/http/login.rs +++ b/src/http/login.rs @@ -1,12 +1,12 @@ use axum::extract::{Form, State}; -use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::response::{Html, IntoResponse, Response}; use axum_extra::extract::cookie::CookieJar; use http::StatusCode; use minijinja::context; use serde::{Deserialize, Serialize}; use crate::db::{DatabaseInterface, UserRef}; -use crate::http::{HttpState, OptionalHttpSession}; +use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; #[derive(Debug, Deserialize)] pub struct LoginForm { @@ -23,12 +23,13 @@ pub enum LoginError { pub async fn login_page( State(state): State, login_error: Option, + redirect: InternalRedirect, ) -> Response { let page = state .templates .get_template("login.html") .unwrap() - .render(context! {login_error => login_error}) + .render(context! {login_error => login_error, redirect => format!("/login/?redirect={}", redirect.to_base64url())}) .unwrap(); (StatusCode::OK, Html(page)).into_response() } @@ -36,23 +37,25 @@ pub async fn login_page( pub async fn get_login( State(state): State, maybe_session: Option, + redirect: InternalRedirect, ) -> Response { if maybe_session.is_some() { - return Redirect::to("/").into_response(); + return redirect.to_redirect().into_response(); } - login_page(State(state), None).await + login_page(State(state), None, redirect).await } pub async fn post_login( State(state): State, session: Option, cookies: CookieJar, + redirect: InternalRedirect, Form(form): Form, ) -> Response { if session.is_some() { // Already logged in - return (cookies, Redirect::to("/")).into_response(); + return (cookies, redirect.to_redirect()).into_response(); } let req_user = match UserRef::from_user_maybe_domain(&form.username) { @@ -83,9 +86,9 @@ pub async fn post_login( }; let cookies = state.sessions.add_session(user, cookies); - (cookies, Redirect::to("/")).into_response() + (cookies, redirect.to_redirect()).into_response() } else { - login_page(State(state), Some(LoginError::InvalidCredentials)) + login_page(State(state), Some(LoginError::InvalidCredentials), redirect) .await .into_response() } diff --git a/src/http/logout.rs b/src/http/logout.rs index 3dc5151..844633d 100644 --- a/src/http/logout.rs +++ b/src/http/logout.rs @@ -3,7 +3,7 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use crate::http::login::{LoginError, login_page}; -use crate::http::{HttpState, OptionalHttpSession}; +use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; pub async fn logout( State(state): State, @@ -17,7 +17,12 @@ pub async fn logout( let cookies = state.sessions.remove_session(&session, cookies); ( cookies, - login_page(State(state), Some(LoginError::SessionInvalidated)).await, + login_page( + State(state), + Some(LoginError::SessionInvalidated), + InternalRedirect::new(), + ) + .await, ) .into_response() } diff --git a/src/http/mod.rs b/src/http/mod.rs index daf1507..e5b8ae1 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -17,6 +17,8 @@ mod domain; mod home; mod login; mod logout; +mod redirect; +use redirect::InternalRedirect; mod session; use session::{HttpSession, HttpSessionManager, OptionalHttpSession}; mod user; diff --git a/src/http/redirect.rs b/src/http/redirect.rs new file mode 100644 index 0000000..120f9c8 --- /dev/null +++ b/src/http/redirect.rs @@ -0,0 +1,96 @@ +use axum::extract::{FromRequestParts, Query}; +use axum::response::Redirect; +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +use http::request::Parts; +use serde::Deserialize; + +use crate::http::HttpState; + +/// Internal redirect to a different page. +/// +/// Is usually constructed from a base64url-encoded `redirect` +/// query string, for example in the login page. +pub struct InternalRedirect(Option); + +impl InternalRedirect { + /// Creates a new redirection from a base64url-encoded string. + /// + /// An invalid redirect is silently discarded and treated as no redirect. + pub fn from_base64url(s: &str) -> Self { + let s = match URL_SAFE.decode(s) { + Ok(s) => s, + Err(e) => { + tracing::debug!("Malformed login redirect URL, treating as empty: {e}"); + return Self::new(); + } + }; + + let s = match String::from_utf8(s) { + Ok(s) => s, + Err(e) => { + tracing::debug!("Malformed login redirect URL bytes, treating as empty: {e}"); + return Self::new(); + } + }; + + Self::from_string(s) + } + + /// Turns into a base64url string that can be added to a URL. + pub fn to_base64url(&self) -> String { + let s = self + .0 + .as_ref() + .expect("Cannot call InternalRedirect::to_base64url on an empty redirect"); + URL_SAFE.encode(s) + } + + /// Creates a new redirection from a raw string. + /// + /// If the string is empty or seems to redirect outside of our website (absolute URL), + /// the redirect is silently discarded and treated as no redirect. + pub fn from_string(s: String) -> Self { + if s.trim().is_empty() { + return Self(None); + } + + if s.starts_with("http://") || s.starts_with("https://") { + tracing::debug!("Invalid login redirect URL, treating as empty: {s}"); + return Self(None); + } + + Self(Some(s)) + } + + pub fn new() -> Self { + Self(None) + } + + pub fn to_redirect(&self) -> Redirect { + if let Some(url) = &self.0 { + Redirect::to(url) + } else { + Redirect::to("/") + } + } +} + +impl FromRequestParts for InternalRedirect { + type Rejection = !; + + async fn from_request_parts( + parts: &mut Parts, + state: &HttpState, + ) -> Result { + let Ok(f) = Query::::from_request_parts(parts, state).await else { + return Ok(Self::new()); + }; + + Ok(Self::from_base64url(&f.redirect)) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RedirectForm { + redirect: String, +} diff --git a/src/http/session.rs b/src/http/session.rs index 1f88874..fd73975 100644 --- a/src/http/session.rs +++ b/src/http/session.rs @@ -2,12 +2,13 @@ use axum::extract::{FromRequestParts, OptionalFromRequestParts}; use axum::response::Redirect; use axum_extra::extract::cookie::{Cookie, CookieJar}; use http::request::Parts; +use http::uri::Uri; use uuid::Uuid; use std::sync::{Arc, RwLock}; use crate::db::User; -use crate::http::HttpState; +use crate::http::{HttpState, InternalRedirect}; pub const COOKIE_NAME: &str = "lldap_session"; @@ -91,10 +92,22 @@ impl FromRequestParts for HttpSession { state: &HttpState, ) -> Result { let cookies = CookieJar::from_request_parts(parts, state).await.unwrap(); - state - .sessions - .get_session(&cookies) - .ok_or(Redirect::to("/login")) + if let Some(session) = state.sessions.get_session(&cookies) { + return Ok(session); + } + + // Extract the requested URL, turn it into base64, to let the login page + // know where to redirect us. + // But first, remove the scheme/host/port from URL. + let s = Uri::builder() + .path_and_query(parts.uri.path_and_query().unwrap().clone()) + .build() + .unwrap(); + let r = InternalRedirect::from_string(s.to_string()); + Err(Redirect::to(&format!( + "/login?redirect={}", + r.to_base64url() + ))) } } diff --git a/templates/login.html b/templates/login.html index 11ae59a..82ef600 100644 --- a/templates/login.html +++ b/templates/login.html @@ -11,7 +11,7 @@

Logout successful

{% endif %} -
+
From 89995a0386e3a2a94feb373bc16349274382d660 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sun, 20 Sep 2026 18:22:46 +0200 Subject: [PATCH 4/4] fix: Include (empty) group memberof info in user search results --- src/ldap/op/search.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ldap/op/search.rs b/src/ldap/op/search.rs index d881508..1ef39f4 100644 --- a/src/ldap/op/search.rs +++ b/src/ldap/op/search.rs @@ -151,9 +151,11 @@ pub async fn search_success( fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry { let mut res: Vec = vec![]; for attr in req_attrs { - if let Some(attr_value) = match attr.as_str() { - "uid" => Some(user.username.clone()), - "cn" | "mail" => Some(user.mail.clone()), + if let Some(attr_values) = match attr.as_str() { + "uid" => Some(vec![user.username.clone()]), + "cn" | "mail" => Some(vec![user.mail.clone()]), + // TODO: group membership + "memberof" => Some(vec![]), _ => { tracing::warn!("Ignoring unknown attr in search query: {attr}"); None @@ -161,9 +163,8 @@ fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResult } { res.push(LdapPartialAttribute { atype: attr.clone(), - // TODO: there may be multiple values here in the future, - // eg. mailaliases - vals: vec![Vec::from(attr_value)], + // LDAP response expects raw byte vec for each value + vals: attr_values.into_iter().map(Vec::from).collect(), }); } }