mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-14 01:58:33 +02:00
* rust: migrate seaweed-volume and seaweed-worker to tonic 0.14 / prost 0.14
tonic 0.14 boxes the contents of tonic::Status, which is what made every
RPC path trip clippy's result_large_err; the allow for that lint goes in
the next commit. The prost codec moved out of tonic into tonic-prost and
tonic-prost-build, so both build scripts now call
tonic_prost_build::configure() and both crates depend on tonic-prost for
the generated code. The `tls` feature was split into a per-backend
feature; `tls-aws-lc` is the same backend both crates already install
through rustls::crypto::aws_lc_rs.
tonic 0.14 depends on axum 0.8 and tower 0.5, which would have left a
second axum and a second tower in each tree next to the 0.7 / 0.4 the
crates named themselves. Bumping them keeps one copy of each: axum 0.8
only changes the path-parameter syntax for the routes here (`/:vid` ->
`/{vid}`, `/*path` -> `/{*path}`), tower 0.5 needs the `util` feature
named explicitly for ServiceExt::oneshot (it used to arrive through
tonic's feature unification), and tower-http 0.6 is the matching
release.
Lock files move only through cargo's own resolution for the new
versions; no other dependency was refreshed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust: drop the result_large_err allow now that tonic::Status is boxed
tonic 0.14 stores Status behind a Box, so Result<_, Status> is no longer
a large-Err type and clippy has nothing to say about it. Both crates
pass `cargo clippy --all-targets -- -D warnings` without the allow
(seaweed-volume in both feature sets), so the policy entry and its
comment go.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: drop the unused headers argument of try_expand_chunk_manifest
The parameter was already named `_headers`; nothing in the body reads it.
With it gone the function is under clippy's argument threshold and the
expect goes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: pass EC peer reads an EcInterval instead of ten arguments
fetch_one_interval, read_remote_ec_shard_interval,
do_read_remote_ec_shard_interval and recover_one_remote_ec_shard_interval
all took the same (vid, needle_id, shard_id, shard_offset, size,
expected_encode_ts_ns) tuple, and the two that reconstruct also took the
location map with the data/parity counts. Those are now EcInterval (Copy)
and EcShardMap (a borrow of the map plus the counts). The fan-out inside
recovery builds its per-shard request with `EcInterval { shard_id: sid,
..iv }`, which is the one place the old argument list was easy to get
wrong. Bodies destructure at the top, so the code below the signatures
is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: give the EC encoder an EcEncodeLayout and an EncodeRun
encode_dat_file took the Reed-Solomon shape and three block sizes as five
loose integers; they are now one Copy struct, EcEncodeLayout, which is
what Go calls ECContext. The per-row and per-batch helpers took the same
six sinks and the offsets; they become methods on EncodeRun, which owns
the borrows for one run, so each call names only the offset and block
size that vary. The byte-level work is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: describe a .dat rebuild with DatRebuild instead of nine arguments
write_dat_file_from_shards, its _with_dirs twin and the private
write_dat_file were three layers over one nine-argument signature. One
public function now takes a DatRebuild, whose shard_dirs is None when
every shard sits beside the .dat and Some(dirs) for the cross-disk
reconciled layout. The field docs carry what the function doc used to
say about the encode-time size and the block layout.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: split copy_file_from_source's fifteen arguments into two structs
CopyFileSpec is the per-file request (what to ask the source for, where
it lands, whether its bytes count as progress); CopyProgress is the
sender, throttler and report state that all three files of one
VolumeCopy share, held by &mut across the calls. The three production
call sites now read as the .dat/.idx/.vif literals they are, instead of
positional trues and falses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: create volumes from a VolumeSpec
Volume::new, DiskLocation::create_volume and Store::add_volume each
took the same five-value tail of Go's NewVolume argument list:
collection, replica placement, TTL, preallocation and needle version.
That tail is now VolumeSpec, a Copy struct whose Default is what almost
every test wanted anyway (empty collection, no replication, no TTL, no
preallocation, current version), so most of the 104 call sites shrink
to `&VolumeSpec::default()` or name the one field they set. The id,
directories, index kind and disk type stay positional because they
differ at every site.
Two imports that only test modules use moved into those modules, and
DiskLocation no longer imports ReplicaPlacement.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-worker: run cargo fmt
Layout only; no token in the workspace changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: run cargo fmt
Layout only; no token in the crate changes. Every earlier Rust PR here
formatted only the blocks it touched so as not to drown its diff in
this one, and this commit is that debt paid in a single place. rustfmt
needed two passes to settle one block in handlers.rs; the committed
form is the fixed point, so `cargo fmt --check` is clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* ci: add a commented-out cargo fmt --check step to both Rust workflows
Same shape as the commented clippy step from #11312: the check is
written out so that making formatting a gate is a one-line uncomment,
and whether to do that stays a maintainer call.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
548 lines
17 KiB
Rust
548 lines
17 KiB
Rust
//! Security: JWT validation and IP whitelist checking.
|
|
//!
|
|
//! Matches Go's security/guard.go and security/jwt.go.
|
|
//! - Guard: combines whitelist IP checking with JWT token validation
|
|
//! - JWT: HS256 HMAC signing with file-id claims
|
|
|
|
pub mod tls;
|
|
|
|
use std::collections::HashSet;
|
|
use std::net::IpAddr;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// ============================================================================
|
|
// JWT Claims
|
|
// ============================================================================
|
|
|
|
/// Claims for volume server file access tokens.
|
|
/// Matches Go's `SeaweedFileIdClaims`.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct FileIdClaims {
|
|
/// File ID this token grants access to (e.g., "3,01637037d6").
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub fid: Option<String>,
|
|
|
|
/// Expiration time (Unix timestamp).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub exp: Option<u64>,
|
|
|
|
/// Not before (Unix timestamp).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub nbf: Option<u64>,
|
|
}
|
|
|
|
/// Signing key wrapper (empty = security disabled).
|
|
#[derive(Clone)]
|
|
pub struct SigningKey(pub Vec<u8>);
|
|
|
|
impl SigningKey {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
|
|
pub fn from_string(s: &str) -> Self {
|
|
SigningKey(s.as_bytes().to_vec())
|
|
}
|
|
}
|
|
|
|
/// Generate a JWT token for file access.
|
|
pub fn gen_jwt(
|
|
signing_key: &SigningKey,
|
|
expires_after_sec: i64,
|
|
file_id: &str,
|
|
) -> Result<String, JwtError> {
|
|
if signing_key.is_empty() {
|
|
return Err(JwtError::NoSigningKey);
|
|
}
|
|
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
let claims = FileIdClaims {
|
|
fid: Some(file_id.to_string()),
|
|
exp: if expires_after_sec > 0 {
|
|
Some(now + expires_after_sec as u64)
|
|
} else {
|
|
None
|
|
},
|
|
nbf: None,
|
|
};
|
|
|
|
let token = encode(
|
|
&Header::new(Algorithm::HS256),
|
|
&claims,
|
|
&EncodingKey::from_secret(&signing_key.0),
|
|
)?;
|
|
|
|
Ok(token)
|
|
}
|
|
|
|
/// Decode and validate a JWT token.
|
|
pub fn decode_jwt(signing_key: &SigningKey, token: &str) -> Result<FileIdClaims, JwtError> {
|
|
if signing_key.is_empty() {
|
|
return Err(JwtError::NoSigningKey);
|
|
}
|
|
|
|
let mut validation = Validation::new(Algorithm::HS256);
|
|
// Match Go behavior: tokens without exp are accepted (Go's jwt-go does not require exp)
|
|
// But if exp IS present, it must be valid (not expired).
|
|
validation.required_spec_claims.clear();
|
|
validation.validate_exp = true;
|
|
// Go's jwt-go/v5 validates nbf when present
|
|
validation.validate_nbf = true;
|
|
validation.leeway = 0;
|
|
|
|
let data = decode::<FileIdClaims>(
|
|
token,
|
|
&DecodingKey::from_secret(&signing_key.0),
|
|
&validation,
|
|
)?;
|
|
|
|
Ok(data.claims)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Guard
|
|
// ============================================================================
|
|
|
|
/// Security guard: IP whitelist + JWT token validation.
|
|
pub struct Guard {
|
|
whitelist_ips: HashSet<String>,
|
|
whitelist_cidrs: Vec<(IpAddr, u8)>, // (network, prefix_len)
|
|
pub signing_key: SigningKey,
|
|
pub expires_after_sec: i64,
|
|
pub read_signing_key: SigningKey,
|
|
pub read_expires_after_sec: i64,
|
|
/// Combined flag: true when whitelist is non-empty OR signing key is present.
|
|
/// Matches Go's `isWriteActive = !isEmptyWhiteList || len(SigningKey) != 0`.
|
|
is_write_active: bool,
|
|
}
|
|
|
|
impl Guard {
|
|
pub fn new(
|
|
whitelist: &[String],
|
|
signing_key: SigningKey,
|
|
expires_after_sec: i64,
|
|
read_signing_key: SigningKey,
|
|
read_expires_after_sec: i64,
|
|
) -> Self {
|
|
let mut guard = Guard {
|
|
whitelist_ips: HashSet::new(),
|
|
whitelist_cidrs: Vec::new(),
|
|
signing_key,
|
|
expires_after_sec,
|
|
read_signing_key,
|
|
read_expires_after_sec,
|
|
is_write_active: false,
|
|
};
|
|
guard.update_whitelist(whitelist);
|
|
guard
|
|
}
|
|
|
|
/// Update the IP whitelist.
|
|
pub fn update_whitelist(&mut self, entries: &[String]) {
|
|
self.whitelist_ips.clear();
|
|
self.whitelist_cidrs.clear();
|
|
|
|
for entry in entries {
|
|
let entry = entry.trim();
|
|
if entry.is_empty() {
|
|
continue;
|
|
}
|
|
if entry.contains('/') {
|
|
// CIDR range
|
|
if let Some((ip, prefix)) = parse_cidr(entry) {
|
|
self.whitelist_cidrs.push((ip, prefix));
|
|
} else {
|
|
tracing::error!("Parse CIDR {} in whitelist failed", entry);
|
|
}
|
|
} else {
|
|
// Exact IP/hostname
|
|
self.whitelist_ips.insert(entry.to_string());
|
|
}
|
|
}
|
|
|
|
// Match Go: isWriteActive = !isEmptyWhiteList || len(SigningKey) != 0
|
|
let is_empty_whitelist = self.whitelist_ips.is_empty() && self.whitelist_cidrs.is_empty();
|
|
self.is_write_active = !is_empty_whitelist || !self.signing_key.is_empty();
|
|
}
|
|
|
|
/// Refresh the JWT signing keys and their expirations in place so operators
|
|
/// can rotate keys via SIGHUP without restarting the process. Mirrors Go's
|
|
/// `Guard::UpdateSigningKeys`: it swaps the fields and recomputes
|
|
/// `is_write_active`.
|
|
pub fn update_signing_keys(
|
|
&mut self,
|
|
signing_key: SigningKey,
|
|
expires_after_sec: i64,
|
|
read_signing_key: SigningKey,
|
|
read_expires_after_sec: i64,
|
|
) {
|
|
self.signing_key = signing_key;
|
|
self.expires_after_sec = expires_after_sec;
|
|
self.read_signing_key = read_signing_key;
|
|
self.read_expires_after_sec = read_expires_after_sec;
|
|
|
|
let is_empty_whitelist = self.whitelist_ips.is_empty() && self.whitelist_cidrs.is_empty();
|
|
self.is_write_active = !is_empty_whitelist || !self.signing_key.is_empty();
|
|
}
|
|
|
|
/// Whether any write-side control is configured: a non-empty whitelist or a
|
|
/// signing key. Mirrors Go's non-nil `guard` -- when this is false, every
|
|
/// caller is allowed and admin RPCs need not carry peer info.
|
|
pub fn is_write_active(&self) -> bool {
|
|
self.is_write_active
|
|
}
|
|
|
|
/// Check if a remote IP is in the whitelist.
|
|
/// Returns true if write security is inactive (no whitelist and no signing key),
|
|
/// if the whitelist is empty, or if the IP matches.
|
|
pub fn check_whitelist(&self, remote_addr: &str) -> bool {
|
|
if !self.is_write_active {
|
|
return true;
|
|
}
|
|
if self.whitelist_ips.is_empty() && self.whitelist_cidrs.is_empty() {
|
|
return true;
|
|
}
|
|
|
|
let host = extract_host(remote_addr);
|
|
|
|
// Check exact match
|
|
if self.whitelist_ips.contains(&host) {
|
|
return true;
|
|
}
|
|
|
|
// Check CIDR ranges
|
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
|
for &(ref network, prefix_len) in &self.whitelist_cidrs {
|
|
if ip_in_cidr(&ip, network, prefix_len) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Check if a read signing key is configured.
|
|
pub fn has_read_signing_key(&self) -> bool {
|
|
!self.read_signing_key.is_empty()
|
|
}
|
|
|
|
/// Validate a request's JWT token.
|
|
/// `is_write` determines which signing key to use.
|
|
/// Returns Ok(()) if valid, or if security is disabled.
|
|
pub fn check_jwt(&self, token: Option<&str>, is_write: bool) -> Result<(), JwtError> {
|
|
let key = if is_write {
|
|
&self.signing_key
|
|
} else {
|
|
&self.read_signing_key
|
|
};
|
|
|
|
if key.is_empty() {
|
|
return Ok(()); // Security disabled for this operation type
|
|
}
|
|
|
|
let token = token.ok_or(JwtError::MissingToken)?;
|
|
decode_jwt(key, token)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Check JWT and validate the file ID claim matches.
|
|
pub fn check_jwt_for_file(
|
|
&self,
|
|
token: Option<&str>,
|
|
expected_fid: &str,
|
|
is_write: bool,
|
|
) -> Result<(), JwtError> {
|
|
let key = if is_write {
|
|
&self.signing_key
|
|
} else {
|
|
&self.read_signing_key
|
|
};
|
|
|
|
if key.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let token = token.ok_or(JwtError::MissingToken)?;
|
|
let claims = decode_jwt(key, token)?;
|
|
|
|
match claims.fid {
|
|
None => {
|
|
return Err(JwtError::MissingFileIdClaim);
|
|
}
|
|
Some(ref fid) if fid != expected_fid => {
|
|
return Err(JwtError::FileIdMismatch {
|
|
expected: expected_fid.to_string(),
|
|
got: fid.to_string(),
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
/// Extract host from "host:port" or "[::1]:port" format.
|
|
fn extract_host(addr: &str) -> String {
|
|
// Handle IPv6 with brackets
|
|
if addr.starts_with('[')
|
|
&& let Some(end) = addr.find(']')
|
|
{
|
|
return addr[1..end].to_string();
|
|
}
|
|
// Handle host:port
|
|
if let Some(pos) = addr.rfind(':') {
|
|
return addr[..pos].to_string();
|
|
}
|
|
addr.to_string()
|
|
}
|
|
|
|
/// Parse CIDR notation "192.168.1.0/24" into (IpAddr, prefix_len).
|
|
fn parse_cidr(cidr: &str) -> Option<(IpAddr, u8)> {
|
|
let parts: Vec<&str> = cidr.split('/').collect();
|
|
if parts.len() != 2 {
|
|
return None;
|
|
}
|
|
let ip: IpAddr = parts[0].parse().ok()?;
|
|
let prefix: u8 = parts[1].parse().ok()?;
|
|
Some((ip, prefix))
|
|
}
|
|
|
|
/// Check if an IP is within a CIDR range.
|
|
fn ip_in_cidr(ip: &IpAddr, network: &IpAddr, prefix_len: u8) -> bool {
|
|
match (ip, network) {
|
|
(IpAddr::V4(ip), IpAddr::V4(net)) => {
|
|
let ip_bits = u32::from(*ip);
|
|
let net_bits = u32::from(*net);
|
|
let mask = if prefix_len == 0 {
|
|
0
|
|
} else if prefix_len >= 32 {
|
|
u32::MAX
|
|
} else {
|
|
u32::MAX << (32 - prefix_len)
|
|
};
|
|
(ip_bits & mask) == (net_bits & mask)
|
|
}
|
|
(IpAddr::V6(ip), IpAddr::V6(net)) => {
|
|
let ip_bits = u128::from(*ip);
|
|
let net_bits = u128::from(*net);
|
|
let mask = if prefix_len == 0 {
|
|
0
|
|
} else if prefix_len >= 128 {
|
|
u128::MAX
|
|
} else {
|
|
u128::MAX << (128 - prefix_len)
|
|
};
|
|
(ip_bits & mask) == (net_bits & mask)
|
|
}
|
|
_ => false, // V4/V6 mismatch
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Errors
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum JwtError {
|
|
#[error("no signing key configured")]
|
|
NoSigningKey,
|
|
|
|
#[error("missing JWT token")]
|
|
MissingToken,
|
|
|
|
#[error("JWT error: {0}")]
|
|
Jwt(#[from] jsonwebtoken::errors::Error),
|
|
|
|
#[error("JWT token missing required fid claim")]
|
|
MissingFileIdClaim,
|
|
|
|
#[error("file ID mismatch: expected {expected}, got {got}")]
|
|
FileIdMismatch { expected: String, got: String },
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_jwt_round_trip() {
|
|
let key = SigningKey::from_string("test-secret-key");
|
|
let token = gen_jwt(&key, 3600, "3,01637037d6").unwrap();
|
|
let claims = decode_jwt(&key, &token).unwrap();
|
|
assert_eq!(claims.fid, Some("3,01637037d6".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_jwt_no_signing_key() {
|
|
let key = SigningKey(vec![]);
|
|
assert!(gen_jwt(&key, 3600, "1,abc").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_jwt_invalid_token() {
|
|
let key = SigningKey::from_string("secret");
|
|
let result = decode_jwt(&key, "invalid.token.here");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_jwt_wrong_key() {
|
|
let key1 = SigningKey::from_string("secret1");
|
|
let key2 = SigningKey::from_string("secret2");
|
|
let token = gen_jwt(&key1, 3600, "1,abc").unwrap();
|
|
assert!(decode_jwt(&key2, &token).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_guard_empty_whitelist() {
|
|
let guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0);
|
|
assert!(guard.check_whitelist("192.168.1.1:8080"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_guard_whitelist_exact() {
|
|
let guard = Guard::new(
|
|
&["192.168.1.1".to_string(), "10.0.0.1".to_string()],
|
|
SigningKey(vec![]),
|
|
0,
|
|
SigningKey(vec![]),
|
|
0,
|
|
);
|
|
assert!(guard.check_whitelist("192.168.1.1:8080"));
|
|
assert!(guard.check_whitelist("10.0.0.1:1234"));
|
|
assert!(!guard.check_whitelist("172.16.0.1:8080"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_guard_whitelist_cidr() {
|
|
let guard = Guard::new(
|
|
&["10.0.0.0/8".to_string()],
|
|
SigningKey(vec![]),
|
|
0,
|
|
SigningKey(vec![]),
|
|
0,
|
|
);
|
|
assert!(guard.check_whitelist("10.1.2.3:8080"));
|
|
assert!(guard.check_whitelist("10.255.255.255:80"));
|
|
assert!(!guard.check_whitelist("11.0.0.1:80"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_guard_check_jwt_disabled() {
|
|
let guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0);
|
|
// No signing key = security disabled
|
|
assert!(guard.check_jwt(None, true).is_ok());
|
|
assert!(guard.check_jwt(None, false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_guard_check_jwt_enabled() {
|
|
let key = SigningKey::from_string("write-secret");
|
|
let read_key = SigningKey::from_string("read-secret");
|
|
let guard = Guard::new(&[], key.clone(), 3600, read_key.clone(), 3600);
|
|
|
|
// Missing token
|
|
assert!(guard.check_jwt(None, true).is_err());
|
|
|
|
// Valid write token
|
|
let token = gen_jwt(&key, 3600, "1,abc").unwrap();
|
|
assert!(guard.check_jwt(Some(&token), true).is_ok());
|
|
|
|
// Write token for read should fail (different key)
|
|
assert!(guard.check_jwt(Some(&token), false).is_err());
|
|
|
|
// Valid read token
|
|
let read_token = gen_jwt(&read_key, 3600, "1,abc").unwrap();
|
|
assert!(guard.check_jwt(Some(&read_token), false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_guard_check_jwt_file_id() {
|
|
let key = SigningKey::from_string("secret");
|
|
let guard = Guard::new(&[], key.clone(), 3600, SigningKey(vec![]), 0);
|
|
|
|
let token = gen_jwt(&key, 3600, "3,01637037d6").unwrap();
|
|
|
|
// Correct file ID
|
|
assert!(
|
|
guard
|
|
.check_jwt_for_file(Some(&token), "3,01637037d6", true)
|
|
.is_ok()
|
|
);
|
|
|
|
// Wrong file ID
|
|
let err = guard.check_jwt_for_file(Some(&token), "4,deadbeef", true);
|
|
assert!(matches!(err, Err(JwtError::FileIdMismatch { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_signing_keys_rotates() {
|
|
let mut guard = Guard::new(
|
|
&[],
|
|
SigningKey::from_string("old-write"),
|
|
10,
|
|
SigningKey::from_string("old-read"),
|
|
60,
|
|
);
|
|
|
|
guard.update_signing_keys(
|
|
SigningKey::from_string("new-write"),
|
|
11,
|
|
SigningKey::from_string("new-read"),
|
|
61,
|
|
);
|
|
|
|
let token = gen_jwt(&guard.signing_key, 60, "3,01637037d6").unwrap();
|
|
// Validates with the rotated key, not the old one.
|
|
assert!(decode_jwt(&guard.signing_key, &token).is_ok());
|
|
assert!(decode_jwt(&SigningKey::from_string("old-write"), &token).is_err());
|
|
assert_eq!(guard.expires_after_sec, 11);
|
|
assert_eq!(guard.read_expires_after_sec, 61);
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_signing_keys_toggles_write_active() {
|
|
let mut guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0);
|
|
assert!(guard.check_jwt(None, true).is_ok());
|
|
|
|
guard.update_signing_keys(SigningKey::from_string("write"), 10, SigningKey(vec![]), 0);
|
|
assert!(guard.is_write_active);
|
|
|
|
guard.update_signing_keys(SigningKey(vec![]), 0, SigningKey(vec![]), 0);
|
|
assert!(!guard.is_write_active);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_host() {
|
|
assert_eq!(extract_host("192.168.1.1:8080"), "192.168.1.1");
|
|
assert_eq!(extract_host("[::1]:8080"), "::1");
|
|
assert_eq!(extract_host("localhost"), "localhost");
|
|
}
|
|
|
|
#[test]
|
|
fn test_ip_in_cidr() {
|
|
let net: IpAddr = "10.0.0.0".parse().unwrap();
|
|
let ip1: IpAddr = "10.1.2.3".parse().unwrap();
|
|
let ip2: IpAddr = "11.0.0.1".parse().unwrap();
|
|
assert!(ip_in_cidr(&ip1, &net, 8));
|
|
assert!(!ip_in_cidr(&ip2, &net, 8));
|
|
}
|
|
}
|