Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
543d5b11db Add explicit lifetime annotations to fix openSUSE warnings
Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com>
2025-11-16 10:59:20 +00:00
copilot-swe-agent[bot]
ac0e0780e9 Initial plan 2025-11-16 10:30:36 +00:00
34 changed files with 991 additions and 2286 deletions

View File

@@ -87,14 +87,14 @@ jobs:
image: lldap/rust-dev:latest image: lldap/rust-dev:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
- name: Install Rust - name: Install Rust
id: toolchain id: toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: "${{ env.MSRV }}" toolchain: "${{ env.MSRV }}"
targets: "wasm32-unknown-unknown" targets: "wasm32-unknown-unknown"
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: | path: |
/usr/local/cargo/bin /usr/local/cargo/bin
@@ -114,7 +114,7 @@ jobs:
- name: Check build path - name: Check build path
run: ls -al app/ run: ls -al app/
- name: Upload ui artifacts - name: Upload ui artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: ui name: ui
path: app/ path: app/
@@ -136,14 +136,14 @@ jobs:
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
- name: Install Rust - name: Install Rust
id: toolchain id: toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: "${{ env.MSRV }}" toolchain: "${{ env.MSRV }}"
targets: "${{ matrix.target }}" targets: "${{ matrix.target }}"
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: | path: |
.cargo/bin .cargo/bin
@@ -159,17 +159,17 @@ jobs:
- name: Check path - name: Check path
run: ls -al target/release run: ls -al target/release
- name: Upload ${{ matrix.target}} lldap artifacts - name: Upload ${{ matrix.target}} lldap artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.target}}-lldap-bin name: ${{ matrix.target}}-lldap-bin
path: target/${{ matrix.target }}/release/lldap path: target/${{ matrix.target }}/release/lldap
- name: Upload ${{ matrix.target }} migration tool artifacts - name: Upload ${{ matrix.target }} migration tool artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.target }}-lldap_migration_tool-bin name: ${{ matrix.target }}-lldap_migration_tool-bin
path: target/${{ matrix.target }}/release/lldap_migration_tool path: target/${{ matrix.target }}/release/lldap_migration_tool
- name: Upload ${{ matrix.target }} password tool artifacts - name: Upload ${{ matrix.target }} password tool artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.target }}-lldap_set_password-bin name: ${{ matrix.target }}-lldap_set_password-bin
path: target/${{ matrix.target }}/release/lldap_set_password path: target/${{ matrix.target }}/release/lldap_set_password
@@ -209,7 +209,7 @@ jobs:
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
name: x86_64-unknown-linux-musl-lldap-bin name: x86_64-unknown-linux-musl-lldap-bin
path: bin/ path: bin/
@@ -310,18 +310,18 @@ jobs:
steps: steps:
- name: Checkout scripts - name: Checkout scripts
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
with: with:
sparse-checkout: 'scripts' sparse-checkout: 'scripts'
- name: Download LLDAP artifacts - name: Download LLDAP artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
name: x86_64-unknown-linux-musl-lldap-bin name: x86_64-unknown-linux-musl-lldap-bin
path: bin/ path: bin/
- name: Download LLDAP set password - name: Download LLDAP set password
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
name: x86_64-unknown-linux-musl-lldap_set_password-bin name: x86_64-unknown-linux-musl-lldap_set_password-bin
path: bin/ path: bin/
@@ -506,21 +506,21 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
path: bin path: bin
- name: Download llap ui artifacts - name: Download llap ui artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
name: ui name: ui
path: web path: web
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Setup buildx - name: Setup buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
@@ -691,7 +691,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
path: bin/ path: bin/
- name: Check file - name: Check file
@@ -712,7 +712,7 @@ jobs:
chmod +x bin/*-lldap_set_password chmod +x bin/*-lldap_set_password
- name: Download llap ui artifacts - name: Download llap ui artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
name: ui name: ui
path: web path: web

View File

@@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
- name: Install Rust - name: Install Rust
id: toolchain id: toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
- name: Install Rust - name: Install Rust
id: toolchain id: toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -75,7 +75,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
- name: Install Rust - name: Install Rust
id: toolchain id: toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -94,7 +94,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v6.0.2 uses: actions/checkout@v5.0.0
- name: Install Rust - name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
@@ -108,7 +108,7 @@ jobs:
- name: Aggregate reports - name: Aggregate reports
run: cargo llvm-cov --no-run --lcov --output-path lcov.info run: cargo llvm-cov --no-run --lcov --output-path lcov.info
- name: Upload coverage to Codecov (main) - name: Upload coverage to Codecov (main)
uses: codecov/codecov-action@v6 uses: codecov/codecov-action@v4
with: with:
files: lcov.info files: lcov.info
fail_ci_if_error: true fail_ci_if_error: true

2384
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,9 @@ lto = true
[profile.release.package.lldap_app] [profile.release.package.lldap_app]
opt-level = 's' opt-level = 's'
[patch.crates-io.lber]
git = 'https://github.com/inejge/ldap3/'
[workspace.dependencies.sea-orm] [workspace.dependencies.sea-orm]
version = "1.1.8" version = "1.1.8"
default-features = false default-features = false

View File

@@ -83,7 +83,7 @@ MySQL/MariaDB or PostgreSQL.
## Installation ## Installation
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), [TrueNAS](docs/install.md#truenas-scale), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD). It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported. Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.

View File

@@ -304,14 +304,11 @@ impl Component for CreateUserForm {
} }
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html { fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
let mail_is_required = attribute_schema.name.as_str() == "mail";
if attribute_schema.is_list { if attribute_schema.is_list {
html! { html! {
<ListAttributeInput <ListAttributeInput
name={attribute_schema.name.clone()} name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type} attribute_type={attribute_schema.attribute_type}
required={mail_is_required}
/> />
} }
} else { } else {
@@ -319,7 +316,6 @@ fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
<SingleAttributeInput <SingleAttributeInput
name={attribute_schema.name.clone()} name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type} attribute_type={attribute_schema.attribute_type}
required={mail_is_required}
/> />
} }
} }

View File

@@ -45,8 +45,6 @@ fn attribute_input(props: &AttributeInputProps) -> Html {
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
struct AttributeLabelProps { struct AttributeLabelProps {
pub name: String, pub name: String,
#[prop_or(false)]
pub required: bool,
} }
#[function_component(AttributeLabel)] #[function_component(AttributeLabel)]
fn attribute_label(props: &AttributeLabelProps) -> Html { fn attribute_label(props: &AttributeLabelProps) -> Html {
@@ -68,9 +66,7 @@ fn attribute_label(props: &AttributeLabelProps) -> Html {
<label for={props.name.clone()} <label for={props.name.clone()}
class="form-label col-4 col-form-label" class="form-label col-4 col-form-label"
> >
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")} {props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
{if props.required { html!{<span class="text-danger">{"*"}</span>} } else { html!{} }}
{":"}
<button <button
class="btn btn-sm btn-link" class="btn btn-sm btn-link"
type="button" type="button"
@@ -89,15 +85,13 @@ pub struct SingleAttributeInputProps {
pub(crate) attribute_type: AttributeType, pub(crate) attribute_type: AttributeType,
#[prop_or(None)] #[prop_or(None)]
pub value: Option<String>, pub value: Option<String>,
#[prop_or(false)]
pub required: bool,
} }
#[function_component(SingleAttributeInput)] #[function_component(SingleAttributeInput)]
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html { pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
html! { html! {
<div class="row mb-3"> <div class="row mb-3">
<AttributeLabel name={props.name.clone()} required={props.required} /> <AttributeLabel name={props.name.clone()} />
<div class="col-8"> <div class="col-8">
<AttributeInput <AttributeInput
attribute_type={props.attribute_type} attribute_type={props.attribute_type}
@@ -114,8 +108,6 @@ pub struct ListAttributeInputProps {
pub(crate) attribute_type: AttributeType, pub(crate) attribute_type: AttributeType,
#[prop_or(vec!())] #[prop_or(vec!())]
pub values: Vec<String>, pub values: Vec<String>,
#[prop_or(false)]
pub required: bool,
} }
pub enum ListAttributeInputMsg { pub enum ListAttributeInputMsg {
@@ -168,7 +160,7 @@ impl Component for ListAttributeInput {
let link = &ctx.link(); let link = &ctx.link();
html! { html! {
<div class="row mb-3"> <div class="row mb-3">
<AttributeLabel name={props.name.clone()} required={props.required} /> <AttributeLabel name={props.name.clone()} />
<div class="col-8"> <div class="col-8">
{self.indices.iter().map(|&i| html! { {self.indices.iter().map(|&i| html! {
<div class="input-group mb-2" key={i}> <div class="input-group mb-2" key={i}>

View File

@@ -27,7 +27,7 @@ pub struct LoginForm {
pub struct FormModel { pub struct FormModel {
#[validate(length(min = 1, message = "Missing username"))] #[validate(length(min = 1, message = "Missing username"))]
username: String, username: String,
#[validate(length(min = 1, message = "Missing password"))] #[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
password: String, password: String,
} }

View File

@@ -8,17 +8,12 @@ pub mod group {
use super::AttributeDescription; use super::AttributeDescription;
pub fn resolve_group_attribute_description(name: &'_ str) -> Option<AttributeDescription<'_>> { pub fn resolve_group_attribute_description(name: &str) -> Option<AttributeDescription<'_>> {
match name { match name {
"creation_date" => Some(AttributeDescription { "creation_date" => Some(AttributeDescription {
attribute_identifier: name, attribute_identifier: name,
attribute_name: "creationdate", attribute_name: "creationdate",
aliases: vec![name, "createtimestamp"], aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "modifydate",
aliases: vec![name, "modifytimestamp"],
}), }),
"display_name" => Some(AttributeDescription { "display_name" => Some(AttributeDescription {
attribute_identifier: name, attribute_identifier: name,
@@ -39,9 +34,7 @@ pub mod group {
} }
} }
pub fn resolve_group_attribute_description_or_default( pub fn resolve_group_attribute_description_or_default(name: &str) -> AttributeDescription<'_> {
name: &'_ str,
) -> AttributeDescription<'_> {
match resolve_group_attribute_description(name) { match resolve_group_attribute_description(name) {
Some(d) => d, Some(d) => d,
None => AttributeDescription { None => AttributeDescription {
@@ -57,7 +50,7 @@ pub mod user {
use super::AttributeDescription; use super::AttributeDescription;
pub fn resolve_user_attribute_description(name: &'_ str) -> Option<AttributeDescription<'_>> { pub fn resolve_user_attribute_description(name: &str) -> Option<AttributeDescription<'_>> {
match name { match name {
"avatar" => Some(AttributeDescription { "avatar" => Some(AttributeDescription {
attribute_identifier: name, attribute_identifier: name,
@@ -67,17 +60,7 @@ pub mod user {
"creation_date" => Some(AttributeDescription { "creation_date" => Some(AttributeDescription {
attribute_identifier: name, attribute_identifier: name,
attribute_name: "creationdate", attribute_name: "creationdate",
aliases: vec![name, "createtimestamp"], aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "modifydate",
aliases: vec![name, "modifytimestamp"],
}),
"password_modified_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "passwordmodifydate",
aliases: vec![name, "pwdchangedtime"],
}), }),
"display_name" => Some(AttributeDescription { "display_name" => Some(AttributeDescription {
attribute_identifier: name, attribute_identifier: name,
@@ -113,9 +96,7 @@ pub mod user {
} }
} }
pub fn resolve_user_attribute_description_or_default( pub fn resolve_user_attribute_description_or_default(name: &str) -> AttributeDescription<'_> {
name: &'_ str,
) -> AttributeDescription<'_> {
match resolve_user_attribute_description(name) { match resolve_user_attribute_description(name) {
Some(d) => d, Some(d) => d,
None => AttributeDescription { None => AttributeDescription {

View File

@@ -7,8 +7,8 @@ use sea_orm::{
DbErr, DeriveValueType, QueryResult, TryFromU64, TryGetError, TryGetable, Value, DbErr, DeriveValueType, QueryResult, TryFromU64, TryGetError, TryGetable, Value,
entity::IntoActiveValue, entity::IntoActiveValue,
sea_query::{ sea_query::{
ArrayType, ColumnType, SeaRc, StringLen, ValueTypeErr, extension::mysql::MySqlType, ArrayType, ColumnType, Nullable, SeaRc, StringLen, ValueTypeErr,
value::ValueType, extension::mysql::MySqlType, value::ValueType,
}, },
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -415,6 +415,12 @@ impl JpegPhoto {
} }
} }
impl Nullable for JpegPhoto {
fn null() -> Value {
JpegPhoto::null().into()
}
}
impl IntoActiveValue<Serialized> for JpegPhoto { impl IntoActiveValue<Serialized> for JpegPhoto {
fn into_active_value(self) -> sea_orm::ActiveValue<Serialized> { fn into_active_value(self) -> sea_orm::ActiveValue<Serialized> {
if self.is_empty() { if self.is_empty() {

View File

@@ -261,21 +261,6 @@ fn convert_user_filter(
UserColumn::LowercaseEmail, UserColumn::LowercaseEmail,
value_lc, value_lc,
)), )),
UserFieldType::PrimaryField(UserColumn::DisplayName) => {
// DisplayName (cn) should match case-insensitively, so we try both
// the original value and the lowercase value (if different)
if value.as_str() == value_lc {
Ok(UserRequestFilter::Equality(
UserColumn::DisplayName,
value_lc,
))
} else {
Ok(UserRequestFilter::Or(vec![
UserRequestFilter::Equality(UserColumn::DisplayName, value.to_string()),
UserRequestFilter::Equality(UserColumn::DisplayName, value_lc),
]))
}
}
UserFieldType::PrimaryField(field) => { UserFieldType::PrimaryField(field) => {
Ok(UserRequestFilter::Equality(field, value_lc)) Ok(UserRequestFilter::Equality(field, value_lc))
} }
@@ -785,47 +770,4 @@ mod tests {
panic!("Expected SearchResultEntry"); panic!("Expected SearchResultEntry");
} }
} }
#[tokio::test]
async fn test_search_cn_case_insensitive() {
use lldap_domain::uuid;
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users()
.with(
eq(Some(UserRequestFilter::Or(vec![
UserRequestFilter::Equality(UserColumn::DisplayName, "TestAll".to_string()),
UserRequestFilter::Equality(UserColumn::DisplayName, "testall".to_string()),
]))),
eq(false),
)
.times(1)
.return_once(|_, _| {
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("testall"),
email: "test@example.com".into(),
display_name: Some("TestAll".to_string()),
uuid: uuid!("698e1d5f-7a40-3151-8745-b9b8a37839da"),
attributes: vec![],
..Default::default()
},
groups: None,
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_user_search_request(
LdapFilter::Equality("cn".to_string(), "TestAll".to_string()),
vec!["cn", "uid"],
);
let results = ldap_handler.do_search_or_dse(&request).await.unwrap();
assert_eq!(results.len(), 2);
if let LdapOp::SearchResultEntry(entry) = &results[0] {
assert_eq!(entry.dn, "uid=testall,ou=people,dc=example,dc=com");
assert_eq!(entry.attributes.len(), 2);
assert_eq!(entry.attributes[0].atype, "cn");
assert_eq!(entry.attributes[0].vals[0], b"TestAll");
} else {
panic!("Expected SearchResultEntry");
}
}
} }

View File

@@ -421,14 +421,6 @@ pub async fn do_search(
InternalSearchResults::Raw(raw_results) => raw_results, InternalSearchResults::Raw(raw_results) => raw_results,
InternalSearchResults::Empty => Vec::new(), InternalSearchResults::Empty => Vec::new(),
}; };
// RFC 4511: When performing a base scope search, if the entry doesn't exist,
// we should return NoSuchObject instead of Success with zero entries
if results.is_empty() && request.scope == LdapSearchScope::Base {
return Err(LdapError {
code: LdapResultCode::NoSuchObject,
message: "".to_string(),
});
}
if !matches!(results.last(), Some(LdapOp::SearchResultDone(_))) { if !matches!(results.last(), Some(LdapOp::SearchResultDone(_))) {
results.push(make_search_success()); results.push(make_search_success());
} }
@@ -1455,76 +1447,4 @@ mod tests {
]), ]),
); );
} }
#[tokio::test]
async fn test_search_base_scope_non_existent_user() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().returning(|_, _| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapSearchRequest {
scope: LdapSearchScope::Base,
..make_search_request(
"uid=nonexistent,ou=people,dc=example,dc=com",
LdapFilter::And(vec![]),
vec!["objectClass".to_string()],
)
};
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Err(LdapError {
code: LdapResultCode::NoSuchObject,
message: "".to_string(),
})
);
}
#[tokio::test]
async fn test_search_base_scope_non_existent_group() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_groups().returning(|_| Ok(vec![]));
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapSearchRequest {
scope: LdapSearchScope::Base,
..make_search_request(
"uid=nonexistent,ou=groups,dc=example,dc=com",
LdapFilter::And(vec![]),
vec!["objectClass".to_string()],
)
};
assert_eq!(
ldap_handler.do_search_or_dse(&request).await,
Err(LdapError {
code: LdapResultCode::NoSuchObject,
message: "".to_string(),
})
);
}
#[tokio::test]
async fn test_search_base_scope_existing_user() {
let mut mock = MockTestBackendHandler::new();
mock.expect_list_users().returning(|_, _| {
Ok(vec![UserAndGroups {
user: User {
user_id: UserId::new("bob"),
..Default::default()
},
groups: None,
}])
});
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = LdapSearchRequest {
scope: LdapSearchScope::Base,
..make_search_request(
"uid=bob,ou=people,dc=example,dc=com",
LdapFilter::And(vec![]),
vec!["objectClass".to_string()],
)
};
let results = ldap_handler.do_search_or_dse(&request).await.unwrap();
// Should have 2 results: SearchResultEntry and SearchResultDone
assert_eq!(results.len(), 2);
assert!(matches!(results[0], LdapOp::SearchResultEntry(_)));
assert!(matches!(results[1], LdapOp::SearchResultDone(_)));
}
} }

View File

@@ -3,7 +3,6 @@
- [With Docker](#with-docker) - [With Docker](#with-docker)
- [With Podman](#with-podman) - [With Podman](#with-podman)
- [With Kubernetes](#with-kubernetes) - [With Kubernetes](#with-kubernetes)
- [TrueNAS SCALE](#truenas-scale)
- [From a package repository](#from-a-package-repository) - [From a package repository](#from-a-package-repository)
- [With FreeBSD](#with-freebsd) - [With FreeBSD](#with-freebsd)
- [From source](#from-source) - [From source](#from-source)
@@ -106,27 +105,6 @@ You can bootstrap your lldap instance (users, groups)
using [bootstrap.sh](../example_configs/bootstrap/bootstrap.md#kubernetes-job). using [bootstrap.sh](../example_configs/bootstrap/bootstrap.md#kubernetes-job).
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job. It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
### TrueNAS SCALE
LLDAP can be installed on **TrueNAS SCALE** using the built-in Apps catalog, allowing users to deploy and manage LLDAP directly from the TrueNAS web interface without manually maintaining containers.
To install:
1. Open the TrueNAS web interface.
2. Navigate to **Apps → Discover Apps**.
3. Search for **LLDAP** and click **Install**.
4. Provide the required configuration values such as:
- Base DN
- Admin credentials
- LDAP / LDAPS ports
- Persistent storage dataset
TrueNAS supports selecting certificates for LDAPS and configuring a public web URL. When LDAPS is enabled, it is recommended to disable the unencrypted LDAP port to ensure secure communication.
A full, step-by-step TrueNAS-specific guide (including recommended ports, certificate configuration, and common integrations) is available here:
👉 [example_configs/truenas-install.md](https://github.com/lldap/lldap/blob/main/example_configs/truenas-install.md)
### From a package repository ### From a package repository
**Do not open issues in this repository for problems with third-party **Do not open issues in this repository for problems with third-party

View File

@@ -4,7 +4,6 @@ Some specific clients have been tested to work and come with sample
configuration files: configuration files:
- [Airsonic Advanced](airsonic-advanced.md) - [Airsonic Advanced](airsonic-advanced.md)
- [Apache HTTP Server](apache.md)
- [Apache Guacamole](apacheguacamole.md) - [Apache Guacamole](apacheguacamole.md)
- [Apereo CAS Server](apereo_cas_server.md) - [Apereo CAS Server](apereo_cas_server.md)
- [Authelia](authelia.md) - [Authelia](authelia.md)
@@ -12,7 +11,6 @@ configuration files:
- [Bookstack](bookstack.env.example) - [Bookstack](bookstack.env.example)
- [Calibre-Web](calibre_web.md) - [Calibre-Web](calibre_web.md)
- [Carpal](carpal.md) - [Carpal](carpal.md)
- [Continuwuity](continuwuity.md)
- [Dell iDRAC](dell_idrac.md) - [Dell iDRAC](dell_idrac.md)
- [Dex](dex_config.yml) - [Dex](dex_config.yml)
- [Dokuwiki](dokuwiki.md) - [Dokuwiki](dokuwiki.md)
@@ -21,7 +19,6 @@ configuration files:
- [Ejabberd](ejabberd.md) - [Ejabberd](ejabberd.md)
- [Emby](emby.md) - [Emby](emby.md)
- [Ergo IRCd](ergo.md) - [Ergo IRCd](ergo.md)
- [Gerrit](gerrit.md)
- [Gitea](gitea.md) - [Gitea](gitea.md)
- [GitLab](gitlab.md) - [GitLab](gitlab.md)
- [Grafana](grafana_ldap_config.toml) - [Grafana](grafana_ldap_config.toml)
@@ -63,7 +60,6 @@ configuration files:
- [Radicale](radicale.md) - [Radicale](radicale.md)
- [Rancher](rancher.md) - [Rancher](rancher.md)
- [Seafile](seafile.md) - [Seafile](seafile.md)
- [Semaphore](semaphore.md)
- [Shaarli](shaarli.md) - [Shaarli](shaarli.md)
- [Snipe-IT](snipe-it.md) - [Snipe-IT](snipe-it.md)
- [SonarQube](sonarqube.md) - [SonarQube](sonarqube.md)

View File

@@ -1,65 +0,0 @@
# Configuration for Apache
This example snippet provides space under `/webdav/<username>/` if they log in as the user in question.
## Apache LDAP Configuration
```
# The User/Group specified in httpd.conf needs to have write permissions
# on the directory where the DavLockDB is placed and on any directory where
# "Dav On" is specified.
DavLockDB "/var/local/apache2/DavLock"
Alias /webdav "/var/local/apache2/data"
<Directory "/var/local/apache2/data">
AllowOverride None
Require all denied
DirectoryIndex disabled
</Directory>
<DirectoryMatch "^/var/local/apache2/data/(?<user>[^/]+)">
AuthType Basic
AuthName "LDAP Credentials"
AuthBasicProvider ldap
AuthLDAPURL ldap://lldap:3890/ou=people,dc=example,dc=com?uid?sub?(objectClass=person)
AuthLDAPBindDN uid=integration,ou=people,dc=example,dc=com
AuthLDAPBindPassword [redacted]
<RequireAll>
Require ldap-user "%{env:MATCH_USER}"
Require ldap-group cn=WebDAV,ou=groups,dc=example,dc=com
</RequireAll>
Dav On
Options +Indexes
</DirectoryMatch>
```
### Notes
* Make sure you create the `data` directory, and the subdirectories for your users.
* `integration` was an LDAP user I added with strict readonly.
* The `WebDAV` group was something I added and put relevant users into, more as a test of functionality than out of any need.
* I left the comment from the Apache DAV config in because it's not kidding around and it won't be obvious what's going wrong from the Apache logs if you miss that.
## Apache Orchestration
The stock Apache server with that stanza added to the bottom of the stock config and shared into the container.
```
webdav:
image: httpd:2.4.66-trixie
restart: always
volumes:
- /opt/webdav:/var/local/apache2
- ./httpd.conf:/usr/local/apache2/conf/httpd.conf
labels:
- "traefik.enable=true"
- "traefik.http.routers.webdav.entrypoints=websecure"
- "traefik.http.routers.webdav.rule=Host(`redacted`) && PathPrefix(`/webdav`)"
- "traefik.http.routers.webdav.tls.certresolver=myresolver"
- "traefik.http.routers.webdav.service=webdav-service"
- "traefik.http.services.webdav-service.loadbalancer.server.port=80"
```

View File

@@ -72,7 +72,6 @@ Fields description:
* `id`: it's just username (**MANDATORY**) * `id`: it's just username (**MANDATORY**)
* `email`: self-explanatory (**MANDATORY**) * `email`: self-explanatory (**MANDATORY**)
* `password`: would be used to set the password using `lldap_set_password` utility * `password`: would be used to set the password using `lldap_set_password` utility
* `password_file`: path to a file containing the password otherwise same as above
* `displayName`: self-explanatory * `displayName`: self-explanatory
* `firstName`: self-explanatory * `firstName`: self-explanatory
* `lastName`: self-explanatory * `lastName`: self-explanatory

View File

@@ -1,15 +0,0 @@
# Configuration for Continuwuity
This example is with environment vars from my docker-compose.yml, this also works just as well with a [config file](https://continuwuity.org/reference/config). `uid=query,ou=people,dc=example,dc=com` is a read-only user and you need to put their password into `/etc/bind_password_file`. Users need to be in the group `matrix` to log in and users in the group `matrix-admin` will be an admin.
```
CONTINUWUITY_LDAP__ENABLE: 'true'
CONTINUWUITY_LDAP__LDAP_ONLY: 'true'
CONTINUWUITY_LDAP__URI: 'ldap://lldap.example.com:3890'
CONTINUWUITY_LDAP__BASE_DN: 'ou=people,dc=example,dc=com'
CONTINUWUITY_LDAP__BIND_DN: 'uid=query,ou=people,dc=example,dc=com'
CONTINUWUITY_LDAP__BIND_PASSWORD_FILE: '/etc/bind_password_file'
CONTINUWUITY_LDAP__FILTER: '(memberOf=matrix)'
CONTINUWUITY_LDAP__UID_ATTRIBUTE: 'uid'
CONTINUWUITY_LDAP__ADMIN_FILTER: '(memberOf=matrix-admin)'
```

View File

@@ -1,18 +0,0 @@
# Configuration for Gerrit
Edit `gerrit.config`:
```ini
[auth]
type = ldap
[ldap]
server = ldap://lldap:3890
supportAnonymous = false
username = uid=gerritadmin,ou=people,dc=example.com,dc=com
accountBase = ou=people,dc=example.com,dc=com
accountPattern = (uid=${username})
accountFullName = cn
accountEmailAddress = mail
```
The `supportAnonymous = false` must be set.

View File

@@ -41,14 +41,7 @@ name = "displayName"
surname = "sn" surname = "sn"
username = "uid" username = "uid"
# If you want to map your ldap groups to grafana's groups, configure the group query: # If you want to map your ldap groups to grafana's groups, see: https://grafana.com/docs/grafana/latest/auth/ldap/#group-mappings
# https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/ldap/#posix-schema
# group_search_filter = "(&(objectClass=groupOfUniqueNames)(uniqueMember=%s))"
# group_search_base_dns = ["ou=groups,dc=example,dc=com"]
# group_search_filter_user_attribute = "uid"
#
# Then configure the groups:
# https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/ldap/#group-mappings
# As a quick example, here is how you would map lldap's admin group to grafana's admin # As a quick example, here is how you would map lldap's admin group to grafana's admin
# [[servers.group_mappings]] # [[servers.group_mappings]]
# group_dn = "cn=lldap_admin,ou=groups,dc=example,dc=org" # group_dn = "cn=lldap_admin,ou=groups,dc=example,dc=org"

View File

@@ -64,7 +64,7 @@ if [[ ! -z "$2" ]] && ! jq -e '.groups|map(.displayName)|index("'"$2"'")' <<< $U
exit 1 exit 1
fi fi
DISPLAY_NAME=$(jq -r '.displayName // .id' <<< $USER_JSON) DISPLAY_NAME=$(jq -r .displayName <<< $USER_JSON)
IS_ADMIN=false IS_ADMIN=false
if [[ ! -z "$3" ]] && jq -e '.groups|map(.displayName)|index("'"$3"'")' <<< "$USER_JSON" > /dev/null 2>&1; then if [[ ! -z "$3" ]] && jq -e '.groups|map(.displayName)|index("'"$3"'")' <<< "$USER_JSON" > /dev/null 2>&1; then

View File

@@ -1,37 +0,0 @@
# Configuration for Semaphore
Semaphore configuration is in `config.json`
Just add the following lines:
```json
"ldap_enable": true,
"ldap_needtls": true,
"ldap_server": "ldaps_server:6360",
"ldap_binddn": "uid=semaphorebind,ou=people,dc=example,dc=com",
"ldap_bindpassword": "verysecretpassword",
"ldap_searchdn": "ou=people,dc=example,dc=com",
"ldap_searchfilter": "(|(uid=%[1]s)(mail=%[1]s))",
"ldap_mappings": {
"dn": "dn",
"mail": "mail",
"uid": "uid",
"cn": "cn"
}
```
If you use environment variables:
```bash
Environment=SEMAPHORE_LDAP_ENABLE=true
Environment=SEMAPHORE_LDAP_SERVER="ldaps_server:6360"
Environment=SEMAPHORE_LDAP_NEEDTLS=true
Environment=SEMAPHORE_LDAP_BIND_DN="uid=semaphorebind,ou=people,dc=example,dc=com"
Environment=SEMAPHORE_LDAP_BIND_PASSWORD="verysecretpassword"
Environment=SEMAPHORE_LDAP_SEARCH_DN="ou=people,dc=example,dc=com"
Environment=SEMAPHORE_LDAP_SEARCH_FILTER="(|(uid=%[1]s)(mail=%[1]s))"
Environment=SEMAPHORE_LDAP_MAPPING_UID="uid"
Environment=SEMAPHORE_LDAP_MAPPING_CN="cn"
Environment=SEMAPHORE_LDAP_MAPPING_MAIL="mail"
Environment=SEMAPHORE_LDAP_MAPPING_DN="dn"
```
You can log in with username or email.

View File

@@ -48,13 +48,3 @@ To integrate with LLDAP,
allow-invalid-certs = true allow-invalid-certs = true
enable = false enable = false
``` ```
## Email alias
If you want to enable [email aliases](https://stalw.art/docs/mta/inbound/rcpt/#catch-all-addresses), you have to create a new *User-defined attribute* under *User schema* of type string. Currently, LLDAP doesn't support multi-value filters. If you want multiple aliases, you will have to create multiple attributes (`mailAlias1`, `mailAlias2`, ..., `mailAliasN`), where `N` is the maximum number of aliases an account will have.
You also need to change your ldap filter for emails.
```toml
[directory.ldap.filter]
# Add one clause per alias attribute you created (example: mailAlias1..mailAlias3)
email = "(&(objectclass=person)(|(mail=?)(mailAlias1=?)(mailAlias2=?)(mailAlias3=?)))"
```

View File

@@ -1,126 +0,0 @@
# Installing and Configuring LLDAP on TrueNAS
This guide walks through installing **LLDAP** from the TrueNAS Apps catalog and performing a basic configuration suitable for sharing authentication between multiple applications that support LDAP authentication.
It is intended to accompany the example configuration files in this repository and assumes a basic familiarity with the TrueNAS web interface.
## Prerequisites
- TrueNAS SCALE with Apps enabled
- Administrative access to the TrueNAS UI
- A system with working networking and DNS
- Optional but recommended: HTTPS certificates managed by TrueNAS
## Step 1: Install LLDAP from the TrueNAS Apps Catalog
1. Log in to the **TrueNAS web interface**.
2. Navigate to **Apps → Discover Apps**.
3. Search for **LLDAP**.
4. Click **Install**.
You will be presented with the LLDAP application configuration form.
## Step 2: Application Configuration
Below are the key configuration sections and recommended settings based on the official catalog definition.
### Application Name
- Leave the default name or choose a descriptive one (e.g. `lldap`).
### Networking
- **Web Port**: Default application port is typically **30325**. There is no standard port for the LLDAP web UI; this value is configurable in TrueNAS.
- **LDAP Port**:
- Standard LDAP port: **389**
- Default port configured by the TrueNAS app: **30326**
- **LDAPS Port**:
- Standard LDAPS port: **636**
- Default port configured by the TrueNAS app: **30327**
It is recommended to adjust these ports to suit your environment. Using standard ports (389/636) can simplify client configuration, but non-standard ports may be preferred to avoid conflicts on the host system. Ensure the selected ports are not already in use.
If LDAPS is enabled, it is strongly recommended to **disable the LDAP port** to ensure all directory traffic is encrypted.
### Authentication / Admin Account
- **LLDAP Admin Username**: Set an admin username (e.g. `admin`).
- **LLDAP Admin Password**: Set a strong password. This account is used to access the LLDAP web UI.
> ⚠️ Save this password securely. You will need it to log in and manage users and groups.
### Base DN Configuration
These values define your LDAP directory structure:
- **Base DN**: Example: `dc=example,dc=com`
- **User DN**: Typically `ou=people,dc=example,dc=com`
- **Group DN**: Typically `ou=groups,dc=example,dc=com`
These values must be consistent with the configuration used by client applications.
## Step 3: Storage Configuration
LLDAP requires persistent storage for its database.
- Configure an **application dataset** or **host path** for LLDAP data.
- Ensure the dataset is backed up as part of your normal TrueNAS backup strategy.
## Step 4: (Optional) Enable HTTPS Using TrueNAS Certificates
If your TrueNAS system manages certificates:
1. In the app configuration, select **Use Existing Certificate**.
2. Choose a certificate issued by TrueNAS.
3. Ensure the web port is accessed via `https://`.
This avoids storing certificate files inside the container and improves overall security.
## Step 5: Deploy the App
1. Review all configuration values.
2. Click **Install**.
3. Wait for the application status to show **Running**.
## Step 6: Access the LLDAP Web UI
- Navigate to: `http(s)://<truenas-ip>:<web-port>`
- Log in using the admin credentials you configured earlier.
From here you can:
- Create users
- Create groups
- Assign users to groups
## Step 7: Using LLDAP with Other Applications
LLDAP can be used as a central identity provider for many popular applications available in the TrueNAS Apps catalog. Common examples include:
- **Jellyfin** (media server)
- **Nextcloud** (collaboration and file sharing)
- **Gitea** (self-hosted Git service)
- **Grafana** (monitoring and dashboards)
- **MinIO** (object storage)
Configuration examples for several of these applications are also available in the upstream LLDAP repository under `example_configs`.
When configuring a client application:
- **LDAP Host**: TrueNAS IP address or the LLDAP app service name
- **LDAP / LDAPS Port**: As configured during install (prefer LDAPS if enabled)
- **Bind DN**: A dedicated service (bind) account or admin DN
- **Bind Password**: Password for the bind account
- **Base DN**: Must match the LLDAP Base DN
Once configured, users can authenticate to multiple applications using a single set of credentials managed centrally by LLDAP.
## Notes and Tips
- Prefer creating a **dedicated bind user** for applications instead of using the admin account.
- Keep Base DN values consistent across all services.
- Back up the LLDAP dataset regularly.
## References
- [TrueNAS Apps Catalog](https://apps.truenas.com/catalog/lldap/)
- [TrueNAS SCALE Documentation](https://www.truenas.com/docs/scale/)

View File

@@ -159,16 +159,3 @@ key_seed = "RanD0m STR1ng"
#cert_file="/data/cert.pem" #cert_file="/data/cert.pem"
## Certificate key file. ## Certificate key file.
#key_file="/data/key.pem" #key_file="/data/key.pem"
## Options to configure the healthcheck command.
## To set these options from environment variables, use the following format
## (example with http_host): LLDAP_HEALTHCHECK_OPTIONS__HTTP_HOST
[healthcheck_options]
## The host address that the healthcheck should verify for the HTTP server.
## If "http_host" is set to a specific IP address, this must be set to match if the built-in
## healthcheck command is used. Note: if this is an IPv6 address, it must be wrapped in [].
#http_host = "localhost"
## The host address that the healthcheck should verify for the LDAP server.
## If "ldap_host" is set to a specific IP address, this must be set to match if the built-in
## healthcheck command is used.
#ldap_host = "localhost"

View File

@@ -34,7 +34,7 @@ features = ["json", "blocking", "rustls-tls"]
[dependencies.ldap3] [dependencies.ldap3]
version = "*" version = "*"
default-features = false default-features = false
features = ["sync", "tls-rustls-ring"] features = ["sync", "tls-rustls"]
[dependencies.serde] [dependencies.serde]
workspace = true workspace = true

View File

@@ -18,6 +18,7 @@ actix-http = "3"
actix-rt = "2" actix-rt = "2"
actix-server = "2" actix-server = "2"
actix-service = "2" actix-service = "2"
actix-web = "4.3"
actix-web-httpauth = "0.8" actix-web-httpauth = "0.8"
anyhow = "*" anyhow = "*"
async-trait = "0.1" async-trait = "0.1"
@@ -34,12 +35,12 @@ jwt = "0.16"
ldap3_proto = "0.6.0" ldap3_proto = "0.6.0"
log = "*" log = "*"
rand_chacha = "0.3" rand_chacha = "0.3"
rustls-pemfile = "2" rustls-pemfile = "1"
serde_json = "1" serde_json = "1"
sha2 = "0.10" sha2 = "0.10"
thiserror = "2" thiserror = "2"
time = "0.3" time = "0.3"
tokio-rustls = "0.26" tokio-rustls = "0.23"
tokio-stream = "*" tokio-stream = "*"
tokio-util = "0.7" tokio-util = "0.7"
tracing = "*" tracing = "*"
@@ -47,20 +48,7 @@ tracing-actix-web = "0.7"
tracing-attributes = "^0.1.21" tracing-attributes = "^0.1.21"
tracing-log = "*" tracing-log = "*"
urlencoding = "2" urlencoding = "2"
webpki-roots = "0.26" webpki-roots = "0.22.2"
[dependencies.actix-web]
features = ["rustls-0_23"]
version = "4.12.1"
[dependencies.rustls]
default-features = false
features = ["ring", "logging", "std", "tls12"]
version = "0.23"
[dependencies.rustls-pki-types]
features = ["std"]
version = "1"
[dependencies.chrono] [dependencies.chrono]
features = ["serde"] features = ["serde"]
@@ -86,7 +74,7 @@ features = ["env-filter", "tracing-log"]
[dependencies.lettre] [dependencies.lettre]
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"] features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
default-features = false default-features = false
version = "0.11.19" version = "0.10.1"
[dependencies.lldap_access_control] [dependencies.lldap_access_control]
path = "../crates/access-control" path = "../crates/access-control"
@@ -157,7 +145,7 @@ features = ["smallvec", "chrono", "tokio"]
version = "^0.1.6" version = "^0.1.6"
[dependencies.actix-tls] [dependencies.actix-tls]
features = ["default", "rustls-0_23"] features = ["default", "rustls"]
version = "3" version = "3"
[dependencies.sea-orm] [dependencies.sea-orm]
@@ -175,6 +163,10 @@ version = "0.11"
default-features = false default-features = false
features = ["rustls-tls-webpki-roots"] features = ["rustls-tls-webpki-roots"]
[dependencies.rustls]
version = "0.20"
features = ["dangerous_configuration"]
[dependencies.url] [dependencies.url]
version = "2" version = "2"
features = ["serde"] features = ["serde"]
@@ -193,7 +185,7 @@ version = "0.11"
[dev-dependencies.ldap3] [dev-dependencies.ldap3]
version = "*" version = "*"
default-features = false default-features = false
features = ["sync", "tls-rustls-ring"] features = ["sync", "tls-rustls"]
[dev-dependencies.lldap_auth] [dev-dependencies.lldap_auth]
path = "../crates/auth" path = "../crates/auth"

View File

@@ -174,9 +174,6 @@ pub struct RunOpts {
#[clap(flatten)] #[clap(flatten)]
pub ldaps_opts: LdapsOpts, pub ldaps_opts: LdapsOpts,
#[clap(flatten)]
pub healthcheck_opts: HealthcheckOpts,
} }
#[derive(Debug, Parser, Clone)] #[derive(Debug, Parser, Clone)]
@@ -267,18 +264,6 @@ pub struct ExportGraphQLSchemaOpts {
pub output_file: Option<String>, pub output_file: Option<String>,
} }
#[derive(Debug, Parser, Clone)]
#[clap(next_help_heading = Some("HEALTHCHECK"))]
pub struct HealthcheckOpts {
/// Change the HTTP Host to test the health of. Default: "localhost"
#[clap(long, env = "LLDAP_HEALTHCHECK_OPTIONS__HTTP_HOST")]
pub healthcheck_http_host: Option<String>,
/// Change the LDAP Host to test the health of. Default: "localhost"
#[clap(long, env = "LLDAP_HEALTHCHECK_OPTIONS__LDAP_HOST")]
pub healthcheck_ldap_host: Option<String>,
}
pub fn init() -> CLIOpts { pub fn init() -> CLIOpts {
CLIOpts::parse() CLIOpts::parse()
} }

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
cli::{ cli::{
GeneralConfigOpts, HealthcheckOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts,
TestEmailOpts, TrueFalseAlways, TrueFalseAlways,
}, },
database_string::DatabaseUrl, database_string::DatabaseUrl,
}; };
@@ -83,21 +83,6 @@ impl std::default::Default for LdapsOptions {
} }
} }
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
#[builder(pattern = "owned")]
pub struct HealthcheckOptions {
#[builder(default = r#"String::from("localhost")"#)]
pub http_host: String,
#[builder(default = r#"String::from("localhost")"#)]
pub ldap_host: String,
}
impl std::default::Default for HealthcheckOptions {
fn default() -> Self {
HealthcheckOptionsBuilder::default().build().unwrap()
}
}
#[derive(Clone, Deserialize, Serialize, derive_more::Debug)] #[derive(Clone, Deserialize, Serialize, derive_more::Debug)]
#[debug(r#""{_0}""#)] #[debug(r#""{_0}""#)]
pub struct HttpUrl(pub Url); pub struct HttpUrl(pub Url);
@@ -153,8 +138,6 @@ pub struct Configuration {
#[serde(skip)] #[serde(skip)]
#[builder(field(private), default = "None")] #[builder(field(private), default = "None")]
server_setup: Option<ServerSetupConfig>, server_setup: Option<ServerSetupConfig>,
#[builder(default)]
pub healthcheck_options: HealthcheckOptions,
} }
impl std::default::Default for Configuration { impl std::default::Default for Configuration {
@@ -540,18 +523,6 @@ impl ConfigOverrider for SmtpOpts {
} }
} }
impl ConfigOverrider for HealthcheckOpts {
fn override_config(&self, config: &mut Configuration) {
self.healthcheck_http_host
.as_ref()
.inspect(|host| config.healthcheck_options.http_host.clone_from(host));
self.healthcheck_ldap_host
.as_ref()
.inspect(|host| config.healthcheck_options.ldap_host.clone_from(host));
}
}
fn extract_keys(dict: &figment::value::Dict) -> HashSet<String> { fn extract_keys(dict: &figment::value::Dict) -> HashSet<String> {
use figment::value::{Dict, Value}; use figment::value::{Dict, Value};
fn process_value(value: &Dict, keys: &mut HashSet<String>, path: &mut Vec<String>) { fn process_value(value: &Dict, keys: &mut HashSet<String>, path: &mut Vec<String>) {

View File

@@ -1,4 +1,4 @@
use crate::{configuration::LdapsOptions, tls}; use crate::{configuration::LdapsOptions, ldap_server::read_certificates};
use anyhow::{Context, Result, anyhow, bail, ensure}; use anyhow::{Context, Result, anyhow, bail, ensure};
use futures_util::SinkExt; use futures_util::SinkExt;
use ldap3_proto::{ use ldap3_proto::{
@@ -8,11 +8,6 @@ use ldap3_proto::{
LdapSearchScope, LdapSearchScope,
}, },
}; };
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::sync::Arc;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_rustls::TlsConnector as RustlsTlsConnector; use tokio_rustls::TlsConnector as RustlsTlsConnector;
use tokio_util::codec::{FramedRead, FramedWrite}; use tokio_util::codec::{FramedRead, FramedWrite};
@@ -75,107 +70,77 @@ where
} }
#[instrument(level = "info", err)] #[instrument(level = "info", err)]
pub async fn check_ldap(host: &str, port: u16) -> Result<()> { pub async fn check_ldap(port: u16) -> Result<()> {
check_ldap_endpoint(TcpStream::connect((host, port)).await?).await check_ldap_endpoint(TcpStream::connect(format!("localhost:{port}")).await?).await
}
fn get_root_certificates() -> rustls::RootCertStore {
let mut root_store = rustls::RootCertStore::empty();
root_store.add_server_trust_anchors(webpki_roots::TLS_SERVER_ROOTS.0.iter().map(|ta| {
rustls::OwnedTrustAnchor::from_subject_spki_name_constraints(
ta.subject,
ta.spki,
ta.name_constraints,
)
}));
root_store
} }
fn get_tls_connector(ldaps_options: &LdapsOptions) -> Result<RustlsTlsConnector> { fn get_tls_connector(ldaps_options: &LdapsOptions) -> Result<RustlsTlsConnector> {
let certs = tls::load_certificates(&ldaps_options.cert_file)?; let mut client_config = rustls::ClientConfig::builder()
let target_cert = certs.first().expect("empty certificate chain").clone(); .with_safe_defaults()
.with_root_certificates(get_root_certificates())
#[derive(Debug)] .with_no_client_auth();
let (certs, _private_key) = read_certificates(ldaps_options)?;
// Check that the server cert is the one in the config file.
struct CertificateVerifier { struct CertificateVerifier {
certificate: CertificateDer<'static>, certificate: rustls::Certificate,
certificate_path: String,
} }
impl rustls::client::ServerCertVerifier for CertificateVerifier {
impl ServerCertVerifier for CertificateVerifier {
fn verify_server_cert( fn verify_server_cert(
&self, &self,
end_entity: &CertificateDer<'_>, end_entity: &rustls::Certificate,
_intermediates: &[CertificateDer<'_>], _intermediates: &[rustls::Certificate],
_server_name: &ServerName<'_>, _server_name: &rustls::ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8], _ocsp_response: &[u8],
_now: UnixTime, _now: std::time::SystemTime,
) -> Result<ServerCertVerified, rustls::Error> { ) -> std::result::Result<rustls::client::ServerCertVerified, rustls::Error> {
if end_entity != &self.certificate { if end_entity != &self.certificate {
return Err(rustls::Error::InvalidCertificate( return Err(rustls::Error::InvalidCertificateData(format!(
rustls::CertificateError::NotValidForName, "Server certificate doesn't match the one in the config file {}",
)); &self.certificate_path
)));
} }
Ok(ServerCertVerified::assertion()) Ok(rustls::client::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
} }
} }
let mut dangerous_config = rustls::client::DangerousClientConfig {
let verifier = Arc::new(CertificateVerifier { cfg: &mut client_config,
certificate: target_cert, };
}); dangerous_config.set_certificate_verifier(std::sync::Arc::new(CertificateVerifier {
certificate: certs.first().expect("empty certificate chain").clone(),
let client_config = rustls::ClientConfig::builder_with_provider( certificate_path: ldaps_options.cert_file.clone(),
rustls::crypto::ring::default_provider().into(), }));
) Ok(std::sync::Arc::new(client_config).into())
.with_safe_default_protocol_versions()
.context("Failed to set default protocol versions")?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
Ok(Arc::new(client_config).into())
} }
#[instrument(skip_all, level = "info", err, fields(host = %host, port = %ldaps_options.port))] #[instrument(skip_all, level = "info", err, fields(port = %ldaps_options.port))]
pub async fn check_ldaps(host: &str, ldaps_options: &LdapsOptions) -> Result<()> { pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> {
if !ldaps_options.enabled { if !ldaps_options.enabled {
info!("LDAPS not enabled"); info!("LDAPS not enabled");
return Ok(()); return Ok(());
}; };
let tls_connector = let tls_connector =
get_tls_connector(ldaps_options).context("while preparing the tls connection")?; get_tls_connector(ldaps_options).context("while preparing the tls connection")?;
let url = format!("localhost:{}", ldaps_options.port);
let domain = match host.parse::<std::net::IpAddr>() {
Ok(ip) => ServerName::IpAddress(ip.into()),
Err(_) => ServerName::try_from(host.to_string())
.map_err(|_| anyhow!("Invalid DNS name: {}", host))?,
};
check_ldap_endpoint( check_ldap_endpoint(
tls_connector tls_connector
.connect( .connect(
domain, rustls::ServerName::try_from("localhost")
TcpStream::connect((host, ldaps_options.port)) .context("while parsing the server name")?,
TcpStream::connect(&url)
.await .await
.context("while connecting TCP")?, .context("while connecting TCP")?,
) )
@@ -186,8 +151,8 @@ pub async fn check_ldaps(host: &str, ldaps_options: &LdapsOptions) -> Result<()>
} }
#[instrument(level = "info", err)] #[instrument(level = "info", err)]
pub async fn check_api(host: &str, port: u16) -> Result<()> { pub async fn check_api(port: u16) -> Result<()> {
reqwest::get(format!("http://{host}:{port}/health")) reqwest::get(format!("http://localhost:{port}/health"))
.await? .await?
.error_for_status()?; .error_for_status()?;
info!("Success"); info!("Success");

View File

@@ -1,14 +1,14 @@
use crate::configuration::{Configuration, LdapsOptions}; use crate::configuration::{Configuration, LdapsOptions};
use crate::tls;
use actix_rt::net::TcpStream; use actix_rt::net::TcpStream;
use actix_server::ServerBuilder; use actix_server::ServerBuilder;
use actix_service::{ServiceFactoryExt, fn_service}; use actix_service::{ServiceFactoryExt, fn_service};
use anyhow::{Context, Result}; use anyhow::{Context, Result, anyhow};
use ldap3_proto::{LdapCodec, control::LdapControl, proto::LdapMsg, proto::LdapOp}; use ldap3_proto::{LdapCodec, control::LdapControl, proto::LdapMsg, proto::LdapOp};
use lldap_access_control::AccessControlledBackendHandler; use lldap_access_control::AccessControlledBackendHandler;
use lldap_domain_handlers::handler::{BackendHandler, LoginHandler}; use lldap_domain_handlers::handler::{BackendHandler, LoginHandler};
use lldap_ldap::{LdapHandler, LdapInfo}; use lldap_ldap::{LdapHandler, LdapInfo};
use lldap_opaque_handler::OpaqueHandler; use lldap_opaque_handler::OpaqueHandler;
use rustls::PrivateKey;
use tokio_rustls::TlsAcceptor as RustlsTlsAcceptor; use tokio_rustls::TlsAcceptor as RustlsTlsAcceptor;
use tokio_util::codec::{FramedRead, FramedWrite}; use tokio_util::codec::{FramedRead, FramedWrite};
use tracing::{debug, error, info, instrument}; use tracing::{debug, error, info, instrument};
@@ -102,18 +102,55 @@ where
Ok(requests.into_inner().unsplit(resp.into_inner())) Ok(requests.into_inner().unsplit(resp.into_inner()))
} }
fn get_tls_acceptor(ldaps_options: &LdapsOptions) -> Result<RustlsTlsAcceptor> { fn read_private_key(key_file: &str) -> Result<PrivateKey> {
let certs = tls::load_certificates(&ldaps_options.cert_file)?; use rustls_pemfile::{ec_private_keys, pkcs8_private_keys, rsa_private_keys};
let private_key = tls::load_private_key(&ldaps_options.key_file)?; use std::{fs::File, io::BufReader};
pkcs8_private_keys(&mut BufReader::new(File::open(key_file)?))
.map_err(anyhow::Error::from)
.and_then(|keys| {
keys.into_iter()
.next()
.ok_or_else(|| anyhow!("No PKCS8 key"))
})
.or_else(|_| {
rsa_private_keys(&mut BufReader::new(File::open(key_file)?))
.map_err(anyhow::Error::from)
.and_then(|keys| {
keys.into_iter()
.next()
.ok_or_else(|| anyhow!("No PKCS1 key"))
})
})
.or_else(|_| {
ec_private_keys(&mut BufReader::new(File::open(key_file)?))
.map_err(anyhow::Error::from)
.and_then(|keys| keys.into_iter().next().ok_or_else(|| anyhow!("No EC key")))
})
.with_context(|| {
format!("Cannot read either PKCS1, PKCS8 or EC private key from {key_file}")
})
.map(rustls::PrivateKey)
}
pub fn read_certificates(
ldaps_options: &LdapsOptions,
) -> Result<(Vec<rustls::Certificate>, rustls::PrivateKey)> {
use std::{fs::File, io::BufReader};
let certs = rustls_pemfile::certs(&mut BufReader::new(File::open(&ldaps_options.cert_file)?))?
.into_iter()
.map(rustls::Certificate)
.collect::<Vec<_>>();
let private_key = read_private_key(&ldaps_options.key_file)?;
Ok((certs, private_key))
}
fn get_tls_acceptor(ldaps_options: &LdapsOptions) -> Result<RustlsTlsAcceptor> {
let (certs, private_key) = read_certificates(ldaps_options)?;
let server_config = std::sync::Arc::new( let server_config = std::sync::Arc::new(
rustls::ServerConfig::builder_with_provider( rustls::ServerConfig::builder()
rustls::crypto::ring::default_provider().into(), .with_safe_defaults()
) .with_no_client_auth()
.with_safe_default_protocol_versions() .with_single_cert(certs, private_key)?,
.context("Failed to set default protocol versions")?
.with_no_client_auth()
.with_single_cert(certs, private_key)?,
); );
Ok(server_config.into()) Ok(server_config.into())
} }

View File

@@ -17,7 +17,6 @@ mod mail;
mod sql_tcp_backend_handler; mod sql_tcp_backend_handler;
mod tcp_backend_handler; mod tcp_backend_handler;
mod tcp_server; mod tcp_server;
mod tls;
use crate::{ use crate::{
cli::{Command, RunOpts, TestEmailOpts}, cli::{Command, RunOpts, TestEmailOpts},
@@ -256,18 +255,9 @@ async fn run_healthcheck(opts: RunOpts) -> Result<()> {
use tokio::time::timeout; use tokio::time::timeout;
let delay = Duration::from_millis(3000); let delay = Duration::from_millis(3000);
let (ldap, ldaps, api) = tokio::join!( let (ldap, ldaps, api) = tokio::join!(
timeout( timeout(delay, healthcheck::check_ldap(config.ldap_port)),
delay, timeout(delay, healthcheck::check_ldaps(&config.ldaps_options)),
healthcheck::check_ldap(&config.healthcheck_options.ldap_host, config.ldap_port) timeout(delay, healthcheck::check_api(config.http_port)),
),
timeout(
delay,
healthcheck::check_ldaps(&config.healthcheck_options.ldap_host, &config.ldaps_options)
),
timeout(
delay,
healthcheck::check_api(&config.healthcheck_options.http_host, config.http_port)
),
); );
let failure = [ldap, ldaps, api] let failure = [ldap, ldaps, api]

View File

@@ -12,4 +12,3 @@ pub mod mail;
pub mod sql_tcp_backend_handler; pub mod sql_tcp_backend_handler;
pub mod tcp_backend_handler; pub mod tcp_backend_handler;
pub mod tcp_server; pub mod tcp_server;
pub mod tls;

View File

@@ -1,20 +0,0 @@
use anyhow::{Context, Result, anyhow};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
pub fn load_certificates(filename: &str) -> Result<Vec<CertificateDer<'static>>> {
let certs = CertificateDer::pem_file_iter(filename)
.with_context(|| format!("Unable to open or read certificate file: {}", filename))?
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("Error parsing certificates in {}", filename))?;
if certs.is_empty() {
return Err(anyhow!("No certificates found in {}", filename));
}
Ok(certs)
}
pub fn load_private_key(filename: &str) -> Result<PrivateKeyDer<'static>> {
PrivateKeyDer::from_pem_file(filename)
.with_context(|| format!("Unable to load private key from {}", filename))
}

View File

@@ -6,7 +6,7 @@ use crate::common::{
add_user_to_group, create_group, create_user, delete_group_query, delete_user_query, post, add_user_to_group, create_group, create_user, delete_group_query, delete_user_query, post,
}, },
}; };
use assert_cmd::cargo_bin; use assert_cmd::prelude::*;
use nix::{ use nix::{
sys::signal::{self, Signal}, sys::signal::{self, Signal},
unistd::Pid, unistd::Pid,
@@ -226,7 +226,7 @@ pub fn new_id(prefix: Option<&str>) -> String {
} }
fn create_lldap_command(subcommand: &str) -> Command { fn create_lldap_command(subcommand: &str) -> Command {
let mut cmd = Command::new(cargo_bin!()); let mut cmd = Command::cargo_bin(env!("CARGO_PKG_NAME")).expect("cargo bin not found");
// This gives us the absolute path of the repo base instead of running it in server/ // This gives us the absolute path of the repo base instead of running it in server/
let path = canonicalize("..").expect("canonical path"); let path = canonicalize("..").expect("canonical path");
let db_url = env::database_url(); let db_url = env::database_url();