feat: velocity routing, server registry and RampartVelocity tests

This commit is contained in:
loki5512344 2026-08-03 10:54:28 +02:00
parent fa6de281fb
commit 29ce6fb8e9
Signed by: boba
GPG key ID: 253067914055423B
41 changed files with 1239 additions and 532 deletions

View file

@ -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));
}
}

View file

@ -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());
}
}

View file

@ -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(())
}