ip whitelist
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
use ipnet::IpNet;
|
||||
use std::{io, net::IpAddr, path::Path};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct CheckResult {
|
||||
pub matched: bool,
|
||||
pub entries: Vec<IpNet>,
|
||||
}
|
||||
|
||||
/// Reads and evaluates the owner-managed IP whitelist on every call so a
|
||||
/// read-only bind mount can be updated without restarting the service.
|
||||
pub fn check(path: &Path, ip: IpAddr) -> io::Result<CheckResult> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
let mut networks = Vec::new();
|
||||
for (index, raw) in contents.lines().enumerate() {
|
||||
let value = raw.split('#').next().unwrap_or("").trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
networks.push(parse_entry(value).map_err(|message| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("line {}: {message}", index + 1),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
Ok(CheckResult {
|
||||
matched: networks.iter().any(|network| network.contains(&ip)),
|
||||
entries: networks,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_entry(value: &str) -> Result<IpNet, String> {
|
||||
if value.contains('/') {
|
||||
value.parse::<IpNet>().map_err(|error| error.to_string())
|
||||
} else {
|
||||
value
|
||||
.parse::<IpAddr>()
|
||||
.map(IpNet::from)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_addresses_cidrs_comments_and_live_edits() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ips.txt");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"# owner overrides\n100.64.0.7\n192.0.2.0/24 # office\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(check(&path, "100.64.0.7".parse().unwrap()).unwrap().matched);
|
||||
assert!(check(&path, "192.0.2.25".parse().unwrap()).unwrap().matched);
|
||||
assert!(
|
||||
!check(&path, "203.0.113.9".parse().unwrap())
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
|
||||
std::fs::write(&path, "203.0.113.9\n").unwrap();
|
||||
assert!(
|
||||
check(&path, "203.0.113.9".parse().unwrap())
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_file_fails_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ips.txt");
|
||||
std::fs::write(&path, "127.0.0.1\nnot-an-ip\n").unwrap();
|
||||
assert_eq!(
|
||||
check(&path, "127.0.0.1".parse().unwrap())
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod client_ip;
|
||||
pub mod ip_whitelist;
|
||||
pub mod join_tracker;
|
||||
pub mod whitelist;
|
||||
|
||||
@@ -39,6 +40,20 @@ impl AuthorizationSnapshot {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_ip.is_empty()
|
||||
}
|
||||
pub fn allowed_ips(&self, whitelist: &Whitelist) -> Vec<IpAddr> {
|
||||
let mut ips = self
|
||||
.by_ip
|
||||
.iter()
|
||||
.filter_map(|(ip, records)| {
|
||||
records
|
||||
.iter()
|
||||
.any(|record| whitelist.contains(record.uuid.as_deref(), &record.name))
|
||||
.then_some(*ip)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ips.sort();
|
||||
ips
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -110,6 +125,11 @@ impl AuthorizationService {
|
||||
pub fn rescan_count(&self) -> u64 {
|
||||
self.rescan_count.load(Ordering::Relaxed)
|
||||
}
|
||||
pub async fn allowed_ips(&self) -> Vec<IpAddr> {
|
||||
let snapshot = self.snapshot.read().await;
|
||||
let whitelist = self.whitelist.read().await;
|
||||
snapshot.allowed_ips(&whitelist)
|
||||
}
|
||||
async fn check(&self, ip: IpAddr) -> bool {
|
||||
let snapshot = self.snapshot.read().await;
|
||||
let whitelist = self.whitelist.read().await;
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct Config {
|
||||
pub log_dir: PathBuf,
|
||||
pub latest_log: PathBuf,
|
||||
pub whitelist: PathBuf,
|
||||
pub ip_whitelist_file: Option<PathBuf>,
|
||||
pub trust_proxy: bool,
|
||||
pub trusted_proxy_cidrs: Vec<IpNet>,
|
||||
pub client_ip_header: String,
|
||||
@@ -37,6 +38,9 @@ impl Config {
|
||||
let log_dir = path("MC_LOG_DIR", data_dir.join("logs"));
|
||||
let latest_log = path("MC_LATEST_LOG", log_dir.join("latest.log"));
|
||||
let whitelist = path("MC_WHITELIST", data_dir.join("whitelist.json"));
|
||||
let ip_whitelist_file = env::var_os("IP_WHITELIST_FILE")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let trust_proxy = boolean("TRUST_PROXY", false)?;
|
||||
let trusted_proxy_cidrs = csv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128")
|
||||
.into_iter()
|
||||
@@ -67,6 +71,7 @@ impl Config {
|
||||
log_dir,
|
||||
latest_log,
|
||||
whitelist,
|
||||
ip_whitelist_file,
|
||||
trust_proxy,
|
||||
trusted_proxy_cidrs,
|
||||
client_ip_header: env::var("CLIENT_IP_HEADER")
|
||||
|
||||
+91
-7
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
auth::{client_ip::ClientIpPolicy, AuthorizationService},
|
||||
auth::{client_ip::ClientIpPolicy, ip_whitelist, AuthorizationService},
|
||||
config::Config,
|
||||
logs::{follower::LiveMessage, index::HistoryStore},
|
||||
};
|
||||
@@ -142,17 +142,101 @@ async fn authorize(
|
||||
peer: SocketAddr,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<IpAddr, Response> {
|
||||
validate_origin(&state.config.public_origin, headers)
|
||||
.map_err(|_| error(StatusCode::FORBIDDEN, "origin_denied"))?;
|
||||
validate_origin(&state.config.public_origin, headers).map_err(|_| {
|
||||
tracing::debug!(
|
||||
event = "authorization_decision",
|
||||
result = "denied",
|
||||
reason = "origin_denied",
|
||||
peer_ip = %peer.ip()
|
||||
);
|
||||
error(StatusCode::FORBIDDEN, "origin_denied")
|
||||
})?;
|
||||
let ip = state
|
||||
.ip_policy
|
||||
.extract(peer.ip(), headers)
|
||||
.map_err(|_| error(StatusCode::BAD_REQUEST, "invalid_client_ip"))?;
|
||||
.map_err(|extract_error| {
|
||||
tracing::debug!(
|
||||
event = "authorization_decision",
|
||||
result = "denied",
|
||||
reason = "invalid_client_ip",
|
||||
peer_ip = %peer.ip(),
|
||||
error = %extract_error
|
||||
);
|
||||
error(StatusCode::BAD_REQUEST, "invalid_client_ip")
|
||||
})?;
|
||||
if state.auth.is_allowed(ip).await {
|
||||
Ok(ip)
|
||||
} else {
|
||||
Err(error(StatusCode::FORBIDDEN, "access_denied"))
|
||||
let allowed_player_ips = state.auth.allowed_ips().await;
|
||||
tracing::debug!(
|
||||
event = "authorization_decision",
|
||||
result = "allowed",
|
||||
source = "player_join",
|
||||
client_ip = %ip,
|
||||
peer_ip = %peer.ip(),
|
||||
?allowed_player_ips
|
||||
);
|
||||
return Ok(ip);
|
||||
}
|
||||
let allowed_player_ips = state.auth.allowed_ips().await;
|
||||
if let Some(path) = state.config.ip_whitelist_file.clone() {
|
||||
let log_path = path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || ip_whitelist::check(&path, ip)).await;
|
||||
match result {
|
||||
Ok(Ok(check)) if check.matched => {
|
||||
tracing::debug!(
|
||||
event = "authorization_decision",
|
||||
result = "allowed",
|
||||
source = "ip_whitelist_file",
|
||||
client_ip = %ip,
|
||||
peer_ip = %peer.ip(),
|
||||
path = %log_path.display(),
|
||||
allowed_override_networks = ?check.entries,
|
||||
?allowed_player_ips
|
||||
);
|
||||
return Ok(ip);
|
||||
}
|
||||
Ok(Ok(check)) => tracing::debug!(
|
||||
event = "ip_whitelist_checked",
|
||||
result = "no_match",
|
||||
client_ip = %ip,
|
||||
path = %log_path.display(),
|
||||
allowed_override_networks = ?check.entries,
|
||||
?allowed_player_ips
|
||||
),
|
||||
Ok(Err(error)) => tracing::warn!(
|
||||
event = "ip_whitelist_read_failed",
|
||||
client_ip = %ip,
|
||||
path = %log_path.display(),
|
||||
?allowed_player_ips,
|
||||
%error
|
||||
),
|
||||
Err(error) => tracing::warn!(
|
||||
event = "ip_whitelist_check_failed",
|
||||
client_ip = %ip,
|
||||
path = %log_path.display(),
|
||||
?allowed_player_ips,
|
||||
%error
|
||||
),
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
event = "ip_whitelist_disabled",
|
||||
client_ip = %ip,
|
||||
?allowed_player_ips
|
||||
);
|
||||
}
|
||||
tracing::debug!(
|
||||
event = "authorization_decision",
|
||||
result = "denied",
|
||||
reason = "no_authorization_match",
|
||||
client_ip = %ip,
|
||||
peer_ip = %peer.ip(),
|
||||
?allowed_player_ips
|
||||
);
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({"error":"access_denied", "client_ip":ip})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
fn validate_origin(expected: &str, headers: &HeaderMap) -> Result<(), ()> {
|
||||
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||
|
||||
+11
-1
@@ -62,7 +62,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
let listener = tokio::net::TcpListener::bind(config.listen_addr)
|
||||
.await
|
||||
.context("HTTP bind failed")?;
|
||||
tracing::info!(event="application_startup",listen_addr=%config.listen_addr);
|
||||
let ip_whitelist_file = config
|
||||
.ip_whitelist_file
|
||||
.as_deref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "disabled".into());
|
||||
tracing::info!(
|
||||
event = "application_startup",
|
||||
listen_addr = %config.listen_addr,
|
||||
trust_proxy = config.trust_proxy,
|
||||
ip_whitelist_file
|
||||
);
|
||||
axum::serve(
|
||||
listener,
|
||||
http::router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
|
||||
Reference in New Issue
Block a user