ip whitelist

This commit is contained in:
2026-08-11 02:02:39 +08:00
parent 6e0b0c92d1
commit b62c74b883
10 changed files with 270 additions and 13 deletions
+85
View File
@@ -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
);
}
}
+20
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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>(),