Compare commits

..

No commits in common. "master" and "v0.1.2" have entirely different histories.

23 changed files with 1439 additions and 1889 deletions

View File

@ -1,3 +1,2 @@
README.md
target/
scripts/

View File

@ -1,22 +0,0 @@
# Docs: <https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/customizing-dependency-updates>
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule: {interval: monthly}
reviewers: [ViViDboarder]
assignees: [ViViDboarder]
- package-ecosystem: docker
directory: /
schedule: {interval: monthly}
reviewers: [ViViDboarder]
assignees: [ViViDboarder]
- package-ecosystem: cargo
directory: /
schedule: {interval: monthly}
reviewers: [ViViDboarder]
assignees: [ViViDboarder]

View File

@ -1,35 +0,0 @@
---
name: Tests
"on":
push:
branches: [master]
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- uses: actions/setup-python@v5
- name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1
env:
SKIP: hadolint
- name: Run hadolint
uses: hadolint/hadolint-action@v3.1.0

View File

@ -1,53 +0,0 @@
---
name: Docker
"on":
push:
branches:
- master
tags:
- 'v*'
pull_request:
branches:
- master
jobs:
docker:
strategy:
matrix:
include:
- dockerfile: Dockerfile
latest: "auto"
suffix: ""
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: vividboarder/vaultwarden_ldap
flavor: |
latest=${{ matrix.latest }}
suffix=${{ matrix.suffix }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

2
.gitignore vendored
View File

@ -6,4 +6,4 @@
**/*.rs.bk
# Ignore config while developing
./config.toml
config.toml

View File

@ -1,25 +0,0 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: check-added-large-files
- id: check-yaml
- id: check-toml
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- repo: https://github.com/doublify/pre-commit-rust
rev: v1.0
hooks:
- id: fmt
- id: cargo-check
- id: clippy
- repo: https://github.com/IamTheFij/docker-pre-commit
rev: v3.0.1
hooks:
- id: docker-compose-check
- repo: https://github.com/hadolint/hadolint
rev: v2.12.0
hooks:
- id: hadolint

2246
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,12 @@
[package]
name = "vaultwarden_ldap"
version = "2.0.2"
name = "bitwarden_rs_ldap"
version = "0.1.2"
authors = ["ViViDboarder <vividboarder@gmail.com>"]
edition = "2018"
[dependencies]
ldap3 = "0.11"
ldap3 = "0.6"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
reqwest = { version = "0.12", features = ["json", "blocking"] }
toml = "0.5"
reqwest = "0.9"
serde_json = "1.0"
thiserror = "1.0"
anyhow = "1.0"
envy = "0.4.1"
pledge = "0.4.2"
unveil = "0.3.2"

View File

@ -1,25 +1,19 @@
FROM rust:1.82 as builder
FROM rust:1.33
WORKDIR /usr/src/
RUN USER=root cargo new --bin vaultwarden_ldap
WORKDIR /usr/src/vaultwarden_ldap
RUN USER=root cargo new --bin bitwarden_rs_ldap
WORKDIR /usr/src/bitwarden_rs_ldap
# Compile dependencies
COPY Cargo.toml Cargo.lock ./
RUN cargo build --locked --release
# Remove bins to make sure we rebuild
# hadolint ignore=DL3059
RUN rm ./target/release/deps/vaultwarden_ldap*
# Copy source and install
COPY src ./src
COPY ./Cargo.toml ./Cargo.toml
COPY ./Cargo.lock ./Cargo.lock
RUN cargo build --release
# Remove temp src
RUN rm src/*.rs
# Use most recent ubuntu LTS release
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends 'ca-certificates=20240203' 'libssl-dev=3.*' \
&& rm -rf /var/cache/apt/lists
COPY --from=builder /usr/src/vaultwarden_ldap/target/release/vaultwarden_ldap /usr/local/bin/
# Copy source and install
COPY ./src ./src
RUN rm ./target/release/deps/bitwarden_rs_ldap*
RUN cargo install --path .
CMD ["/usr/local/bin/vaultwarden_ldap"]
CMD ["bitwarden_rs_ldap"]

24
Dockerfile.alpine Normal file
View File

@ -0,0 +1,24 @@
FROM ekidd/rust-musl-builder AS builder
RUN USER=rust cargo init
COPY ./Cargo.toml ./Cargo.toml
COPY ./Cargo.lock ./Cargo.lock
RUN cargo build --release
# Remove temp src
RUN rm src/*.rs
COPY ./src ./src
# Fix permissions on source
RUN sudo chown -R rust:rust /home/rust/src/src
RUN touch ./src/main.rs
RUN cargo build --release
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder \
/home/rust/src/target/x86_64-unknown-linux-musl/release/bitwarden_rs_ldap \
/usr/local/bin/
CMD /usr/local/bin/bitwarden_rs_ldap

101
Makefile
View File

@ -1,101 +0,0 @@
DOCKER_TAG ?= vaultwarden_ldap_${USER}
.PHONY: all
all: test check release
# Default make target will run tests
.DEFAULT_GOAL = test
# Build debug version
target/debug/vaultwarden_ldap: src/
cargo build
# Build release version
target/release/vaultwarden_ldap: src/
cargo build --locked --release
.PHONY: build
build: debug
.PHONY: debug
debug: target/debug/vaultwarden_ldap
.PHONY: release
release: target/release/vaultwarden_ldap
# Run debug version
.PHONY: run-debug
run-debug: target/debug/vaultwarden_ldap
target/debug/vaultwarden_ldap
# Run all tests
.PHONY: test
test:
cargo test
# Run bootstrapped integration test
.PHONY: itest-up
itest-up:
docker compose -f docker-compose.yml \
-f itest/docker-compose.itest.yml \
build
docker compose -f docker-compose.yml \
-f itest/docker-compose.itest.yml \
up -d vaultwarden ldap
.PHONY: itest-run
itest-run:
docker compose -f docker-compose.yml \
-f itest/docker-compose.itest.yml \
run ldap_sync
.PHONY: itest-stop
itest-stop:
docker compose stop
.PHONY: itest
itest: itest-up itest-run itest-stop
# Run bootstrapped integration test using env for config
.PHONY: itest-env
itest-env:
docker compose -f docker-compose.yml \
-f itest/docker-compose.itest-env.yml \
build
docker compose -f docker-compose.yml \
-f itest/docker-compose.itest-env.yml \
up -d vaultwarden ldap
docker compose -f docker-compose.yml \
-f itest/docker-compose.itest-env.yml \
run ldap_sync
docker compose stop
.PHONY: clean-itest
clean-itest:
docker compose down -v
# Installs pre-commit hooks
.PHONY: install-hooks
install-hooks:
pre-commit install --install-hooks
# Checks files for encryption
.PHONY: check
check:
pre-commit run --all-files
# Checks that version matches the current tag
.PHONY: check-version
check-version:
./scripts/check-version.sh
.PHONY: clean
clean:
rm -f ./target
.PHONY: docker-build-all
docker-build-all: docker-build
.PHONY: docker-build
docker-build:
docker build -f ./Dockerfile -t $(DOCKER_TAG) .

View File

@ -1,11 +1,11 @@
# vaultwarden_ldap
LDAP user invites for [vaultwarden](https://github.com/dani-garcia/vaultwarden)
# bitwarden_rs_ldap
A simple LDAP connector for [bitwarden_rs](https://github.com/dani-garcia/bitwarden_rs)
After configuring, run `vaultwarden_ldap` and it will invite any users it finds in LDAP to your `vaultwarden` instance. This is NOT a sync tool like the [Bitwarden Directory Connector](https://bitwarden.com/help/directory-sync/).
After configuring, simply run `bitwarden_rs_ldap` and it will invite any users it finds in LDAP to your `bitwarden_rs` instance.
## Deploying
This is easiest done using Docker. See the `docker-compose.yml` file in this repo for an example. If you would like to use Docker Hub rather than building, change `build: .` to `image: vividboarder/vaultwarden_ldap`.
This is easiest done using Docker. See the `docker-compose.yml` file in this repo for an example. If you would like to use Docker Hub rather than building, change `build: .` to `image: vividboarder/bitwarden_rs_ldap`.
Make sure to populate and mount your `config.toml`!
@ -17,14 +17,11 @@ Configuration values are as follows:
|Name|Type|Optional|Description|
|----|----|--------|-----------|
|`vaultwarden_url`|String||The root URL for accessing `vaultwarden`. Eg: `https://vw.example.com`|
|`vaultwarden_admin_token`|String||The value passed as `ADMIN_TOKEN` to `vaultwarden`|
|`vaultwarden_root_cert_file`|String|Optional|Path to an additional der-encoded root certificate to trust. Eg. `root.cert`. If using Docker see `docker-compose.yml` for how to expose it. Defaults to `empty`|
|`bitwarden_url`|String||The root URL for accessing `bitwarden_rs`. Eg: `https://bw.example.com`|
|`bitwarden_admin_token`|String||The value passed as `ADMIN_TOKEN` to `bitwarden_rs`|
|`ldap_host`|String||The hostname or IP address for your ldap server|
|`ldap_scheme`|String|Optional|The that should be used to connect. `ldap` or `ldaps`. This is set by default based on SSL settings|
|`ldap_ssl`|Boolean|Optional|Indicates if SSL should be used and if we should connect with `ldaps`. Defaults to `false`|
|`ldap_starttls`|Boolean|Optional|Indicates if the connection should be done using StartTLS|
|`ldap_no_tls_verify`|Boolean|Optional|Indicates if certificate should be verified when using SSL. Defaults to `true`|
|`ldap_ssl`|Boolean|Optional|Indicates if SSL should be used. Defaults to `false`|
|`ldap_port`|Integer|Optional|Port used to connect to the LDAP server. This will default to 389 or 636, depending on your SSL settings|
|`ldap_bind_dn`|String||The dn for the bind user that will connect to LDAP. Eg. `cn=admin,dc=example,dc=org`|
|`ldap_bind_password`|String||The password for the provided bind user.|
@ -34,31 +31,12 @@ Configuration values are as follows:
|`ldap_sync_interval_seconds`|Integer|Optional|Number of seconds to wait between each LDAP request. Defaults to `60`|
|`ldap_sync_loop`|Boolean|Optional|Indicates whether or not syncing should be polled in a loop or done once. Defaults to `true`|
Alternatively, instead of using `config.toml`, all values can be provided using enviroment variables prefixed with `APP_`. For example: `APP_VAULTWARDEN_URL=https://vault.example.com`
## Development
This repo has a predefined set of [pre-commit](https://pre-commit.com) rules. You can install pre-commit via any means you'd like. Once your system has `pre-commit` installed, you can run `make install-hooks` to ensure the hooks will run with every commit. You can also force running all hooks with `make check`.
For those less familiar with `cargo`, you can use the `make` targets that have been included for common tasks like running a debug version. `make run-debug` or building a release version `make release`.
## Testing
There are no unit tests, but there are integration tests that require manual verification.
### Integration tests
Running `make itest` will spin up an ldap server with a test user, a Vaultwarden server, and then run the sync. If successful the log should show an invitation sent to the test user. If you run `make itest` again, it should show no invites sent because the user already has been invited. If you'd like to reset the testing, `make clean-itest` will clear out the Vaultwarden database and start fresh.
It's also possible to test passing configs via enviornment variables by running `make itest-env`. The validation steps are the same.
### Steps for manual testing
The first step is to set up Bitwarden and the LDAP server.
All testing is manual right now. First step is to set up Bitwarden and the LDAP server.
```bash
docker-compose up -d vaultwarden ldap ldap_admin
docker-compose up -d bitwarden ldap ldap_admin
```
1. After that, open the admin portal on http://localhost:8001 and log in using the default account info:
@ -82,6 +60,7 @@ From there you can set up your test group and users.
docker-compose up ldap_sync
```
Alternately, you can bootstrap some of this by running:
## Future
docker-compose -f docker-compose.yml -f itest/docker-compose.itest.yml up --build
* Any kind of proper logging
* Tests

View File

@ -1,32 +1,29 @@
---
version: '3'
services:
ldap_sync:
build:
context: .
dockerfile: Dockerfile
# dockerfile: Dockerfile.alpine
volumes:
- ./example.config.toml:/config.toml:ro
# ./root.cert:/usr/src/vaultwarden_ldap/root.cert:ro
- ./example.config.toml:/usr/src/bitwarden_rs_ldap/config.toml:ro
environment:
CONFIG_PATH: /config.toml
RUST_BACKTRACE: full
depends_on:
- vaultwarden
- ldap
RUST_BACKTRACE: 1
restart: always
vaultwarden:
image: vaultwarden/server
bitwarden:
image: mprasil/bitwarden
ports:
- 8000:80
environment:
ADMIN_TOKEN: admin
SIGNUPS_ALLOWED: 'false'
INVITATIONS_ALLOWED: 'true'
I_REALLY_WANT_VOLATILE_STORAGE: 'true'
ldap:
image: osixia/openldap
ports:
- 389:389
- 636:636
volumes:
- /var/lib/ldap
- /etc/ldap/slapd.d
@ -42,5 +39,3 @@ services:
environment:
PHPLDAPADMIN_HTTPS: 'false'
PHPLDAPADMIN_LDAP_HOSTS: ldap
depends_on:
- ldap

View File

@ -1,5 +1,5 @@
vaultwarden_url = "http://vaultwarden:80"
vaultwarden_admin_token = "admin"
bitwarden_url = "http://bitwarden:80"
bitwarden_admin_token = "admin"
ldap_host = "ldap"
ldap_bind_dn = "cn=admin,dc=example,dc=org"
ldap_bind_password = "admin"

View File

@ -1,8 +0,0 @@
vaultwarden_url = "http://vaultwarden:80"
vaultwarden_admin_token = "admin"
ldap_host = "ldap"
ldap_bind_dn = "cn=readonly,dc=example,dc=org"
ldap_bind_password = "readonly"
ldap_search_base_dn = "dc=example,dc=org"
ldap_search_filter = "(&(objectClass=*)(uid=*))"
ldap_sync_loop = false

View File

@ -1,20 +0,0 @@
---
services:
ldap_sync:
environment:
CONFIG_PATH: ""
APP_VAULTWARDEN_URL: "http://vaultwarden:80"
APP_VAULTWARDEN_ADMIN_TOKEN: "admin"
APP_LDAP_HOST: "ldap"
APP_LDAP_BIND_DN: "cn=admin,dc=example,dc=org"
APP_LDAP_BIND_PASSWORD: "admin"
APP_LDAP_SEARCH_BASE_DN: "dc=example,dc=org"
APP_LDAP_SEARCH_FILTER: "(&(objectClass=*)(uid=*))"
APP_LDAP_SYNC_LOOP: "false"
vaultwarden: {}
ldap:
command: ["--copy-service"]
volumes:
- ./itest/ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom

View File

@ -1,12 +0,0 @@
---
services:
ldap_sync:
volumes:
- ./itest/config.toml:/config.toml:ro
vaultwarden: {}
ldap:
command: ["--copy-service"]
volumes:
- ./itest/ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom

View File

@ -1,49 +0,0 @@
# Generated by phpLDAPadmin (http://phpldapadmin.sourceforge.net) on May 4, 2021 6:06 pm
# Version: 1.2.5
version: 1
# Entry 1: Users group
dn: cn=Users,dc=example,dc=org
cn: Users
gidnumber: 500
objectclass: posixGroup
objectclass: top
# Entry 2: User with email
dn: cn=Someone,cn=Users,dc=example,dc=org
cn: Someone
gidnumber: 500
homedirectory: /home/users/someone
mail: test@example.com
objectclass: inetOrgPerson
objectclass: posixAccount
objectclass: top
sn: Someone
uid: someone
uidnumber: 1000
# Entry 3: User with no email
dn: cn=SomeoneNoEmail,cn=Users,dc=example,dc=org
cn: SomeoneNoEmail
gidnumber: 500
homedirectory: /home/users/someonenoemail
objectclass: inetOrgPerson
objectclass: posixAccount
objectclass: top
sn: SomeoneNoEmail
uid: someonenoemail
uidnumber: 1001
# Entry 4: User with email containing +
dn: cn=SomeonePlus,cn=Users,dc=example,dc=org
cn: SomeonePlus
gidnumber: 500
homedirectory: /home/users/someoneplus
mail: test+plus@example.com
objectclass: inetOrgPerson
objectclass: posixAccount
objectclass: top
sn: SomeonePlus
uid: someoneplus
uidnumber: 1002

View File

@ -1,8 +0,0 @@
#! /bin/sh
CARGO_VERSION=$(cargo pkgid --offline | sed 's/.*#//')
GIT_VERSION=${GIT_VERSION:-$(git describe --tags --exact-match)}
if ! [ "v$CARGO_VERSION" = "$GIT_VERSION" ]; then
echo "ERROR: Cargo version (v$CARGO_VERSION) and git version ($GIT_VERSION) do not match"
exit 1
fi

157
src/bw_admin.rs Normal file
View File

@ -0,0 +1,157 @@
extern crate reqwest;
extern crate serde;
use reqwest::Response;
use serde::Deserialize;
use std::collections::HashMap;
use std::error::Error;
use std::time::{Duration, Instant};
const COOKIE_LIFESPAN: Duration = Duration::from_secs(20 * 60);
#[derive(Debug, Deserialize)]
pub struct User {
#[serde(rename = "Email")]
email: String,
#[serde(rename = "_Status")]
status: i32,
}
impl User {
pub fn get_email(&self) -> String {
self.email.clone()
}
pub fn is_disabled(&self) -> bool {
// HACK: Magic number
self.status == 2
}
}
pub struct Client {
url: String,
admin_token: String,
cookie: Option<String>,
cookie_created: Option<Instant>,
}
impl Client {
/// Create new instance of client
pub fn new(url: String, admin_token: String) -> Client {
Client {
url,
admin_token,
cookie: None,
cookie_created: None,
}
}
/// Authenticate client
fn auth(&mut self) -> Response {
let cookie_created = Instant::now();
let client = reqwest::Client::builder()
// Avoid redirects because server will redirect to admin page after auth
.redirect(reqwest::RedirectPolicy::none())
.build()
.unwrap();
let result = client
.post(format!("{}{}", &self.url, "/admin/").as_str())
.form(&[("token", &self.admin_token)])
.send()
.unwrap_or_else(|e| {
panic!("Could not authenticate with {}. {:?}", &self.url, e);
});
// TODO: Handle error statuses
if let Some(cookie) = result.headers().get(reqwest::header::SET_COOKIE) {
self.cookie = cookie.to_str().map(|s| String::from(s)).ok();
self.cookie_created = Some(cookie_created);
} else {
panic!("Could not authenticate.")
}
result
}
/// Ensure that the client has a current auth cookie
fn ensure_auth(&mut self) {
match &self.cookie {
Some(_) => {
if self
.cookie_created
.map_or(true, |created| (created.elapsed() >= COOKIE_LIFESPAN))
{
self.auth();
}
}
None => {
self.auth();
}
};
// TODO: handle errors
}
/// Make an authenticated GET to Bitwarden Admin
fn get(&mut self, path: &str) -> Response {
self.ensure_auth();
match &self.cookie {
None => {
panic!("We haven't authenticated. Must be an error");
}
Some(cookie) => {
let url = format!("{}/admin{}", &self.url, path);
let request = reqwest::Client::new()
.get(url.as_str())
.header(reqwest::header::COOKIE, cookie.clone());
let response = request.send().unwrap_or_else(|e| {
panic!("Could not call with {}. {:?}", url, e);
});
// TODO: Handle error statuses
return response;
}
}
}
/// Make authenticated POST to Bitwarden Admin with JSON data
fn post(&mut self, path: &str, json: &HashMap<String, String>) -> Response {
self.ensure_auth();
match &self.cookie {
None => {
panic!("We haven't authenticated. Must be an error");
}
Some(cookie) => {
let url = format!("{}/admin{}", &self.url, path);
let request = reqwest::Client::new()
.post(url.as_str())
.header("Cookie", cookie.clone())
.json(&json);
let response = request.send().unwrap_or_else(|e| {
panic!("Could not call with {}. {:?}", url, e);
});
// TODO: Handle error statuses
return response;
}
}
}
/// Invite user with provided email
pub fn invite(&mut self, email: &str) -> Response {
let mut json = HashMap::new();
json.insert("email".to_string(), email.to_string());
self.post("/invite", &json)
}
/// Get all existing users
pub fn users(&mut self) -> Result<Vec<User>, Box<dyn Error>> {
let all_users: Vec<User> = self.get("/users").json()?;
Ok(all_users)
}
}

View File

@ -17,41 +17,18 @@ pub fn get_config_path() -> String {
}
}
// Tries to read configuration from file, failing that from the environment,
// panics if it can't
/// Reads configuration from file and panics if it can't
pub fn read_config() -> Config {
match read_config_from_file() {
Ok(config) => config,
Err(err) => {
println!("{}", err);
match read_config_from_env() {
Ok(config) => config,
Err(err) => panic!("{}", err),
}
}
}
}
/// Tries to read configuration from file
pub fn read_config_from_file() -> Result<Config, String> {
let config_path = get_config_path();
let contents = fs::read_to_string(&config_path)
.map_err(|err| format!("Failed to open config file at {}: {}", config_path, err))?;
let config: Config = toml::from_str(contents.as_str())
.map_err(|err| format!("Failed to parse config file at {}: {}", config_path, err))?;
let contents = fs::read_to_string(&config_path).unwrap_or_else(|_| {
panic!("Failed to open config file at {}", config_path);
});
let config: Config = toml::from_str(contents.as_str()).unwrap_or_else(|_| {
panic!("Failed to parse config file at {}", config_path);
});
println!("Config read from file at {}", config_path);
Ok(config)
}
// Tries to read configuration from environment
pub fn read_config_from_env() -> Result<Config, String> {
let config = envy::prefixed("APP_")
.from_env()
.map_err(|err| format!("Error parsing config from env: {}", err))?;
println!("Config read from environment");
Ok(config)
config
}
#[derive(Deserialize)]
@ -59,16 +36,13 @@ pub fn read_config_from_env() -> Result<Config, String> {
/// Contains all config values for LDAP syncing
pub struct Config {
// Bitwarden connection config
vaultwarden_url: String,
vaultwarden_admin_token: String,
vaultwarden_root_cert_file: Option<String>,
bitwarden_url: String,
bitwarden_admin_token: String,
// LDAP Connection config
ldap_host: String,
ldap_scheme: Option<String>,
ldap_ssl: Option<bool>,
ldap_starttls: Option<bool>,
ldap_port: Option<u16>,
ldap_no_tls_verify: Option<bool>,
// LDAP auth config
ldap_bind_dn: String,
ldap_bind_password: Pass,
@ -89,19 +63,12 @@ impl Config {
read_config()
}
pub fn get_vaultwarden_url(&self) -> String {
self.vaultwarden_url.clone()
pub fn get_bitwarden_url(&self) -> String {
self.bitwarden_url.clone()
}
pub fn get_vaultwarden_admin_token(&self) -> String {
self.vaultwarden_admin_token.clone()
}
pub fn get_vaultwarden_root_cert_file(&self) -> String {
match &self.vaultwarden_root_cert_file {
Some(vaultwarden_root_cert_file) => vaultwarden_root_cert_file.clone(),
None => String::new(),
}
pub fn get_bitwarden_admin_token(&self) -> String {
self.bitwarden_admin_token.clone()
}
pub fn get_ldap_url(&self) -> String {
@ -134,14 +101,6 @@ impl Config {
self.ldap_ssl.unwrap_or(false)
}
pub fn get_ldap_starttls(&self) -> bool {
self.ldap_starttls.unwrap_or(false)
}
pub fn get_ldap_no_tls_verify(&self) -> bool {
self.ldap_no_tls_verify.unwrap_or(false)
}
pub fn get_ldap_port(&self) -> u16 {
match self.ldap_port {
Some(ldap_port) => ldap_port,
@ -174,7 +133,7 @@ impl Config {
pub fn get_ldap_mail_field(&self) -> String {
match &self.ldap_mail_field {
Some(mail_field) => mail_field.clone(),
None => String::from("mail"),
None => String::from("mail").clone(),
}
}

View File

@ -1,57 +1,45 @@
extern crate anyhow;
extern crate ldap3;
extern crate pledge;
extern crate unveil;
use std::collections::HashSet;
use std::error::Error;
use std::thread::sleep;
use std::time::Duration;
use anyhow::Context as _;
use anyhow::Error as AnyError;
use anyhow::Result;
use ldap3::{DerefAliases, LdapConn, LdapConnSettings, Scope, SearchEntry, SearchOptions};
use pledge::pledge;
use unveil::unveil;
use ldap3::{DerefAliases, LdapConn, Scope, SearchEntry, SearchOptions};
mod bw_admin;
mod config;
mod vw_admin;
fn main() {
let config = config::Config::from_file();
let mut client = vw_admin::Client::new(
config.get_vaultwarden_url(),
config.get_vaultwarden_admin_token(),
config.get_vaultwarden_root_cert_file(),
let mut client = bw_admin::Client::new(
config.get_bitwarden_url().clone(),
config.get_bitwarden_admin_token().clone(),
);
unveil(config::get_config_path(), "r")
.or_else(unveil::Error::ignore_platform)
.expect("Could not unveil config file");
unveil("", "")
.or_else(unveil::Error::ignore_platform)
.expect("Could not disable further unveils");
pledge("dns flock inet rpath stdio tty", "")
.or_else(pledge::Error::ignore_platform)
.expect("Could not pledge permissions");
invite_users(&config, &mut client, config.get_ldap_sync_loop())
}
/// Invites new users to Bitwarden from LDAP
fn invite_users(config: &config::Config, client: &mut vw_admin::Client, start_loop: bool) {
if start_loop {
start_sync_loop(config, client).expect("Failed to start invite sync loop");
} else {
invite_from_ldap(config, client).expect("Failed to invite users");
if let Err(e) = invite_users(&config, &mut client, config.get_ldap_sync_loop()) {
panic!("{}", e);
}
}
/// Invites new users to Bitwarden from LDAP
fn invite_users(
config: &config::Config,
client: &mut bw_admin::Client,
start_loop: bool,
) -> Result<(), Box<dyn Error>> {
if start_loop {
start_sync_loop(config, client)?;
} else {
invite_from_ldap(config, client)?;
}
Ok(())
}
/// Creates set of email addresses for users that already exist in Bitwarden
fn get_existing_users(client: &mut vw_admin::Client) -> Result<HashSet<String>, AnyError> {
let all_users = client
.users()
.context("Could not get list of existing users from server")?;
fn get_existing_users(client: &mut bw_admin::Client) -> Result<HashSet<String>, Box<dyn Error>> {
let all_users = client.users()?;
let mut user_emails = HashSet::with_capacity(all_users.len());
for user in all_users {
user_emails.insert(user.get_email().to_lowercase());
@ -76,46 +64,40 @@ fn ldap_client(
ldap_url: String,
bind_dn: String,
bind_pw: String,
no_tls_verify: bool,
starttls: bool,
) -> Result<LdapConn, AnyError> {
let settings = LdapConnSettings::new()
.set_starttls(starttls)
.set_no_tls_verify(no_tls_verify);
let mut ldap = LdapConn::with_settings(settings, ldap_url.as_str())
.context("Failed to connect to LDAP server")?;
ldap.simple_bind(bind_dn.as_str(), bind_pw.as_str())
.context("Could not bind to LDAP server")?;
) -> Result<LdapConn, Box<dyn Error>> {
let ldap = LdapConn::new(ldap_url.as_str())?;
match ldap.simple_bind(bind_dn.as_str(), bind_pw.as_str()) {
_ => {}
};
Ok(ldap)
}
/// Retrieves search results from ldap
fn search_entries(config: &config::Config) -> Result<Vec<SearchEntry>, AnyError> {
let mut ldap = ldap_client(
fn search_entries(config: &config::Config) -> Result<Vec<SearchEntry>, Box<dyn Error>> {
let ldap = ldap_client(
config.get_ldap_url(),
config.get_ldap_bind_dn(),
config.get_ldap_bind_password(),
config.get_ldap_no_tls_verify(),
config.get_ldap_starttls(),
)
.context("LDAP client initialization failed")?;
);
if ldap.is_err() {
println!("Error: Could not bind to ldap server");
}
let mail_field = config.get_ldap_mail_field();
let fields = vec!["uid", "givenname", "sn", "cn", mail_field.as_str()];
// Something something error handling
let (results, _res) = ldap
// TODO: Something something error handling
let (results, _res) = ldap?
.with_search_options(SearchOptions::new().deref(DerefAliases::Always))
.search(
config.get_ldap_search_base_dn().as_str(),
&config.get_ldap_search_base_dn().as_str(),
Scope::Subtree,
config.get_ldap_search_filter().as_str(),
&config.get_ldap_search_filter().as_str(),
fields,
)
.context("LDAP search failure")?
.success()
.context("LDAP search usucessful")?;
)?
.success()?;
// Build list of entries
let mut entries = Vec::new();
@ -129,66 +111,53 @@ fn search_entries(config: &config::Config) -> Result<Vec<SearchEntry>, AnyError>
/// Invite all LDAP users to Bitwarden
fn invite_from_ldap(
config: &config::Config,
client: &mut vw_admin::Client,
) -> Result<(), AnyError> {
let existing_users =
get_existing_users(client).context("Failed to get existing users from server")?;
let mail_field = config.get_ldap_mail_field();
let mut num_users = 0;
client: &mut bw_admin::Client,
) -> Result<(), Box<dyn Error>> {
match get_existing_users(client) {
Ok(existing_users) => {
let mail_field = config.get_ldap_mail_field();
let mut num_users = 0;
for ldap_user in search_entries(config)? {
// Safely get first email from list of emails in field
if let Some(user_email) = ldap_user
.attrs
.get(mail_field.as_str())
.and_then(|l| (l.first()))
{
if existing_users.contains(&user_email.to_lowercase()) {
println!("User with email already exists: {}", user_email);
} else {
println!("Try to invite user: {}", user_email);
// TODO: Validate response
let _response = client.invite(user_email);
num_users = num_users + 1;
// println!("Invite response: {:?}", response);
}
} else {
println!("Warning: Email field, {:?}, not found on user", mail_field);
}
}
for ldap_user in search_entries(config)? {
//
// Safely get first email from list of emails in field
if let Some(user_email) = ldap_user
.attrs
.get(mail_field.as_str())
.and_then(|l| (l.first()))
{
if existing_users.contains(&user_email.to_lowercase()) {
println!("User with email already exists: {}", user_email);
} else {
println!("Try to invite user: {}", user_email);
client
.invite(user_email)
.context(format!("Failed to invite user {}", user_email))?;
num_users += 1;
}
} else {
match ldap_user.attrs.get("uid").and_then(|l| l.first()) {
Some(user_uid) => println!(
"Warning: Email field, {:?}, not found on user {}",
mail_field, user_uid
),
None => println!("Warning: Email field, {:?}, not found on user", mail_field),
}
// Maybe think about returning this value for some other use
println!("Sent invites to {} user(s).", num_users);
}
Err(e) => {
println!("Error: Failed to get existing users from Bitwarden");
return Err(e);
}
}
// Maybe think about returning this value for some other use
println!("Sent invites to {} user(s).", num_users);
Ok(())
}
/// Begin sync loop to invite LDAP users to Bitwarden
fn start_sync_loop(config: &config::Config, client: &mut vw_admin::Client) -> Result<(), AnyError> {
fn start_sync_loop(
config: &config::Config,
client: &mut bw_admin::Client,
) -> Result<(), Box<dyn Error>> {
let interval = Duration::from_secs(config.get_ldap_sync_interval_seconds());
let mut fail_count = 0;
let fail_limit = 5;
loop {
if let Err(err) = invite_from_ldap(config, client) {
println!(
"Error inviting users from ldap. Count {}: {:?}",
fail_count, err
);
fail_count += 1;
if fail_count > fail_limit {
return Err(err);
}
} else {
fail_count = 0
}
invite_from_ldap(config, client)?;
sleep(interval);
}
}

View File

@ -1,186 +0,0 @@
extern crate reqwest;
extern crate serde;
extern crate thiserror;
use reqwest::blocking::Response;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::time::{Duration, Instant};
use thiserror::Error;
const COOKIE_LIFESPAN: Duration = Duration::from_secs(20 * 60);
#[derive(Error, Debug)]
pub enum ResponseError {
#[error("vaultwarden error {0}")]
ApiError(String),
#[error("http error making request {0:?}")]
HttpError(#[from] reqwest::Error),
}
#[derive(Debug, Deserialize)]
pub struct User {
#[serde(alias = "Email")]
email: String,
#[serde(rename = "_status", alias = "_Status")]
status: i32,
}
impl User {
pub fn get_email(&self) -> String {
self.email.clone()
}
pub fn is_disabled(&self) -> bool {
// HACK: Magic number
self.status == 2
}
}
pub struct Client {
url: String,
admin_token: String,
root_cert_file: String,
cookie: Option<String>,
cookie_created: Option<Instant>,
}
impl Client {
/// Create new instance of client
pub fn new(url: String, admin_token: String, root_cert_file: String) -> Client {
Client {
url,
admin_token,
root_cert_file,
cookie: None,
cookie_created: None,
}
}
fn get_root_cert(&self) -> reqwest::Certificate {
let mut buf = Vec::new();
// read a local binary DER encoded certificate
File::open(&self.root_cert_file)
.expect("Could not open root cert file")
.read_to_end(&mut buf)
.expect("Could not read root cert file");
reqwest::Certificate::from_der(&buf).expect("Could not load DER root cert file")
}
fn get_http_client(&self) -> reqwest::blocking::Client {
let mut client =
reqwest::blocking::Client::builder().redirect(reqwest::redirect::Policy::none());
if !&self.root_cert_file.is_empty() {
let cert = self.get_root_cert();
client = client.add_root_certificate(cert);
}
client.build().expect("Failed to build http client")
}
/// Authenticate client
fn auth(&mut self) -> Result<Response, ResponseError> {
let cookie_created = Instant::now();
let client = self.get_http_client();
let result = client
.post(format!("{}{}", &self.url, "/admin/").as_str())
.form(&[("token", &self.admin_token)])
.send()?
.error_for_status()?;
let cookie = result
.headers()
.get(reqwest::header::SET_COOKIE)
.ok_or_else(|| {
ResponseError::ApiError(String::from("Could not read authentication cookie"))
})?;
self.cookie = cookie.to_str().map(String::from).ok();
self.cookie_created = Some(cookie_created);
Ok(result)
}
fn cookie_expired(&self) -> bool {
match &self.cookie {
Some(_) => self
.cookie_created
.map_or(true, |created| (created.elapsed() >= COOKIE_LIFESPAN)),
None => true,
}
}
/// Ensure that the client has a current auth cookie
fn ensure_auth(&mut self) -> Result<(), ResponseError> {
if self.cookie_expired() {
match self.auth() {
Ok(_) => Ok(()),
Err(err) => Err(err),
}?
}
Ok(())
}
/// Make an authenticated GET to Bitwarden Admin
fn get(&mut self, path: &str) -> Result<Response, ResponseError> {
self.ensure_auth()?;
let url = format!("{}/admin{}", &self.url, path);
let client = self.get_http_client();
let request = client.get(url.as_str()).header(
reqwest::header::COOKIE,
self.cookie
.as_ref()
.expect("No cookie found to add to header")
.clone(),
);
let response = request.send()?.error_for_status()?;
Ok(response)
}
/// Make authenticated POST to Bitwarden Admin with JSON data
fn post(
&mut self,
path: &str,
json: &HashMap<String, String>,
) -> Result<Response, ResponseError> {
self.ensure_auth()?;
let url = format!("{}/admin{}", &self.url, path);
let client = self.get_http_client();
let request = client.post(url.as_str()).json(&json).header(
reqwest::header::COOKIE,
self.cookie
.as_ref()
.expect("No cookie found to add to header")
.clone(),
);
let response = request.send()?.error_for_status()?;
Ok(response)
}
/// Invite user with provided email
pub fn invite(&mut self, email: &str) -> Result<Response, ResponseError> {
let mut json = HashMap::new();
json.insert("email".to_string(), email.to_string());
self.post("/invite", &json)
}
/// Get all existing users
pub fn users(&mut self) -> Result<Vec<User>, ResponseError> {
let all_users: Vec<User> = self.get("/users")?.json()?;
Ok(all_users)
}
}