mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
rust volume: gate the remaining admin RPCs behind check_grpc_admin_auth
The Go volume server gates 29 destructive VolumeServer RPCs on the
-whiteList admin check; the Rust port only gated 14. Add the gate to the
other 15 -- batch_delete, read_all_needles, fetch_and_write_needle, the
EC-shard generate/rebuild/copy/unmount/to-volume RPCs, both tier-move RPCs,
volume_copy, volume_tail_receiver, set_state, scrub_ec_volume and
volume_needle_status -- so a configured whitelist restricts them the same
way it already does on the Go side.
check_grpc_admin_auth also required peer info before checking whether any
control was configured, unlike Go's `if vs.guard == nil { return nil }`.
Short-circuit when no whitelist and no signing key are set, so in-process
callers keep working with security inactive and only the gate ordering
changes for configured servers.
tests/admin_auth_coverage.rs mirrors the Go coverage test: every handler
must either gate or be listed as intentionally open with a reason, so the
two implementations can't silently drift apart again.
546 lines
17 KiB
Rust
546 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::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
|
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('[') {
|
|
if 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));
|
|
}
|
|
}
|