mirror of
https://github.com/ViViDboarder/bitwarden_rs_ldap.git
synced 2024-11-05 03:06:26 +00:00
Compare commits
157 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
45252896a6 | ||
|
d849ddf3a4 | ||
|
e07191758b | ||
|
a33e013000 | ||
|
964924b111 | ||
|
f34b3c8390 | ||
|
e473ad81f7 | ||
|
1a287af481 | ||
|
95ce406c69 | ||
|
6ebe317986 | ||
|
a0c12dbd72 | ||
|
6ef27773ba | ||
|
46b27acd17 | ||
|
c8e862b3a0 | ||
|
70eb8059d4 | ||
|
ef495651e2 | ||
|
36a4f46b92 | ||
|
ef0c962946 | ||
|
596b2891c2 | ||
|
1608d994de | ||
|
a699ec878a | ||
|
951ffaeb5f | ||
|
7ae5ebbb4b | ||
|
3f2de06341 | ||
|
74e3fdcc62 | ||
|
7a69296d17 | ||
|
1a2e611c47 | ||
|
de1b43d372 | ||
|
0f3d7c6b84 | ||
|
cf147a2d6d | ||
|
a75afc6c2d | ||
|
571e5cf113 | ||
|
c28468a37e | ||
|
1dc931b26c | ||
|
ba7658c466 | ||
|
02270621c8 | ||
|
ab6b0ca38a | ||
|
847f6d48a6 | ||
|
7ec6f7f63e | ||
|
ceaa239d53 | ||
|
f6fbd1ec1d | ||
|
201266ce3c | ||
|
d1fcf2f6d2 | ||
|
fc7f902984 | ||
|
7baa18110e | ||
|
10f5f5edc6 | ||
|
775e8777cc | ||
|
79f7ee099c | ||
|
e0c3582bdb | ||
|
86e7a827f6 | ||
|
0feac2d904 | ||
|
5f63df3dee | ||
|
e80dce8042 | ||
|
0a0d9ed69f | ||
|
44179f886a | ||
|
4b5c9f36c4 | ||
|
8e8586bc90 | ||
|
a96c8d91b8 | ||
|
3dda190c3a | ||
|
f366b20fef | ||
|
78fd8c4248 | ||
|
10aeba1ce8 | ||
|
6882de79af | ||
|
5209fb6c8d | ||
|
02f6383444 | ||
|
8f897713fd | ||
|
fcc02354ca | ||
|
50cc9cf2cb | ||
|
e0005fbd38 | ||
|
30974696be | ||
|
f7b7f28e77 | ||
|
1111b2a3df | ||
|
9c6c339dc8 | ||
|
74c5ec4e72 | ||
|
3faf747817 | ||
|
98a276b644 | ||
|
b735760315 | ||
|
e4b9c19215 | ||
|
66f35bff6d | ||
|
11ab2b92c2 | ||
|
11a0c3902b | ||
|
ce6cb783d0 | ||
|
b2dd2d42bc | ||
|
274adaff9d | ||
|
97a64c7247 | ||
|
eb204793d3 | ||
|
dd92cc509a | ||
|
8ab22b5788 | ||
|
4d71304f91 | ||
|
7514928afe | ||
|
ef52691c63 | ||
|
8992de2e6b | ||
|
5d41a1532f | ||
|
13760d7e6f | ||
|
71aa764522 | ||
|
653891102b | ||
|
2d5656bd22 | ||
|
b134df4653 | ||
|
f1b229ebc3 | ||
|
829ae70a9b | ||
|
fce9d3064c | ||
|
741ece568a | ||
|
4c4112cd82 | ||
|
3a34f70385 | ||
|
1c69bd6936 | ||
|
9e7f4a7820 | ||
|
3c83cde044 | ||
|
07b05844b3 | ||
|
0c2c2eae0d | ||
|
bc390527bd | ||
|
d9468b0105 | ||
|
d29d7ffb07 | ||
|
ade0192e11 | ||
|
40113a71d4 | ||
|
55e3536c70 | ||
|
d21c835411 | ||
|
0d49839189 | ||
|
60699e6f5a | ||
|
85511d8dc9 | ||
|
db03a5299f | ||
|
96d7483d31 | ||
|
94d82a827f | ||
|
21a50a1ee0 | ||
|
5682eab8dc | ||
|
556a8edbe7 | ||
|
0ea5eadd71 | ||
|
5fd947f127 | ||
|
f25278ea3c | ||
|
5238bc1b60 | ||
|
08dcdd2bba | ||
|
a1a69617e3 | ||
|
80a16664e8 | ||
|
13e0775d56 | ||
|
77919aa3d4 | ||
|
c090fb5a52 | ||
|
231f9036fb | ||
|
adf460c05e | ||
|
8be4057e3b | ||
|
2f6ecc6860 | ||
|
a0bb9152ee | ||
|
e05929765c | ||
|
8d0f48f00c | ||
|
1d07357a97 | ||
|
d5dd5903b1 | ||
|
c2ae9c1e93 | ||
|
b47d279ca9 | ||
|
402fff84fd | ||
|
be227a638d | ||
|
b04ee3a60a | ||
|
d19a0efae7 | ||
|
d41b93d3b9 | ||
|
7b3a814ce7 | ||
|
f5b94ee792 | ||
|
91d70e6bb8 | ||
|
acf8165bed | ||
|
78be951474 | ||
|
9d7f226c8e |
@ -1,2 +1,3 @@
|
|||||||
README.md
|
README.md
|
||||||
target/
|
target/
|
||||||
|
scripts/
|
||||||
|
22
.github/dependabot.yml
vendored
Normal file
22
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# 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]
|
35
.github/workflows/cargo.yml
vendored
Normal file
35
.github/workflows/cargo.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
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
|
53
.github/workflows/docker.yml
vendored
Normal file
53
.github/workflows/docker.yml
vendored
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
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
2
.gitignore
vendored
@ -6,4 +6,4 @@
|
|||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
# Ignore config while developing
|
# Ignore config while developing
|
||||||
config.toml
|
./config.toml
|
||||||
|
25
.pre-commit-config.yaml
Normal file
25
.pre-commit-config.yaml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
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
|
2248
Cargo.lock
generated
2248
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@ -1,12 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bitwarden_rs_ldap"
|
name = "vaultwarden_ldap"
|
||||||
version = "0.1.2"
|
version = "2.0.2"
|
||||||
authors = ["ViViDboarder <vividboarder@gmail.com>"]
|
authors = ["ViViDboarder <vividboarder@gmail.com>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ldap3 = "0.6"
|
ldap3 = "0.11"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
toml = "0.5"
|
toml = "0.8"
|
||||||
reqwest = "0.9"
|
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
envy = "0.4.1"
|
||||||
|
pledge = "0.4.2"
|
||||||
|
unveil = "0.3.2"
|
||||||
|
30
Dockerfile
30
Dockerfile
@ -1,19 +1,25 @@
|
|||||||
FROM rust:1.33
|
FROM rust:1.82 as builder
|
||||||
|
|
||||||
WORKDIR /usr/src/
|
WORKDIR /usr/src/
|
||||||
RUN USER=root cargo new --bin bitwarden_rs_ldap
|
RUN USER=root cargo new --bin vaultwarden_ldap
|
||||||
WORKDIR /usr/src/bitwarden_rs_ldap
|
WORKDIR /usr/src/vaultwarden_ldap
|
||||||
|
|
||||||
# Compile dependencies
|
# Compile dependencies
|
||||||
COPY ./Cargo.toml ./Cargo.toml
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY ./Cargo.lock ./Cargo.lock
|
RUN cargo build --locked --release
|
||||||
RUN cargo build --release
|
|
||||||
# Remove temp src
|
|
||||||
RUN rm src/*.rs
|
|
||||||
|
|
||||||
|
# Remove bins to make sure we rebuild
|
||||||
|
# hadolint ignore=DL3059
|
||||||
|
RUN rm ./target/release/deps/vaultwarden_ldap*
|
||||||
# Copy source and install
|
# Copy source and install
|
||||||
COPY ./src ./src
|
COPY src ./src
|
||||||
RUN rm ./target/release/deps/bitwarden_rs_ldap*
|
RUN cargo build --release
|
||||||
RUN cargo install --path .
|
|
||||||
|
|
||||||
CMD ["bitwarden_rs_ldap"]
|
# 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/
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/vaultwarden_ldap"]
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
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
Normal file
101
Makefile
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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) .
|
45
README.md
45
README.md
@ -1,11 +1,11 @@
|
|||||||
# bitwarden_rs_ldap
|
# vaultwarden_ldap
|
||||||
A simple LDAP connector for [bitwarden_rs](https://github.com/dani-garcia/bitwarden_rs)
|
LDAP user invites for [vaultwarden](https://github.com/dani-garcia/vaultwarden)
|
||||||
|
|
||||||
After configuring, simply run `bitwarden_rs_ldap` and it will invite any users it finds in LDAP to your `bitwarden_rs` instance.
|
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/).
|
||||||
|
|
||||||
## Deploying
|
## 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/bitwarden_rs_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/vaultwarden_ldap`.
|
||||||
|
|
||||||
Make sure to populate and mount your `config.toml`!
|
Make sure to populate and mount your `config.toml`!
|
||||||
|
|
||||||
@ -17,11 +17,14 @@ Configuration values are as follows:
|
|||||||
|
|
||||||
|Name|Type|Optional|Description|
|
|Name|Type|Optional|Description|
|
||||||
|----|----|--------|-----------|
|
|----|----|--------|-----------|
|
||||||
|`bitwarden_url`|String||The root URL for accessing `bitwarden_rs`. Eg: `https://bw.example.com`|
|
|`vaultwarden_url`|String||The root URL for accessing `vaultwarden`. Eg: `https://vw.example.com`|
|
||||||
|`bitwarden_admin_token`|String||The value passed as `ADMIN_TOKEN` to `bitwarden_rs`|
|
|`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`|
|
||||||
|`ldap_host`|String||The hostname or IP address for your ldap server|
|
|`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_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. Defaults to `false`|
|
|`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_port`|Integer|Optional|Port used to connect to the LDAP server. This will default to 389 or 636, depending on your SSL settings|
|
|`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_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.|
|
|`ldap_bind_password`|String||The password for the provided bind user.|
|
||||||
@ -31,12 +34,31 @@ 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_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`|
|
|`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
|
## Testing
|
||||||
|
|
||||||
All testing is manual right now. First step is to set up Bitwarden and the LDAP server.
|
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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d bitwarden ldap ldap_admin
|
docker-compose up -d vaultwarden ldap ldap_admin
|
||||||
```
|
```
|
||||||
|
|
||||||
1. After that, open the admin portal on http://localhost:8001 and log in using the default account info:
|
1. After that, open the admin portal on http://localhost:8001 and log in using the default account info:
|
||||||
@ -60,7 +82,6 @@ From there you can set up your test group and users.
|
|||||||
docker-compose up ldap_sync
|
docker-compose up ldap_sync
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future
|
Alternately, you can bootstrap some of this by running:
|
||||||
|
|
||||||
* Any kind of proper logging
|
docker-compose -f docker-compose.yml -f itest/docker-compose.itest.yml up --build
|
||||||
* Tests
|
|
||||||
|
@ -1,29 +1,32 @@
|
|||||||
version: '3'
|
---
|
||||||
services:
|
services:
|
||||||
ldap_sync:
|
ldap_sync:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
# dockerfile: Dockerfile.alpine
|
dockerfile: Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- ./example.config.toml:/usr/src/bitwarden_rs_ldap/config.toml:ro
|
- ./example.config.toml:/config.toml:ro
|
||||||
|
# ./root.cert:/usr/src/vaultwarden_ldap/root.cert:ro
|
||||||
environment:
|
environment:
|
||||||
RUST_BACKTRACE: 1
|
CONFIG_PATH: /config.toml
|
||||||
|
RUST_BACKTRACE: full
|
||||||
|
depends_on:
|
||||||
|
- vaultwarden
|
||||||
|
- ldap
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
bitwarden:
|
vaultwarden:
|
||||||
image: mprasil/bitwarden
|
image: vaultwarden/server
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
environment:
|
environment:
|
||||||
ADMIN_TOKEN: admin
|
ADMIN_TOKEN: admin
|
||||||
SIGNUPS_ALLOWED: 'false'
|
SIGNUPS_ALLOWED: 'false'
|
||||||
INVITATIONS_ALLOWED: 'true'
|
INVITATIONS_ALLOWED: 'true'
|
||||||
|
I_REALLY_WANT_VOLATILE_STORAGE: 'true'
|
||||||
|
|
||||||
ldap:
|
ldap:
|
||||||
image: osixia/openldap
|
image: osixia/openldap
|
||||||
ports:
|
|
||||||
- 389:389
|
|
||||||
- 636:636
|
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/ldap
|
- /var/lib/ldap
|
||||||
- /etc/ldap/slapd.d
|
- /etc/ldap/slapd.d
|
||||||
@ -39,3 +42,5 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PHPLDAPADMIN_HTTPS: 'false'
|
PHPLDAPADMIN_HTTPS: 'false'
|
||||||
PHPLDAPADMIN_LDAP_HOSTS: ldap
|
PHPLDAPADMIN_LDAP_HOSTS: ldap
|
||||||
|
depends_on:
|
||||||
|
- ldap
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
bitwarden_url = "http://bitwarden:80"
|
vaultwarden_url = "http://vaultwarden:80"
|
||||||
bitwarden_admin_token = "admin"
|
vaultwarden_admin_token = "admin"
|
||||||
ldap_host = "ldap"
|
ldap_host = "ldap"
|
||||||
ldap_bind_dn = "cn=admin,dc=example,dc=org"
|
ldap_bind_dn = "cn=admin,dc=example,dc=org"
|
||||||
ldap_bind_password = "admin"
|
ldap_bind_password = "admin"
|
||||||
|
8
itest/config.toml
Normal file
8
itest/config.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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
|
20
itest/docker-compose.itest-env.yml
Normal file
20
itest/docker-compose.itest-env.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
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
|
12
itest/docker-compose.itest.yml
Normal file
12
itest/docker-compose.itest.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
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
|
49
itest/ldif/50-seed-user.ldif
Normal file
49
itest/ldif/50-seed-user.ldif
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# 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
|
8
scripts/check-version.sh
Executable file
8
scripts/check-version.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#! /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
157
src/bw_admin.rs
@ -1,157 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -17,18 +17,41 @@ pub fn get_config_path() -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads configuration from file and panics if it can't
|
// Tries to read configuration from file, failing that from the environment,
|
||||||
|
// panics if it can't
|
||||||
pub fn read_config() -> Config {
|
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 config_path = get_config_path();
|
||||||
|
|
||||||
let contents = fs::read_to_string(&config_path).unwrap_or_else(|_| {
|
let contents = fs::read_to_string(&config_path)
|
||||||
panic!("Failed to open config file at {}", config_path);
|
.map_err(|err| format!("Failed to open config file at {}: {}", config_path, err))?;
|
||||||
});
|
let config: Config = toml::from_str(contents.as_str())
|
||||||
let config: Config = toml::from_str(contents.as_str()).unwrap_or_else(|_| {
|
.map_err(|err| format!("Failed to parse config file at {}: {}", config_path, err))?;
|
||||||
panic!("Failed to parse config file at {}", config_path);
|
|
||||||
});
|
|
||||||
|
|
||||||
config
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -36,13 +59,16 @@ pub fn read_config() -> Config {
|
|||||||
/// Contains all config values for LDAP syncing
|
/// Contains all config values for LDAP syncing
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
// Bitwarden connection config
|
// Bitwarden connection config
|
||||||
bitwarden_url: String,
|
vaultwarden_url: String,
|
||||||
bitwarden_admin_token: String,
|
vaultwarden_admin_token: String,
|
||||||
|
vaultwarden_root_cert_file: Option<String>,
|
||||||
// LDAP Connection config
|
// LDAP Connection config
|
||||||
ldap_host: String,
|
ldap_host: String,
|
||||||
ldap_scheme: Option<String>,
|
ldap_scheme: Option<String>,
|
||||||
ldap_ssl: Option<bool>,
|
ldap_ssl: Option<bool>,
|
||||||
|
ldap_starttls: Option<bool>,
|
||||||
ldap_port: Option<u16>,
|
ldap_port: Option<u16>,
|
||||||
|
ldap_no_tls_verify: Option<bool>,
|
||||||
// LDAP auth config
|
// LDAP auth config
|
||||||
ldap_bind_dn: String,
|
ldap_bind_dn: String,
|
||||||
ldap_bind_password: Pass,
|
ldap_bind_password: Pass,
|
||||||
@ -63,12 +89,19 @@ impl Config {
|
|||||||
read_config()
|
read_config()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_bitwarden_url(&self) -> String {
|
pub fn get_vaultwarden_url(&self) -> String {
|
||||||
self.bitwarden_url.clone()
|
self.vaultwarden_url.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_bitwarden_admin_token(&self) -> String {
|
pub fn get_vaultwarden_admin_token(&self) -> String {
|
||||||
self.bitwarden_admin_token.clone()
|
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_ldap_url(&self) -> String {
|
pub fn get_ldap_url(&self) -> String {
|
||||||
@ -101,6 +134,14 @@ impl Config {
|
|||||||
self.ldap_ssl.unwrap_or(false)
|
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 {
|
pub fn get_ldap_port(&self) -> u16 {
|
||||||
match self.ldap_port {
|
match self.ldap_port {
|
||||||
Some(ldap_port) => ldap_port,
|
Some(ldap_port) => ldap_port,
|
||||||
@ -133,7 +174,7 @@ impl Config {
|
|||||||
pub fn get_ldap_mail_field(&self) -> String {
|
pub fn get_ldap_mail_field(&self) -> String {
|
||||||
match &self.ldap_mail_field {
|
match &self.ldap_mail_field {
|
||||||
Some(mail_field) => mail_field.clone(),
|
Some(mail_field) => mail_field.clone(),
|
||||||
None => String::from("mail").clone(),
|
None => String::from("mail"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
181
src/main.rs
181
src/main.rs
@ -1,45 +1,57 @@
|
|||||||
|
extern crate anyhow;
|
||||||
extern crate ldap3;
|
extern crate ldap3;
|
||||||
|
extern crate pledge;
|
||||||
|
extern crate unveil;
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::error::Error;
|
|
||||||
use std::thread::sleep;
|
use std::thread::sleep;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use ldap3::{DerefAliases, LdapConn, Scope, SearchEntry, SearchOptions};
|
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;
|
||||||
|
|
||||||
mod bw_admin;
|
|
||||||
mod config;
|
mod config;
|
||||||
|
mod vw_admin;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let config = config::Config::from_file();
|
let config = config::Config::from_file();
|
||||||
let mut client = bw_admin::Client::new(
|
let mut client = vw_admin::Client::new(
|
||||||
config.get_bitwarden_url().clone(),
|
config.get_vaultwarden_url(),
|
||||||
config.get_bitwarden_admin_token().clone(),
|
config.get_vaultwarden_admin_token(),
|
||||||
|
config.get_vaultwarden_root_cert_file(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(e) = invite_users(&config, &mut client, config.get_ldap_sync_loop()) {
|
unveil(config::get_config_path(), "r")
|
||||||
panic!("{}", e);
|
.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
|
/// Invites new users to Bitwarden from LDAP
|
||||||
fn invite_users(
|
fn invite_users(config: &config::Config, client: &mut vw_admin::Client, start_loop: bool) {
|
||||||
config: &config::Config,
|
|
||||||
client: &mut bw_admin::Client,
|
|
||||||
start_loop: bool,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
if start_loop {
|
if start_loop {
|
||||||
start_sync_loop(config, client)?;
|
start_sync_loop(config, client).expect("Failed to start invite sync loop");
|
||||||
} else {
|
} else {
|
||||||
invite_from_ldap(config, client)?;
|
invite_from_ldap(config, client).expect("Failed to invite users");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates set of email addresses for users that already exist in Bitwarden
|
/// Creates set of email addresses for users that already exist in Bitwarden
|
||||||
fn get_existing_users(client: &mut bw_admin::Client) -> Result<HashSet<String>, Box<dyn Error>> {
|
fn get_existing_users(client: &mut vw_admin::Client) -> Result<HashSet<String>, AnyError> {
|
||||||
let all_users = client.users()?;
|
let all_users = client
|
||||||
|
.users()
|
||||||
|
.context("Could not get list of existing users from server")?;
|
||||||
let mut user_emails = HashSet::with_capacity(all_users.len());
|
let mut user_emails = HashSet::with_capacity(all_users.len());
|
||||||
for user in all_users {
|
for user in all_users {
|
||||||
user_emails.insert(user.get_email().to_lowercase());
|
user_emails.insert(user.get_email().to_lowercase());
|
||||||
@ -64,40 +76,46 @@ fn ldap_client(
|
|||||||
ldap_url: String,
|
ldap_url: String,
|
||||||
bind_dn: String,
|
bind_dn: String,
|
||||||
bind_pw: String,
|
bind_pw: String,
|
||||||
) -> Result<LdapConn, Box<dyn Error>> {
|
no_tls_verify: bool,
|
||||||
let ldap = LdapConn::new(ldap_url.as_str())?;
|
starttls: bool,
|
||||||
match ldap.simple_bind(bind_dn.as_str(), bind_pw.as_str()) {
|
) -> 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")?;
|
||||||
|
|
||||||
Ok(ldap)
|
Ok(ldap)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves search results from ldap
|
/// Retrieves search results from ldap
|
||||||
fn search_entries(config: &config::Config) -> Result<Vec<SearchEntry>, Box<dyn Error>> {
|
fn search_entries(config: &config::Config) -> Result<Vec<SearchEntry>, AnyError> {
|
||||||
let ldap = ldap_client(
|
let mut ldap = ldap_client(
|
||||||
config.get_ldap_url(),
|
config.get_ldap_url(),
|
||||||
config.get_ldap_bind_dn(),
|
config.get_ldap_bind_dn(),
|
||||||
config.get_ldap_bind_password(),
|
config.get_ldap_bind_password(),
|
||||||
);
|
config.get_ldap_no_tls_verify(),
|
||||||
|
config.get_ldap_starttls(),
|
||||||
if ldap.is_err() {
|
)
|
||||||
println!("Error: Could not bind to ldap server");
|
.context("LDAP client initialization failed")?;
|
||||||
}
|
|
||||||
|
|
||||||
let mail_field = config.get_ldap_mail_field();
|
let mail_field = config.get_ldap_mail_field();
|
||||||
let fields = vec!["uid", "givenname", "sn", "cn", mail_field.as_str()];
|
let fields = vec!["uid", "givenname", "sn", "cn", mail_field.as_str()];
|
||||||
|
|
||||||
// TODO: Something something error handling
|
// Something something error handling
|
||||||
let (results, _res) = ldap?
|
let (results, _res) = ldap
|
||||||
.with_search_options(SearchOptions::new().deref(DerefAliases::Always))
|
.with_search_options(SearchOptions::new().deref(DerefAliases::Always))
|
||||||
.search(
|
.search(
|
||||||
&config.get_ldap_search_base_dn().as_str(),
|
config.get_ldap_search_base_dn().as_str(),
|
||||||
Scope::Subtree,
|
Scope::Subtree,
|
||||||
&config.get_ldap_search_filter().as_str(),
|
config.get_ldap_search_filter().as_str(),
|
||||||
fields,
|
fields,
|
||||||
)?
|
)
|
||||||
.success()?;
|
.context("LDAP search failure")?
|
||||||
|
.success()
|
||||||
|
.context("LDAP search usucessful")?;
|
||||||
|
|
||||||
// Build list of entries
|
// Build list of entries
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
@ -111,53 +129,66 @@ fn search_entries(config: &config::Config) -> Result<Vec<SearchEntry>, Box<dyn E
|
|||||||
/// Invite all LDAP users to Bitwarden
|
/// Invite all LDAP users to Bitwarden
|
||||||
fn invite_from_ldap(
|
fn invite_from_ldap(
|
||||||
config: &config::Config,
|
config: &config::Config,
|
||||||
client: &mut bw_admin::Client,
|
client: &mut vw_admin::Client,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), AnyError> {
|
||||||
match get_existing_users(client) {
|
let existing_users =
|
||||||
Ok(existing_users) => {
|
get_existing_users(client).context("Failed to get existing users from server")?;
|
||||||
let mail_field = config.get_ldap_mail_field();
|
let mail_field = config.get_ldap_mail_field();
|
||||||
let mut num_users = 0;
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maybe think about returning this value for some other use
|
for ldap_user in search_entries(config)? {
|
||||||
println!("Sent invites to {} user(s).", num_users);
|
//
|
||||||
}
|
// Safely get first email from list of emails in field
|
||||||
Err(e) => {
|
if let Some(user_email) = ldap_user
|
||||||
println!("Error: Failed to get existing users from Bitwarden");
|
.attrs
|
||||||
return Err(e);
|
.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);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begin sync loop to invite LDAP users to Bitwarden
|
/// Begin sync loop to invite LDAP users to Bitwarden
|
||||||
fn start_sync_loop(
|
fn start_sync_loop(config: &config::Config, client: &mut vw_admin::Client) -> Result<(), AnyError> {
|
||||||
config: &config::Config,
|
|
||||||
client: &mut bw_admin::Client,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let interval = Duration::from_secs(config.get_ldap_sync_interval_seconds());
|
let interval = Duration::from_secs(config.get_ldap_sync_interval_seconds());
|
||||||
|
let mut fail_count = 0;
|
||||||
|
let fail_limit = 5;
|
||||||
loop {
|
loop {
|
||||||
invite_from_ldap(config, client)?;
|
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
|
||||||
|
}
|
||||||
|
|
||||||
sleep(interval);
|
sleep(interval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
186
src/vw_admin.rs
Normal file
186
src/vw_admin.rs
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user