feat: velocity routing, server registry and RampartVelocity tests
This commit is contained in:
parent
fa6de281fb
commit
29ce6fb8e9
41 changed files with 1239 additions and 532 deletions
|
|
@ -1,21 +1,104 @@
|
|||
use crate::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{ConnectInfo, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
const LOGIN_WINDOW: Duration = Duration::from_secs(60);
|
||||
const LOGIN_MAX_ATTEMPTS: u32 = 5;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn login(State(state): State<Arc<AppState>>, Json(req): Json<LoginRequest>) -> Json<serde_json::Value> {
|
||||
let api_password = std::env::var("API_PASSWORD").unwrap_or_else(|_| "changeme".to_string());
|
||||
if req.password != api_password {
|
||||
return Json(serde_json::json!({"error": "invalid password"}));
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
if !allow_login_attempt(&state.login_limiter, addr.ip()) {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(serde_json::json!({"error": "too many requests"})),
|
||||
));
|
||||
}
|
||||
|
||||
match crate::auth::create_token(&state.jwt_secret, state.jwt_expiration) {
|
||||
Ok(token) => Json(serde_json::json!({"token": token})),
|
||||
Err(_) => Json(serde_json::json!({"error": "token creation failed"})),
|
||||
if !verify_password(&req.password, &state.api_password) {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "invalid password"})),
|
||||
));
|
||||
}
|
||||
|
||||
match crate::auth::create_token(&state.jwt_secret, state.jwt_expiration, &state.jwt_audience) {
|
||||
Ok(token) => Ok(Json(serde_json::json!({"token": token}))),
|
||||
Err(_) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "token creation failed"})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_password(provided: &str, expected: &str) -> bool {
|
||||
let provided = provided.as_bytes();
|
||||
let expected = expected.as_bytes();
|
||||
let len_match = (provided.len() as u64).ct_eq(&(expected.len() as u64));
|
||||
let min_len = provided.len().min(expected.len());
|
||||
let bytes_match = provided[..min_len].ct_eq(&expected[..min_len]);
|
||||
bool::from(len_match & bytes_match)
|
||||
}
|
||||
|
||||
fn allow_login_attempt(limiter: &DashMap<IpAddr, (Instant, u32)>, ip: IpAddr) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut slot = limiter.entry(ip).or_insert((now, 0));
|
||||
let (last_reset, attempts) = &mut *slot;
|
||||
if now.duration_since(*last_reset) >= LOGIN_WINDOW {
|
||||
*last_reset = now;
|
||||
*attempts = 1;
|
||||
} else if *attempts >= LOGIN_MAX_ATTEMPTS {
|
||||
return false;
|
||||
} else {
|
||||
*attempts += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verify_password_matches() {
|
||||
assert!(verify_password("s3cret-pass", "s3cret-pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_password_rejects_wrong() {
|
||||
assert!(!verify_password("wrong-pass", "s3cret-pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_password_rejects_different_length() {
|
||||
assert!(!verify_password("short", "a-longer-password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_login_attempt_respects_limit() {
|
||||
let limiter = DashMap::new();
|
||||
let ip = IpAddr::from([127, 0, 0, 1]);
|
||||
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||
assert!(allow_login_attempt(&limiter, ip));
|
||||
}
|
||||
assert!(!allow_login_attempt(&limiter, ip));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,26 +10,28 @@ use std::sync::Arc;
|
|||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub aud: String,
|
||||
pub role: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
}
|
||||
|
||||
pub fn create_token(secret: &str, expiration: u64) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
pub fn create_token(secret: &str, expiration: u64, audience: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let now = chrono::Utc::now().timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: "rampart-admin".to_string(),
|
||||
aud: audience.to_string(),
|
||||
role: "admin".to_string(),
|
||||
exp: now + expiration as usize,
|
||||
iat: now,
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
pub fn verify_token(token: &str, secret: &str, audience: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[audience]);
|
||||
let token_data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_ref()), &validation)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ pub async fn auth_middleware(
|
|||
},
|
||||
};
|
||||
|
||||
if verify_token(token, &state.jwt_secret).is_err() {
|
||||
if verify_token(token, &state.jwt_secret, &state.jwt_audience).is_err() {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "unauthorized"})),
|
||||
|
|
@ -72,3 +74,38 @@ pub async fn auth_middleware(
|
|||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_secret() -> String {
|
||||
"this-is-a-test-secret-32-bytes-long!".to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_verify_roundtrip_passes() {
|
||||
let secret = valid_secret();
|
||||
let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed");
|
||||
let claims = verify_token(&token, &secret, "rampart").expect("verification should succeed");
|
||||
assert_eq!(claims.sub, "rampart-admin");
|
||||
assert_eq!(claims.aud, "rampart");
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert!(claims.exp > claims.iat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_audience() {
|
||||
let secret = valid_secret();
|
||||
let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed");
|
||||
assert!(verify_token(&token, &secret, "other").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_secret() {
|
||||
let secret = valid_secret();
|
||||
let other_secret = "another-test-secret-also-32-bytes-long!".to_string();
|
||||
let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed");
|
||||
assert!(verify_token(&token, &other_secret, "rampart").is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
use axum::{
|
||||
Router, middleware,
|
||||
Router,
|
||||
http::HeaderValue,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use dashmap::DashMap;
|
||||
use std::{net::IpAddr, sync::Arc, time::Instant};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
|
|
@ -13,7 +16,10 @@ mod sync;
|
|||
pub struct AppState {
|
||||
pub redis_client: redis::Client,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_audience: String,
|
||||
pub jwt_expiration: u64,
|
||||
pub api_password: String,
|
||||
pub login_limiter: DashMap<IpAddr, (Instant, u32)>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -26,15 +32,27 @@ async fn main() -> anyhow::Result<()> {
|
|||
let redis_client = redis::Client::open(redis_url)?;
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").map_err(|_| anyhow::anyhow!("JWT_SECRET must be set"))?;
|
||||
if jwt_secret.len() < 32 {
|
||||
return Err(anyhow::anyhow!("JWT_SECRET must be at least 32 bytes"));
|
||||
}
|
||||
let jwt_audience = std::env::var("JWT_AUDIENCE").unwrap_or_else(|_| "rampart".to_string());
|
||||
let jwt_expiration = std::env::var("JWT_EXPIRATION_SECS")
|
||||
.unwrap_or_else(|_| "86400".to_string())
|
||||
.parse::<u64>()
|
||||
.map_err(|_| anyhow::anyhow!("JWT_EXPIRATION_SECS must be a valid u64"))?;
|
||||
|
||||
let api_password = std::env::var("API_PASSWORD").map_err(|_| anyhow::anyhow!("API_PASSWORD must be set"))?;
|
||||
if api_password == "changeme" {
|
||||
return Err(anyhow::anyhow!("API_PASSWORD must not be the default 'changeme'"));
|
||||
}
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
redis_client,
|
||||
jwt_secret,
|
||||
jwt_audience,
|
||||
jwt_expiration,
|
||||
api_password,
|
||||
login_limiter: DashMap::new(),
|
||||
});
|
||||
|
||||
tokio::spawn(sync::heartbeat::start_heartbeat_check(state.clone()));
|
||||
|
|
@ -52,15 +70,22 @@ async fn main() -> anyhow::Result<()> {
|
|||
.route("/api/v1/nodes", get(api::nodes::list_nodes))
|
||||
.route_layer(middleware::from_fn(auth::auth_middleware));
|
||||
|
||||
let cors = match std::env::var("CORS_ORIGIN") {
|
||||
Ok(origin) if origin.is_empty() || origin == "*" => CorsLayer::new().allow_origin(tower_http::cors::Any),
|
||||
Ok(origin) => CorsLayer::new().allow_origin(HeaderValue::from_str(&origin)?),
|
||||
Err(_) => CorsLayer::new().allow_origin(HeaderValue::from_static("http://localhost:5173")),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public)
|
||||
.merge(protected)
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
let addr = "0.0.0.0:8080";
|
||||
tracing::info!("Manager API listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue