Compare commits

...
10 Commits
Author SHA1 Message Date
barrypandClaude Fable 5.1 7ba8629e47 Bump Dockerfile to rust 1.98.1 and alpine 3.24.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:26:59 -07:00
barrypandClaude Fable 5.1 2919037d3b Update argon2 to 0.6
The only API change affecting us is that PasswordHash moved to
password_hash::phc; use the argon2 crate's top-level re-export instead.
Existing argon2id hashes in password files still verify.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:22:39 -07:00
barrypandClaude Fable 5.1 44c8e6b6f8 Updated rust libraries
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:21:53 -07:00
barrypandClaude Fable 5.1 1467b71037 Make shutdown event-driven and address review findings
Replace the non-blocking accept + 50ms poll with a plain blocking
accept(). A signal-hook thread waits for SIGTERM/SIGINT, sets a flag,
and wakes accept() with a throwaway connection to the socket.

On shutdown the listener is closed and the socket file unlinked before
the grace period, so clients that connect late fail fast with ENOENT
instead of queueing and being reset. In-flight handlers are tracked by
a counter + Condvar rather than a Vec of JoinHandles.

Accepted streams now get a 5s read/write timeout so a silent client
cannot pin a handler thread or stretch every shutdown to the full
grace period. Transient accept() errors are logged and retried instead
of exiting the daemon. A second SIGTERM/SIGINT during the grace period
forces an immediate exit with status 1.

Tests: wrap the child in the kill-on-drop guard immediately after
spawn, drop the redundant wall-clock assertion, and add a case for a
client that connects but never sends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:19:36 -07:00
barrypandClaude Fable 5.1 08aace5428 Bump version to 0.4.0 and add --version to both binaries
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:13:12 -07:00
barrypandClaude Fable 5.1 1749d9c08e Add CLAUDE.md with build commands and architecture notes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:08:11 -07:00
barrypandClaude Fable 5.1 ab163ca5e6 Fix clippy warnings in repository tests
Use assert!/assert!(!..) instead of assert_eq! against literal bools,
and drop a needless borrow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:06:28 -07:00
barrypandClaude Fable 5.1 6385be0890 Handle SIGTERM/SIGINT for a graceful shutdown
Running as PID 1 in a container, the daemon silently dropped SIGTERM,
so `docker stop` always hit the 10s timeout and SIGKILL, and host
reboots waited on it.

The accept loop now polls a non-blocking listener and a shutdown flag
registered via signal-hook. On SIGTERM or SIGINT it stops accepting,
gives in-flight requests up to 3s to finish, removes the socket file,
and exits with status 0. Log lines mark signal receipt and completion.

Adds integration tests that spawn the binary, perform an auth
round-trip, signal it, and assert a clean exit and socket removal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
2026-09-12 13:05:20 -07:00
Barry Pederson 1de0fa16db Add some logging so that with the env variable RUST_LOG=debug or similar, there is timestamped output to stderr 2026-02-03 15:57:11 -08:00
Barry Pederson 83d437c9ce Update rust and alpine versions in Dockerfile 2026-02-03 13:15:06 -08:00
12 changed files with 773 additions and 119 deletions
+77
View File
@@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A minimal Rust reimplementation of the Cyrus `saslauthd` daemon. It listens on a Unix
socket, speaks the saslauthd wire protocol, and checks credentials against per-user
password files in a directory. In production it runs as PID 1 in an Alpine container
(`pobox_saslauthd`), and Cyrus IMAP / Exim authenticate through its socket.
## Commands
```sh
cargo build --workspace
cargo test --workspace # unit + integration tests
cargo test -p server --test shutdown_test # one integration test file
cargo test -p server it_checks_argon2 # one test by name
cargo clippy --workspace --all-targets # plain `cargo clippy` skips test code
cargo run -p server -- -f /tmp/saslauthd.sock -p ./test_passwords
RUST_LOG=info cargo run -p server # env_logger; info shows auth results + shutdown
cargo run -p client -- -f /tmp/saslauthd.sock bp some-rad-password # manual round-trip
docker build -t saslauthd . # musl static build, targets x86_64 only
```
Tests spawn the real `saslauthd` binary on a socket under the OS temp dir and signal it
with `kill`, so they are Unix-only and rely on `test_passwords/bp` at the repo root.
## Architecture
Cargo workspace (edition 2024) with three crates:
- `common` — the wire protocol shared by server and client. `io.rs` reads/writes
length-prefixed strings (u16 big-endian length + bytes). `request.rs` is the four-field
request (userid, password, service, realm) in that order.
- `server` — the `saslauthd` binary. `Server` owns the `UnixListener`, spawns one thread
per connection running `Handler`, which parses a `Request`, asks `PasswordDirectory`
whether it is valid, and writes back `OK` or `NO` as a length-prefixed string.
- `client``saslauthd_test_client`, a tiny CLI for exercising a running server.
Top-level `tests/` holds the `common` crate's protocol tests; `server/tests/` holds the
process-level shutdown tests.
### Authentication model (`server/src/repository.rs`)
The password directory contains one file per userid. Blank lines and `#` comments are
ignored. Each remaining line is either:
- a plain-text password, matched exactly, or
- `[key]<argon2 PHC hash>`, matched only when the client sends the password as
`[key]<plaintext>` and the plaintext verifies against the hash for that key.
The client's password prefix decides which path is taken. Userids containing `/` are
rejected before touching the filesystem. `service` and `realm` are read but ignored.
### Server lifecycle (`server/src/server.rs`)
Startup removes any stale file at the socket path, binds, and chmods the socket 0777.
The accept loop is a plain blocking `accept()`. A `signal-hook` thread waits for
SIGTERM/SIGINT; on the first one it sets a shutdown flag and makes a throwaway
connection to the socket to wake the accept loop, which then closes the listener,
unlinks the socket file, and waits up to 3 s for in-flight handler threads before
exiting 0. A second signal exits immediately with status 1. Accepted streams get a 5 s
read/write timeout so a silent client cannot pin a thread. This matters because as
container PID 1 the process gets no default signal handling; without it `docker stop`
hangs for 10 s and host reboots stall.
CLI options live in `options.rs` as a `LazyLock<Opt>` (clap derive) and are read from
anywhere via `OPTIONS`.
## Constraints
- Do not change the wire protocol or the `--socket-name` / `--password-dir` flags;
Cyrus and Exim depend on both.
- The Dockerfile hardcodes `x86_64-unknown-linux-musl`. On an arm64 Mac the cross-build
segfaults under qemu; verify container behaviour by substituting
`aarch64-unknown-linux-musl` in a copy of the Dockerfile instead.
Generated
+352 -78
View File
@@ -3,10 +3,19 @@
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -19,15 +28,15 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.13"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@@ -54,9 +63,9 @@ dependencies = [
[[package]]
name = "argon2"
version = "0.5.3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
@@ -71,28 +80,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "blake2"
version = "0.10.6"
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "blake2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"generic-array",
"hybrid-array",
]
[[package]]
name = "clap"
version = "4.5.57"
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
@@ -100,9 +121,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.57"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
@@ -112,78 +133,157 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.55"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 3.0.5",
]
[[package]]
name = "clap_lex"
version = "0.7.7"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "client"
version = "0.3.1"
version = "0.4.0"
dependencies = [
"clap",
"common",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "common"
version = "0.3.1"
version = "0.4.0"
[[package]]
name = "cpufeatures"
version = "0.2.17"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"generic-array",
"typenum",
"hybrid-array",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "digest"
version = "0.10.7"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
"ctutils",
]
[[package]]
name = "generic-array"
version = "0.14.7"
name = "env_filter"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"typenum",
"version_check",
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
@@ -192,6 +292,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -199,10 +308,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "libc"
version = "0.2.180"
name = "jiff"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-core"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell_polyfill"
@@ -212,46 +370,143 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "password-hash"
version = "0.5.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"getrandom",
"phc",
]
[[package]]
name = "phc"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
dependencies = [
"base64ct",
"rand_core",
"subtle",
"ctutils",
"getrandom",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand_core"
version = "0.6.4"
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "server"
version = "0.3.1"
version = "0.4.0"
dependencies = [
"argon2",
"clap",
"common",
"env_logger",
"log",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
@@ -260,17 +515,11 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.114"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
@@ -278,16 +527,47 @@ dependencies = [
]
[[package]]
name = "typenum"
version = "1.19.0"
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.22"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
@@ -295,12 +575,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
+1 -1
View File
@@ -6,7 +6,7 @@ members = [
]
[workspace.package]
version = "0.3.1"
version = "0.4.0"
edition = "2024"
[workspace.dependencies]
+2 -2
View File
@@ -1,6 +1,6 @@
################
##### Builder
FROM rust:1.88.0-slim AS builder
FROM rust:1.98.1-slim AS builder
WORKDIR /usr/src
@@ -20,7 +20,7 @@ RUN cargo build --package server --target x86_64-unknown-linux-musl --release
################
##### Runtime
FROM alpine:3.22.0 AS runtime
FROM alpine:3.24.1 AS runtime
# Copy application binary from builder image
COPY --from=builder /usr/src/target/x86_64-unknown-linux-musl/release/saslauthd /usr/local/bin
+1
View File
@@ -3,6 +3,7 @@ use std::path::PathBuf;
use std::sync::LazyLock;
#[derive(Parser, Clone, Debug)]
#[command(name = "saslauthd_test_client", version)]
/// saslauthd test client
pub struct Opt {
#[arg(
+5 -2
View File
@@ -6,8 +6,11 @@ edition.workspace = true
[dependencies]
clap = { workspace = true }
common = { workspace = true }
argon2 = "0.5.3"
argon2 = "0.6.0"
env_logger = "0.11.8"
log = "0.4.29"
signal-hook = "0.4"
[[bin]]
name = "saslauthd"
path = "src/main.rs"
path = "src/main.rs"
+7 -4
View File
@@ -1,5 +1,6 @@
use crate::repository::PasswordDirectory;
use common::request::Request;
use log::{error, info};
use std::io::{Error, Read, Write};
pub const RESPONSE_NO: [u8; 4] = [0x0, 0x2, b'N', b'O'];
@@ -18,19 +19,21 @@ impl<S: Read + Write> Handler<S> {
fn communicate(&mut self) -> Result<(), Error> {
let request = Request::from_stream(&mut self.stream)?;
println!("userid: {}", request.userid);
if self.repository.check_auth(&request) {
self.stream.write_all(&RESPONSE_OK)?
self.stream.write_all(&RESPONSE_OK)?;
info!("userid: {}, service: {}, realm: {} - OK", request.userid, request.service, request.realm);
} else {
self.stream.write_all(&RESPONSE_NO)?
self.stream.write_all(&RESPONSE_NO)?;
info!("userid: {}, service: {}, realm: {} - FAIL", request.userid, request.service, request.realm);
}
Ok(())
}
pub fn handle_client(&mut self) {
if let Err(error) = self.communicate() {
eprint!("{error}");
error!("{error}");
}
}
}
+3 -1
View File
@@ -8,7 +8,9 @@ use crate::repository::PasswordDirectory;
use crate::server::Server;
fn main() -> std::io::Result<()> {
env_logger::init();
let repository = PasswordDirectory::new(&OPTIONS.password_dir);
let mut server = Server::new(&OPTIONS.socket_name, repository)?;
let server = Server::new(&OPTIONS.socket_name, repository)?;
server.run()
}
+1
View File
@@ -2,6 +2,7 @@ use clap::Parser;
use std::sync::LazyLock;
#[derive(Parser, Clone, Debug)]
#[command(name = "saslauthd", version)]
/// saslauthd server
pub struct Opt {
#[arg(
+13 -12
View File
@@ -1,13 +1,11 @@
use common::request::Request;
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordVerifier},
};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use log::info;
#[derive(Clone)]
pub struct PasswordDirectory {
@@ -49,6 +47,7 @@ impl PasswordDirectory {
///
fn check_hashed_password(&self, lines: &Vec<String>, password: &str) -> bool {
let (key, split_password) = password.strip_prefix('[').unwrap().split_once(']').unwrap();
info!(" looking for password-key: [{}]", key);
let mut result = false;
@@ -72,6 +71,8 @@ impl PasswordDirectory {
}
fn check_plain_password(&self, lines: &Vec<String>, password: &str) -> bool {
info!(" looking for plain password");
let mut result = false;
for line in lines {
@@ -123,7 +124,7 @@ mod tests {
// echo -n "hunter2" | argon2 "$(openssl rand -base64 16)" -id
let password_hash = "$argon2id$v=19$m=4096,t=3,p=1$VTMrc2wyaW93L01ibVFPOGNQcHcxQT09$UxX86sGpknkc45CnXq+4CZ0coiTYDvSWIN7JgbeAZUs";
let parsed_hash = PasswordHash::new(&password_hash).unwrap();
let parsed_hash = PasswordHash::new(password_hash).unwrap();
assert!(
Argon2::default()
.verify_password(password, &parsed_hash)
@@ -137,7 +138,7 @@ mod tests {
let request = Request::new("bp", "some-rad-password", "ignore-this", "also-ignore-this");
assert_eq!(repository.check_auth(&request), true);
assert!(repository.check_auth(&request));
}
#[test]
@@ -146,7 +147,7 @@ mod tests {
let request = Request::new("bp", "[id-1]hunter2", "ignore-this", "also-ignore-this");
assert_eq!(repository.check_auth(&request), true);
assert!(repository.check_auth(&request));
}
/// The key [id-1] is present in the test file, but the hash shouldn't match
@@ -157,7 +158,7 @@ mod tests {
let request = Request::new("bp", "[id-1]hunter-bad", "ignore-this", "also-ignore-this");
assert_eq!(repository.check_auth(&request), false);
assert!(!repository.check_auth(&request));
}
/// The key [id-2] is not present in the test file
@@ -168,7 +169,7 @@ mod tests {
let request = Request::new("bp", "[id-2]hunter", "ignore-this", "also-ignore-this");
assert_eq!(repository.check_auth(&request), false);
assert!(!repository.check_auth(&request));
}
#[test]
@@ -177,7 +178,7 @@ mod tests {
let request = Request::new("bp", "not-correct", "ignore-this", "also-ignore-this");
assert_eq!(repository.check_auth(&request), false);
assert!(!repository.check_auth(&request));
}
#[test]
@@ -191,7 +192,7 @@ mod tests {
"also-ignore-this",
);
assert_eq!(repository.check_auth(&request), false);
assert!(!repository.check_auth(&request));
}
#[test]
@@ -200,6 +201,6 @@ mod tests {
let request = Request::new("bp-xxx", "commented-out", "ignore-this", "also-ignore-this");
assert_eq!(repository.check_auth(&request), false);
assert!(!repository.check_auth(&request));
}
}
+168 -19
View File
@@ -1,16 +1,42 @@
use crate::handler::Handler;
use crate::repository::PasswordDirectory;
use log::{error, info, warn};
use signal_hook::consts::{SIGINT, SIGTERM};
use signal_hook::flag;
use signal_hook::iterator::Signals;
use signal_hook::low_level::signal_name;
use std::fs::{metadata, set_permissions};
use std::io::Error;
use std::io::{Error, ErrorKind};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixListener;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, Instant};
/// Signals that request a graceful shutdown.
const SHUTDOWN_SIGNALS: [i32; 2] = [SIGTERM, SIGINT];
/// Exit status used when a second shutdown signal forces an immediate exit.
const FORCED_EXIT_STATUS: i32 = 1;
/// Upper bound on how long a single client may take to send its request
/// or receive its response.
const CLIENT_IO_TIMEOUT: Duration = Duration::from_secs(5);
/// How long to wait for in-flight client handlers to finish on shutdown.
const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(3);
/// How long to pause after a transient accept() failure (e.g. EMFILE)
/// before trying again.
const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(100);
pub struct Server {
listener: UnixListener,
socket_file: SocketFile,
repository: PasswordDirectory,
socket_name: PathBuf,
shutdown: Arc<AtomicBool>,
}
impl Server {
@@ -22,40 +48,163 @@ impl Server {
}
let listener = UnixListener::bind(&socket_name)?;
let socket_file = SocketFile(socket_name.clone());
let mut perms = metadata(&socket_name)?.permissions();
perms.set_mode(0o0777);
set_permissions(&socket_name, perms)?;
let shutdown = Arc::new(AtomicBool::new(false));
// A second SIGTERM/SIGINT while already shutting down exits immediately.
// Registered first so it runs before the handler that sets the flag.
for signal in SHUTDOWN_SIGNALS {
flag::register_conditional_shutdown(signal, FORCED_EXIT_STATUS, Arc::clone(&shutdown))?;
}
// The first SIGTERM/SIGINT sets the flag, then wakes the blocking
// accept() with a throwaway connection to the socket.
let mut signals = Signals::new(SHUTDOWN_SIGNALS)?;
let wake = (Arc::clone(&shutdown), socket_name);
thread::spawn(move || {
if let Some(signal) = signals.forever().next() {
let name = signal_name(signal).unwrap_or("signal");
info!("received {name}, shutting down");
wake.0.store(true, Ordering::SeqCst);
if let Err(err) = UnixStream::connect(&wake.1) {
warn!("could not wake the accept loop: {err}");
}
}
});
info!("listening on {}", socket_file.0.display());
Ok(Server {
repository,
listener,
socket_name,
socket_file,
repository,
shutdown,
})
}
pub fn run(&mut self) -> Result<(), Error> {
// accept connections and process them, spawning a new thread for each one
for stream in self.listener.incoming() {
match stream {
Ok(stream) => {
/* connection succeeded */
let repository = self.repository.clone();
thread::spawn(|| Handler::new(stream, repository).handle_client());
pub fn run(self) -> Result<(), Error> {
let Server {
listener,
socket_file,
repository,
shutdown,
} = self;
let in_flight = Arc::new(InFlight::default());
// accept connections and process them, spawning a new thread for each one,
// until a shutdown signal arrives
loop {
match listener.accept() {
Ok((stream, _addr)) => {
if shutdown.load(Ordering::SeqCst) {
// Either the wake-up connection from the signal thread or a
// client that raced the shutdown; either way, stop here.
break;
}
if let Err(err) = configure_stream(&stream) {
error!("could not configure client connection: {err}");
continue;
}
let repository = repository.clone();
let guard = in_flight.start();
thread::spawn(move || {
let _guard = guard;
Handler::new(stream, repository).handle_client()
});
}
Err(_err) => {
/* connection failed */
break;
Err(err) if err.kind() == ErrorKind::Interrupted => {}
Err(err) => {
// Transient failures (EMFILE, ECONNABORTED, ...) should not
// take the daemon down; pause briefly and keep serving.
error!("accept failed: {err}");
if shutdown.load(Ordering::SeqCst) {
break;
}
thread::sleep(ACCEPT_RETRY_DELAY);
}
}
}
// Stop accepting and remove the socket path right away, so clients that
// connect from here on fail fast instead of queueing behind the shutdown.
drop(listener);
drop(socket_file);
in_flight.wait_until_idle(SHUTDOWN_GRACE_PERIOD);
info!("shutdown complete");
Ok(())
}
}
impl Drop for Server {
/// Put an accepted stream into blocking mode with bounded I/O, so a client that
/// stalls cannot pin a handler thread forever.
fn configure_stream(stream: &UnixStream) -> Result<(), Error> {
// On some platforms (e.g. macOS) an accepted stream can inherit the
// listener's flags; the handler expects blocking I/O.
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(CLIENT_IO_TIMEOUT))?;
stream.set_write_timeout(Some(CLIENT_IO_TIMEOUT))?;
Ok(())
}
/// Owns the socket path on disk; removes it when dropped.
struct SocketFile(PathBuf);
impl Drop for SocketFile {
fn drop(&mut self) {
let _result = std::fs::remove_file(&self.socket_name);
println!("Shutting down");
let _result = std::fs::remove_file(&self.0);
}
}
/// Counts client handlers that are still running.
#[derive(Default)]
struct InFlight {
count: Mutex<usize>,
idle: Condvar,
}
impl InFlight {
fn start(self: &Arc<Self>) -> InFlightGuard {
*self.count.lock().unwrap() += 1;
InFlightGuard(Arc::clone(self))
}
fn wait_until_idle(&self, timeout: Duration) {
let mut count = self.count.lock().unwrap();
if *count == 0 {
return;
}
info!("waiting for {} in-flight request(s) to finish", *count);
let deadline = Instant::now() + timeout;
while *count > 0 {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
warn!("abandoning {} request(s) still in flight after {timeout:?}", *count);
break;
}
count = self.idle.wait_timeout(count, remaining).unwrap().0;
}
}
}
/// Decrements the in-flight count when the handler thread finishes, even on panic.
struct InFlightGuard(Arc<InFlight>);
impl Drop for InFlightGuard {
fn drop(&mut self) {
let mut count = self.0.count.lock().unwrap();
*count -= 1;
if *count == 0 {
self.0.idle.notify_all();
}
}
}
+143
View File
@@ -0,0 +1,143 @@
use common::io::read_string;
use common::request::Request;
use std::os::unix::fs::FileTypeExt;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::time::{Duration, Instant};
/// Idle shutdown takes milliseconds; anything approaching the daemon's 3s
/// grace period means it is waiting on something it should not be.
const EXIT_TIMEOUT: Duration = Duration::from_secs(2);
const PASSWORD_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../test_passwords");
fn temp_socket_path(tag: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("saslauthd-{}-{}.sock", tag, std::process::id()));
let _ = std::fs::remove_file(&path);
path
}
/// A spawned daemon that is killed if the test panics before stopping it.
struct Daemon(Child);
impl Drop for Daemon {
fn drop(&mut self) {
if self.0.try_wait().ok().flatten().is_none() {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
}
fn is_socket(path: &PathBuf) -> bool {
std::fs::metadata(path)
.map(|m| m.file_type().is_socket())
.unwrap_or(false)
}
fn spawn_daemon(socket: &PathBuf) -> Daemon {
let child = Command::new(env!("CARGO_BIN_EXE_saslauthd"))
.arg("--socket-name")
.arg(socket)
.arg("--password-dir")
.arg(PASSWORD_DIR)
.spawn()
.expect("failed to spawn saslauthd");
// Wrap immediately so a failed startup below still kills the process.
let daemon = Daemon(child);
let deadline = Instant::now() + Duration::from_secs(5);
while !is_socket(socket) {
assert!(Instant::now() < deadline, "daemon never created {socket:?}");
std::thread::sleep(Duration::from_millis(10));
}
daemon
}
fn send_signal(daemon: &Daemon, signal: &str) {
let child = &daemon.0;
let status = Command::new("kill")
.arg(format!("-{signal}"))
.arg(child.id().to_string())
.status()
.expect("failed to run kill");
assert!(status.success(), "kill -{signal} failed");
}
fn wait_for_exit(daemon: &mut Daemon, timeout: Duration) -> std::process::ExitStatus {
let child = &mut daemon.0;
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait().expect("try_wait failed") {
return status;
}
if Instant::now() >= deadline {
panic!("daemon did not exit within {timeout:?}");
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn authenticate(socket: &PathBuf, password: &str) -> String {
let mut stream = UnixStream::connect(socket).expect("connect to daemon");
Request::new("bp", password, "imap", "")
.write_to_stream(&mut stream)
.expect("write request");
read_string(&mut stream).expect("read response")
}
fn assert_clean_shutdown(signal: &str) {
let socket = temp_socket_path(signal);
let mut daemon = spawn_daemon(&socket);
// Make sure the daemon actually works before we shut it down.
assert_eq!(authenticate(&socket, "some-rad-password"), "OK");
assert_eq!(authenticate(&socket, "wrong-password"), "NO");
send_signal(&daemon, signal);
let status = wait_for_exit(&mut daemon, EXIT_TIMEOUT);
assert!(status.success(), "expected exit status 0 on {signal}, got {status}");
assert!(!socket.exists(), "socket file {socket:?} was left behind");
}
#[test]
fn it_shuts_down_cleanly_on_sigterm() {
assert_clean_shutdown("TERM");
}
#[test]
fn it_shuts_down_cleanly_on_sigint() {
assert_clean_shutdown("INT");
}
#[test]
fn it_recreates_a_stale_socket_on_startup() {
let socket = temp_socket_path("stale");
std::fs::write(&socket, b"stale").expect("create stale socket file");
let mut daemon = spawn_daemon(&socket);
assert_eq!(authenticate(&socket, "some-rad-password"), "OK");
send_signal(&daemon, "TERM");
let status = wait_for_exit(&mut daemon, EXIT_TIMEOUT);
assert!(status.success());
assert!(!socket.exists());
}
#[test]
fn it_does_not_wait_forever_for_a_silent_client() {
let socket = temp_socket_path("silent");
let mut daemon = spawn_daemon(&socket);
// Connect but never send a request, leaving a handler blocked on read.
let _silent = UnixStream::connect(&socket).expect("connect to daemon");
send_signal(&daemon, "TERM");
// Must finish once the grace period (3s) expires, whatever the client does.
let status = wait_for_exit(&mut daemon, Duration::from_secs(5));
assert!(status.success());
assert!(!socket.exists());
}