[READ-ONLY] Mirror of https://github.com/FoxxMD/docker-proxy-filter. Filter the contents of Docker API responses
docker docker-socket-proxy filter
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement label filtering

FoxxMD (Oct 9, 2025, 6:07 PM UTC) 4cf6c4db d59a56a5

+162 -47
+44 -6
src/config.rs
··· 1 - use dotenvy; 2 1 use serde::Deserialize; 3 2 use tracing::*; 3 + use std::collections::HashMap; 4 4 5 5 fn default_scrub() -> bool { 6 6 false ··· 8 8 fn default_port() -> u16 { 9 9 2375 10 10 } 11 + fn default_list() -> Vec<String> { 12 + Vec::new() 13 + } 11 14 12 - #[derive(Deserialize, Debug)] 13 - pub struct Config { 15 + #[derive(Deserialize, Debug, Clone)] 16 + pub struct EnvConfig { 14 17 pub proxy_url: String, 18 + #[serde(default = "default_list")] 15 19 pub container_names: Vec<String>, 20 + #[serde(default = "default_list")] 21 + pub container_labels: Vec<String>, 16 22 #[serde(default = "default_scrub")] 17 23 pub scrub_envs: bool, 18 24 #[serde(default = "default_port")] 19 25 pub port: u16, 20 26 } 21 27 28 + pub type ContainerLabels = HashMap<String, Option<String>>; 29 + pub type ContainerNames = Vec<String>; 30 + 31 + #[derive(Clone)] 32 + pub struct AppConfig { 33 + pub proxy_url: String, 34 + pub container_names: ContainerNames, 35 + pub scrub_envs: bool, 36 + pub container_labels: ContainerLabels 37 + } 38 + 39 + impl From<&EnvConfig> for AppConfig { 40 + fn from(item: &EnvConfig) -> Self { 41 + 42 + let mut label_map: ContainerLabels = HashMap::<String, Option<String>>::new(); 43 + for label_kv in item.container_labels.iter() { 44 + if let Some((k, v)) = label_kv.split_once('=') { 45 + label_map.insert(k.to_string(), Some(v.to_string())); 46 + } else { 47 + label_map.insert(label_kv.clone(), None); 48 + } 49 + } 50 + 51 + AppConfig { 52 + proxy_url: item.proxy_url.clone(), 53 + container_names: item.container_names.clone(), 54 + scrub_envs: item.scrub_envs, 55 + container_labels: label_map.clone() 56 + } 57 + } 58 + } 59 + 22 60 pub fn loadenv() -> Result<(), dotenvy::Error> { 23 61 match dotenvy::dotenv() { 24 62 Ok(_) => Ok(()), ··· 33 71 } 34 72 } 35 73 36 - pub fn get_config() -> Result<Config, envy::Error> { 37 - match envy::from_env::<Config>() { 74 + pub fn get_config() -> Result<(AppConfig, u16), envy::Error> { 75 + match envy::from_env::<EnvConfig>() { 38 76 Ok(config) => { 39 77 info!("{:#?}", config); 40 - Ok(config) 78 + Ok((AppConfig::from(&config), config.port)) 41 79 } 42 80 Err(error) => { 43 81 error!("Could not parse envs correctly! {:#?}", error);
+29 -17
src/docker.rs
··· 1 1 use std::sync::LazyLock; 2 2 use regex::Regex; 3 + use std::collections::HashMap; 4 + 5 + use crate::config::{ContainerLabels, ContainerNames}; 6 + use crate::utils; 3 7 4 8 pub mod types; 5 9 ··· 12 16 static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"containers/(?<id>.+)/(?<resource>.+)").unwrap()); 13 17 let captures = RE.captures(haystack); 14 18 15 - let get_match = match captures { 19 + match captures { 16 20 Some(cap) => { 17 21 Some(GetMatch { 18 22 id: String::from(cap.name("id").unwrap().as_str()), ··· 22 26 None => { 23 27 None 24 28 } 25 - }; 26 - get_match 29 + } 27 30 } 28 31 29 - pub fn is_container_named(container: &types::ContainerSummary, container_names: &Vec<String>) -> bool { 30 - return Option::is_some(&container.names.clone().unwrap().iter().find(|&y| { 31 - return container_names.iter().any(|z| y.contains(z)); 32 - })); 32 + pub fn match_labels_or_names(filter_names: &ContainerNames, filter_labels: &ContainerLabels, names: &Vec<String>, labels: &HashMap<String, String>) -> bool { 33 + if filter_names.is_empty() && filter_labels.is_empty() { 34 + return true 35 + } 36 + let mut any = false; 37 + if !filter_names.is_empty() { 38 + if utils::strings_in_strings(&names, &filter_names) { 39 + any = true; 40 + } 41 + } 42 + if !filter_labels.is_empty() { 43 + if utils::label_match(&labels, &filter_labels) { 44 + any = true; 45 + } 46 + } 47 + any 33 48 } 34 49 35 - pub async fn get_container_name(u: &String, container_id: &String) -> Result<String, Box<dyn std::error::Error>> { 50 + pub fn container_summary_match(container: &types::ContainerSummary, container_names: &ContainerNames, container_labels: &ContainerLabels) -> bool { 51 + 52 + return match_labels_or_names(container_names, container_labels, &container.names.as_ref().unwrap_or(Vec::new().as_ref()), &container.labels.as_ref().unwrap_or(&HashMap::<String,String>::new())) 53 + } 54 + 55 + pub async fn get_container_info(u: &String, container_id: &String) -> Result<(String, HashMap<String,String>), Box<dyn std::error::Error>> { 36 56 37 57 let url = format!("{}containers/{id}/json", u, id = container_id); 38 - //debug!(log, "Get Container URL: {}", url); 39 58 let resp = reqwest::get(&url) 40 59 .await? 41 60 .json::<types::ContainerInspect>() ··· 45 64 if json_res.message.is_some() { 46 65 return Err(json_res.message.unwrap().into()) 47 66 } 48 - match json_res.name { 49 - Some(v) => { 50 - Ok(v) 51 - } 52 - None => { 53 - return Err("Container has no name".into()) 54 - } 55 - } 67 + Ok((json_res.name.expect("Container has a name"), json_res.config.expect("Container has config").labels.expect("Container has labels"))) 56 68 } 57 69 Err(e) => { 58 70 return Err(Box::new(e));
+5
src/docker/types.rs
··· 17 17 18 18 pub message: Option<String>, 19 19 20 + #[serde(rename = "Labels")] 21 + pub labels: Option<HashMap<String,String>>, 22 + 20 23 #[serde(flatten)] 21 24 extra: HashMap<String, Value>, 22 25 } ··· 42 45 pub struct ContainerConfig { 43 46 #[serde(rename = "Env")] 44 47 pub env: Option<Vec<String>>, 48 + #[serde(rename = "Labels")] 49 + pub labels: Option<HashMap<String,String>>, 45 50 46 51 #[serde(flatten)] 47 52 extra: HashMap<String, Value>,
+8 -8
src/main.rs
··· 1 - use tracing::*; 2 - use tracing_subscriber; 3 - 4 1 use ntex::{http, web::{self}}; 5 2 use std::sync::{Arc, Mutex}; 6 3 ··· 9 6 mod config; 10 7 mod docker; 11 8 mod proxy; 9 + mod utils; 12 10 13 11 use proxy::{AppStateWithContainerMap}; 14 12 ··· 27 25 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 28 26 .init(); 29 27 30 - let config = match config::get_config() { 31 - Ok(config) => { 32 - config 28 + let (config, port) = match config::get_config() { 29 + Ok((config, port)) => { 30 + (config, port) 33 31 }, 34 32 Err(_error) => { 35 33 panic!("Unable to start due to invalid envs") ··· 37 35 }; 38 36 39 37 let cm = AppStateWithContainerMap { 40 - container_map: Arc::new(Mutex::new(HashMap::<String, Option<String>>::new())) 38 + container_map: Arc::new(Mutex::new(HashMap::<String, Option<bool>>::new())) 41 39 }; 42 40 43 41 let forward_url = url::Url::parse(&config.proxy_url) ··· 49 47 .state(config.container_names.clone()) 50 48 .state(cm.clone()) 51 49 .state(config.scrub_envs.clone()) 50 + .state(config.clone()) 51 + 52 52 .wrap(web::middleware::Logger::default()) 53 53 .default_service(web::route().to(proxy::forward)) 54 54 }) 55 - .bind(("0.0.0.0", config.port))? 55 + .bind(("0.0.0.0", port))? 56 56 .run() 57 57 .await 58 58 }
+29 -16
src/proxy.rs
··· 5 5 use futures_util::TryStreamExt; 6 6 use ::http::StatusCode; 7 7 8 + use crate::config::{AppConfig}; 8 9 use crate::docker::{self, types::*}; 9 10 10 11 #[derive(Clone)] 11 12 pub struct AppStateWithContainerMap { 12 - pub container_map: Arc<Mutex<HashMap<String, Option<String>>>>, // <- Mutex is necessary to mutate safely across threads 13 + pub container_map: Arc<Mutex<HashMap<String, Option<bool>>>>, // <- Mutex is necessary to mutate safely across threads 14 + } 15 + 16 + macro_rules! is { 17 + ($cond:expr; $if:expr; $else:expr) => { 18 + if $cond { $if } else { $else } 19 + }; 13 20 } 14 21 15 22 pub async fn forward( ··· 17 24 body: ntex::util::Bytes, 18 25 client: web::types::State<http::Client>, 19 26 forward_url: web::types::State<url::Url>, 20 - container_names: web::types::State<Vec<String>>, 21 - scrub_env: web::types::State<bool>, 27 + app_config: web::types::State<AppConfig>, 22 28 data: web::types::State<AppStateWithContainerMap> 23 29 ) -> Result<web::HttpResponse, web::Error> { 24 30 let mut new_url = forward_url.get_ref().clone(); ··· 42 48 43 49 // filter all containers to only those that have values from CONTAINER_NAMES includes in their names 44 50 let filtered_containers = containers.into_iter() 45 - .filter(|&con| docker::is_container_named(con, &container_names.get_ref())) 51 + .filter(|&con| docker::container_summary_match(con, &app_config.get_ref().container_names, &app_config.get_ref().container_labels)) 46 52 .collect::<Vec<&ContainerSummary>>(); 47 53 48 54 let fresp = web::HttpResponse::build(res.status()).json(&filtered_containers); ··· 59 65 let mut cm = data.container_map.lock().unwrap(); 60 66 61 67 // if the map does not already include the container id-name then we try to get it with our own request to docker api 62 - if !cm.contains_key(&m.id) { 68 + // or if we encountered an error last time then try again 69 + if !cm.contains_key(&m.id) || cm.get(&m.id).unwrap().is_none() { 63 70 debug!("Requested container Id not in map, trying to inspect: {}", &m.id); 64 - let name_res = docker::get_container_name(&forward_url.to_string(), &m.id).await; 65 - match name_res { 66 - Ok(name) => { 67 - debug!("Container Id {} has name '{}'", &m.id, &name); 68 - cm.insert(m.id.clone(), Some(name)); 71 + let info_res = docker::get_container_info(&forward_url.to_string(), &m.id).await; 72 + match info_res { 73 + Ok((name, labels)) => { 74 + let is_container_match = docker::match_labels_or_names(&app_config.get_ref().container_names, &app_config.get_ref().container_labels, &Vec::from([name.clone()]), &labels); 75 + debug!("Container Id {} with name '{}' {} valid", &m.id, name, is!(is_container_match; "is";"is not")); 76 + cm.insert(m.id.clone(), Some(is_container_match)); 69 77 } 70 78 Err(e) => { 71 79 warn!("Could not inspect container {}: {e}", &m.id); 72 - cm.insert(m.id.clone(), None); 80 + if e.to_string().contains("No such container") { 81 + cm.insert(m.id.clone(), Some(false)); 82 + } else { 83 + // if the error was not a 404 then allow trying again later 84 + cm.insert(m.id.clone(), None); 85 + } 73 86 } 74 87 } 75 88 } 76 89 77 90 // then we try to get the name from the stateful hashmap 78 - let name_val = cm.get(&m.id).unwrap(); 91 + let allowed = cm.get(&m.id).unwrap(); 79 92 80 - match name_val { 93 + match allowed { 81 94 Some(n) => { 82 95 // only return if a response if requested container has a name that includes values from CONTAINER_NAMES 83 - if container_names.get_ref().iter().any(|x| n.contains(x)) { 96 + if *n { 84 97 85 98 // if the route resource is specifically the Container Inspect API 86 99 // then we may need to scrub Envs if SCRUB_ENVS=true ··· 88 101 89 102 client_resp.content_type("application/json"); 90 103 91 - if *scrub_env.get_ref() { 104 + if app_config.get_ref().scrub_envs { 92 105 let mut container = res.json::<ContainerInspect>().await.unwrap(); 93 106 container.config.as_mut().unwrap().env = Some(Vec::new()); 94 107 Ok(client_resp.json(&container)) ··· 101 114 Ok(client_resp.streaming(res.into_stream())) 102 115 } 103 116 } else { 104 - debug!("Container {} does not include container filter name, 404ing...", &m.id); 117 + debug!("Container {} does not match container filters, 404ing...", &m.id); 105 118 client_resp.status(StatusCode::NOT_FOUND); 106 119 Ok(client_resp.json(&DockerErrorMessage { message: format!("No such container: {}", &m.id)})) 107 120 }
+47
src/utils.rs
··· 1 + use crate::config::ContainerLabels; 2 + use std::collections::HashMap; 3 + use tracing::*; 4 + 5 + pub fn strings_in_strings(ref_strings: &Vec<String>, contains_strings: &Vec<String>) -> bool { 6 + let matched_str = ref_strings.iter().find(|x| contains_strings.iter().any(|y| x.contains(y))); 7 + if matched_str.is_some() { 8 + debug!("One of '{}' found in {}", contains_strings.join(","), matched_str.unwrap()); 9 + } 10 + 11 + matched_str.is_some() 12 + } 13 + 14 + pub fn label_match(ref_map: &HashMap<String,String>, contains_map: &ContainerLabels) -> bool { 15 + if contains_map.is_empty() { 16 + return true 17 + } 18 + if ref_map.is_empty() { 19 + return false 20 + } 21 + 22 + for(k, v) in ref_map.iter() { 23 + if contains_map.iter().any(|(ck, cv)| { 24 + if k.contains(ck) { 25 + let val_match = match cv { 26 + Some(val) => { 27 + if v.contains(val) { 28 + debug!("Label {}={} contains label key filter '{}' and label value filter '{}'", k,v, ck, val); 29 + return true; 30 + } 31 + false 32 + } 33 + None => { 34 + debug!("Label {}={} contains label key filter '{}'", k,v, ck); 35 + true 36 + } 37 + }; 38 + return val_match 39 + } 40 + return false 41 + }) { 42 + return true 43 + } 44 + } 45 + 46 + return false 47 + }