Skip to content

Commit 256e04a

Browse files
committed
feat: support SASL authentication
Closes #12.
1 parent 15582ba commit 256e04a

File tree

14 files changed

+1215
-29
lines changed

14 files changed

+1215
-29
lines changed

.github/workflows/ci.yml

+20-5
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ jobs:
3131
with:
3232
toolchain: stable
3333
override: true
34+
- name: Install krb5
35+
run: |
36+
sudo apt install -y libkrb5-dev
3437
- name: Build code
35-
run: cargo build
38+
run: cargo build --all-features
3639
test:
3740
needs: [build]
3841
runs-on: ubuntu-latest
@@ -46,8 +49,11 @@ jobs:
4649
with:
4750
toolchain: stable
4851
override: true
52+
- name: Install krb5
53+
run: |
54+
sudo apt install -y libkrb5-dev
4955
- name: Test code
50-
run: cargo test -- --nocapture
56+
run: cargo test --all-features -- --nocapture
5157
coverage:
5258
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref_type == 'branch' && github.ref_name == 'master')
5359
needs: [test]
@@ -65,9 +71,12 @@ jobs:
6571
override: true
6672
components: llvm-tools-preview
6773
- run: cargo install grcov
68-
- run: cargo build --verbose
74+
- name: Install krb5
75+
run: |
76+
sudo apt install -y libkrb5-dev
77+
- run: cargo build --all-features --verbose
6978
- name: Run tests
70-
run: LLVM_PROFILE_FILE="zookeeper-client-%p-%m.profraw" cargo test --verbose -- --nocapture
79+
run: LLVM_PROFILE_FILE="zookeeper-client-%p-%m.profraw" cargo test --all-features --verbose -- --nocapture
7180
- name: Generate coverage report
7281
run: grcov $(find . -name "zookeeper-*.profraw" -print) --binary-path ./target/debug/ -s . -t lcov --branch --ignore-not-existing --ignore "/*" -o lcov.info
7382
- name: Upload to codecov.io
@@ -86,15 +95,21 @@ jobs:
8695
toolchain: stable
8796
override: true
8897
components: clippy
98+
- name: Install krb5
99+
run: |
100+
sudo apt install -y libkrb5-dev
89101
- name: Lint code
90-
run: cargo clippy --no-deps -- -D clippy::all
102+
run: cargo clippy --all-features --no-deps -- -D clippy::all
91103
release:
92104
if: github.event_name == 'push' && github.ref_type == 'tag'
93105
needs: [build, test, lint]
94106
runs-on: ubuntu-latest
95107
steps:
96108
- uses: actions/checkout@v2
97109
- uses: Swatinem/rust-cache@v1
110+
- name: Install krb5
111+
run: |
112+
sudo apt install -y libkrb5-dev
98113
- name: publish crate
99114
env:
100115
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

Cargo.toml

+10
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ rust-version = "1.65"
1414

1515
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
1616

17+
[features]
18+
default = []
19+
sasl = ["sasl-gssapi", "sasl-digest-md5"]
20+
sasl-digest-md5 = ["rsasl/unstable_custom_mechanism", "md5", "linkme", "hex"]
21+
sasl-gssapi = ["rsasl/gssapi"]
22+
1723
[dependencies]
1824
bytes = "1.1.0"
1925
tokio = {version = "1.15.0", features = ["full"]}
@@ -35,6 +41,10 @@ derive-where = "1.2.7"
3541
tokio-rustls = "0.26.0"
3642
fastrand = "2.0.2"
3743
tracing = "0.1.40"
44+
rsasl = { version = "2.0.1", default-features = false, features = ["provider", "config_builder", "registry_static", "std"], optional = true }
45+
md5 = { version = "0.7.0", optional = true }
46+
hex = { version = "0.4.3", optional = true }
47+
linkme = { version = "0.2", optional = true }
3848

3949
[dev-dependencies]
4050
test-log = { version = "0.2.15", features = ["log", "trace"] }

Makefile

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ check_fmt:
99
cargo +nightly fmt --all -- --check
1010

1111
lint:
12-
cargo clippy --no-deps -- -D clippy::all
12+
cargo clippy --all-features --no-deps -- -D clippy::all
1313

1414
build:
15-
cargo build
15+
cargo build --all-features
1616

1717
test:
18-
cargo test
18+
cargo test --all-features

README.md

-3
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,6 @@ latch.create("/app/data", b"data", &zk::CreateMode::Ephemeral.with_acls(zk::Acls
7979

8080
For more examples, see [zookeeper.rs](tests/zookeeper.rs).
8181

82-
## TODO
83-
* [ ] Sasl authentication
84-
8582
## License
8683
The MIT License (MIT). See [LICENSE](LICENSE) for the full license text.
8784

src/client/mod.rs

+15
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ use crate::proto::{
4545
};
4646
pub use crate::proto::{EnsembleUpdate, Stat};
4747
use crate::record::{self, Record, StaticRecord};
48+
#[cfg(any(feature = "sasl-digest-md5", feature = "sasl-gssapi"))]
49+
use crate::sasl::SaslOptions;
4850
use crate::session::StateReceiver;
4951
pub use crate::session::{EventType, SessionId, SessionInfo, SessionState, WatchedEvent};
5052
use crate::tls::TlsOptions;
@@ -1538,6 +1540,8 @@ pub(crate) struct Version(u32, u32, u32);
15381540
#[derive(Clone, Debug)]
15391541
pub struct Connector {
15401542
tls: Option<TlsOptions>,
1543+
#[cfg(any(feature = "sasl-digest-md5", feature = "sasl-gssapi"))]
1544+
sasl: Option<SaslOptions>,
15411545
authes: Vec<AuthPacket>,
15421546
session: Option<SessionInfo>,
15431547
readonly: bool,
@@ -1553,6 +1557,8 @@ impl Connector {
15531557
fn new() -> Self {
15541558
Self {
15551559
tls: None,
1560+
#[cfg(any(feature = "sasl-digest-md5", feature = "sasl-gssapi"))]
1561+
sasl: None,
15561562
authes: Default::default(),
15571563
session: None,
15581564
readonly: false,
@@ -1624,6 +1630,13 @@ impl Connector {
16241630
self
16251631
}
16261632

1633+
/// Specifies SASL options.
1634+
#[cfg(any(feature = "sasl-digest-md5", feature = "sasl-gssapi"))]
1635+
pub fn sasl(&mut self, options: impl Into<SaslOptions>) -> &mut Self {
1636+
self.sasl = Some(options.into());
1637+
self
1638+
}
1639+
16271640
/// Fail session establishment eagerly with [Error::NoHosts] when all hosts has been tried.
16281641
///
16291642
/// This permits fail-fast without wait up to [Self::session_timeout] in [Self::connect]. This
@@ -1657,6 +1670,8 @@ impl Connector {
16571670
self.readonly,
16581671
self.detached,
16591672
tls_config,
1673+
#[cfg(any(feature = "sasl-digest-md5", feature = "sasl-gssapi"))]
1674+
self.sasl.take(),
16601675
self.session_timeout,
16611676
self.connection_timeout,
16621677
);

src/lib.rs

+8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ mod endpoint;
66
mod error;
77
mod proto;
88
mod record;
9+
#[cfg(any(feature = "sasl-digest-md5", feature = "sasl-gssapi"))]
10+
mod sasl;
911
mod session;
1012
mod tls;
1113
mod util;
@@ -14,3 +16,9 @@ pub use self::acl::{Acl, Acls, AuthId, AuthUser, Permission};
1416
pub use self::error::Error;
1517
pub use self::tls::TlsOptions;
1618
pub use crate::client::*;
19+
#[cfg(feature = "sasl-digest-md5")]
20+
pub use crate::sasl::DigestMd5SaslOptions;
21+
#[cfg(feature = "sasl-gssapi")]
22+
pub use crate::sasl::GssapiSaslOptions;
23+
#[cfg(any(feature = "sasl-digest-md5", feature = "sasl-gssapi"))]
24+
pub use crate::sasl::SaslOptions;

src/record.rs

+31-3
Original file line numberDiff line numberDiff line change
@@ -422,16 +422,44 @@ impl<'a> DeserializableRecord<'a> for &'a [u8] {
422422
type Error = InsufficientBuf;
423423

424424
fn deserialize(buf: &mut ReadingBuf<'a>) -> Result<&'a [u8], Self::Error> {
425+
Option::<&[u8]>::deserialize(buf).map(|opt| opt.unwrap_or_default())
426+
}
427+
}
428+
429+
impl SerializableRecord for Option<&[u8]> {
430+
fn serialize(&self, buf: &mut dyn BufMut) {
431+
match self {
432+
None => buf.put_i32(-1),
433+
Some(bytes) => bytes.serialize(buf),
434+
}
435+
}
436+
}
437+
438+
impl DynamicRecord for Option<&[u8]> {
439+
fn serialized_len(&self) -> usize {
440+
match self {
441+
None => 4,
442+
Some(bytes) => 4 + bytes.len(),
443+
}
444+
}
445+
}
446+
447+
impl<'a> DeserializableRecord<'a> for Option<&'a [u8]> {
448+
type Error = InsufficientBuf;
449+
450+
fn deserialize(buf: &mut ReadingBuf<'a>) -> Result<Option<&'a [u8]>, Self::Error> {
425451
let n = i32::deserialize(buf)?;
426-
if n <= 0 {
427-
return Ok(Default::default());
452+
if n < 0 {
453+
return Ok(None);
454+
} else if n == 0 {
455+
return Ok(Some(Default::default()));
428456
} else if n > buf.len() as i32 {
429457
return Err(InsufficientBuf);
430458
}
431459
let n = n as usize;
432460
let bytes = unsafe { buf.get_unchecked(..n) };
433461
unsafe { *buf = buf.get_unchecked(n..) };
434-
Ok(bytes)
462+
Ok(Some(bytes))
435463
}
436464
}
437465

src/sasl/digest_md5.rs

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use std::borrow::Cow;
2+
3+
use rsasl::callback::{Context, Request, SessionCallback, SessionData};
4+
use rsasl::prelude::*;
5+
use rsasl::property::{AuthId, Password, Realm};
6+
7+
use super::{Result, SaslInitiator, SaslInnerOptions, SaslOptions, SaslSession};
8+
9+
#[derive(Clone, Debug)]
10+
pub struct DigestMd5SaslOptions {
11+
realm: Option<Cow<'static, str>>,
12+
username: Cow<'static, str>,
13+
password: Cow<'static, str>,
14+
}
15+
16+
impl DigestMd5SaslOptions {
17+
fn realm(&self) -> Option<&str> {
18+
self.realm.as_ref().map(|s| s.as_ref())
19+
}
20+
21+
pub(crate) fn new(username: impl Into<Cow<'static, str>>, password: impl Into<Cow<'static, str>>) -> Self {
22+
Self { realm: None, username: username.into(), password: password.into() }
23+
}
24+
25+
/// Specifies the client chosen realm.
26+
pub fn with_realm(self, realm: impl Into<Cow<'static, str>>) -> Self {
27+
Self { realm: Some(realm.into()), ..self }
28+
}
29+
}
30+
31+
impl From<DigestMd5SaslOptions> for SaslOptions {
32+
fn from(options: DigestMd5SaslOptions) -> Self {
33+
Self(SaslInnerOptions::DigestMd5(options))
34+
}
35+
}
36+
37+
struct DigestSessionCallback {
38+
options: DigestMd5SaslOptions,
39+
}
40+
41+
impl SessionCallback for DigestSessionCallback {
42+
fn callback(
43+
&self,
44+
_session_data: &SessionData,
45+
_context: &Context,
46+
request: &mut Request<'_>,
47+
) -> Result<(), SessionError> {
48+
if request.is::<Realm>() {
49+
if let Some(realm) = self.options.realm() {
50+
request.satisfy::<Realm>(realm)?;
51+
}
52+
} else if request.is::<AuthId>() {
53+
request.satisfy::<AuthId>(&self.options.username)?;
54+
} else if request.is::<Password>() {
55+
request.satisfy::<Password>(self.options.password.as_bytes())?;
56+
}
57+
Ok(())
58+
}
59+
}
60+
61+
impl SaslInitiator for DigestMd5SaslOptions {
62+
fn new_session(&self, _hostname: &str) -> Result<SaslSession> {
63+
let callback = DigestSessionCallback { options: self.clone() };
64+
let config = SASLConfig::builder().with_defaults().with_callback(callback).unwrap();
65+
let client = SASLClient::new(config);
66+
let session = client.start_suggested(&[Mechname::parse(b"DIGEST-MD5").unwrap()]).unwrap();
67+
SaslSession::new(session)
68+
}
69+
}

src/sasl/gssapi.rs

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use std::borrow::Cow;
2+
3+
use rsasl::callback::{Context, Request, SessionCallback, SessionData};
4+
use rsasl::mechanisms::gssapi::properties::GssService;
5+
use rsasl::prelude::*;
6+
use rsasl::property::Hostname;
7+
8+
use super::{Result, SaslInitiator, SaslInnerOptions, SaslOptions, SaslSession};
9+
10+
impl From<GssapiSaslOptions> for SaslOptions {
11+
fn from(options: GssapiSaslOptions) -> Self {
12+
Self(SaslInnerOptions::Gssapi(options))
13+
}
14+
}
15+
16+
/// GSSAPI SASL options.
17+
#[derive(Clone, Debug)]
18+
pub struct GssapiSaslOptions {
19+
username: Cow<'static, str>,
20+
hostname: Option<Cow<'static, str>>,
21+
}
22+
23+
impl GssapiSaslOptions {
24+
pub(crate) fn new() -> Self {
25+
Self { username: Cow::from("zookeeper"), hostname: None }
26+
}
27+
28+
/// Specifies the primary part of Kerberos principal.
29+
///
30+
/// It is `zookeeper.sasl.client.username` in Java client, but the word "client" is misleading
31+
/// as it is the username of targeting server.
32+
///
33+
/// Defaults to "zookeeper".
34+
pub fn with_username(self, username: impl Into<Cow<'static, str>>) -> Self {
35+
Self { username: username.into(), ..self }
36+
}
37+
38+
/// Specifies the instance part of Kerberos principal.
39+
///
40+
/// Defaults to hostname or ip of targeting server in connecting string.
41+
pub fn with_hostname(self, hostname: impl Into<Cow<'static, str>>) -> Self {
42+
Self { hostname: Some(hostname.into()), ..self }
43+
}
44+
45+
fn hostname_or(&self, hostname: &str) -> Cow<'static, str> {
46+
match self.hostname.as_ref() {
47+
None => Cow::Owned(hostname.to_string()),
48+
Some(hostname) => hostname.clone(),
49+
}
50+
}
51+
}
52+
53+
impl SaslInitiator for GssapiSaslOptions {
54+
fn new_session(&self, hostname: &str) -> Result<SaslSession> {
55+
struct GssapiOptionsProvider {
56+
username: Cow<'static, str>,
57+
hostname: Cow<'static, str>,
58+
}
59+
impl SessionCallback for GssapiOptionsProvider {
60+
fn callback(
61+
&self,
62+
_session_data: &SessionData,
63+
_context: &Context,
64+
request: &mut Request<'_>,
65+
) -> Result<(), SessionError> {
66+
if request.is::<Hostname>() {
67+
request.satisfy::<Hostname>(&self.hostname)?;
68+
} else if request.is::<GssService>() {
69+
request.satisfy::<GssService>(&self.username)?;
70+
}
71+
Ok(())
72+
}
73+
}
74+
let provider = GssapiOptionsProvider { username: self.username.clone(), hostname: self.hostname_or(hostname) };
75+
let config = SASLConfig::builder().with_defaults().with_callback(provider).unwrap();
76+
let client = SASLClient::new(config);
77+
let session = client.start_suggested(&[Mechname::parse(b"GSSAPI").unwrap()]).unwrap();
78+
SaslSession::new(session)
79+
}
80+
}

0 commit comments

Comments
 (0)