diff --git a/CHANGELOG.md b/CHANGELOG.md index f88350f62..9f90175cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Output filters are applied after all transformations have been performed, theref - Enhanced STRM output filenames to include detailed media quality info (e.g., 4K, HDR, x265, 5.1) for easy version distinction. - Added standardized SSDP (Simple Service Discovery Protocol) and the Proprietary HDHomeRun UDP Discovery Protocol (Port 65001) - Fixed some session handling issue +- added `reverse_proxy.disabled_header` configuration + Allows removing selected headers before forwarding requests when acting as a reverse proxy. Configure removal of the referer header, all `X-*` headers, and additional custom headers. +- !BREAKING_CHANGE! `disble_referer_header` is now part of `reverse_proxy.disabled_header` configuration # 3.1.7 (2025-10-10) - Added Dark/Bright theme switch diff --git a/README.md b/README.md index 48c23561d..3eb90988b 100644 --- a/README.md +++ b/README.md @@ -305,12 +305,16 @@ reverse_proxy: burst_size: 10 ``` -#### 1.6.5 `disable_referer_header` -This option, when set to `true`, prevents tuliprox from sending the Referer header in requests made when acting as a reverse proxy. This can be particularly useful when dealing with certain Xtream Codes providers that might restrict or behave differently based on the Referer header. Default is `false`. +#### 1.6.5 `disabled_header` +Controls which headers are removed before tuliprox forwards a request to the upstream provider when acting as a reverse proxy. Use `referer_header` to drop the Referer header, enable `x_header` to strip every header beginning with `X-`, and list any additional headers to remove under `custom_header`. ```yaml reverse_proxy: - disable_referer_header: false + disabled_header: + referer_header: false + x_header: false + custom_header: + - my-custom-header ``` ### 1.6.7 `geoip` diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 8f700efe9..f05f983b3 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -506,6 +506,13 @@ async fn create_stream_response_details( | ProviderStreamState::GracePeriod(_provider_name, request_url) => { let parsed_url = Url::parse(&request_url); let ((stream, stream_info), reconnect_flag) = if let Ok(url) = parsed_url { + let disabled_headers = app_state + .app_config + .config + .load() + .reverse_proxy + .as_ref() + .and_then(|r| r.disabled_header.clone()); let provider_stream_factory_options = ProviderStreamFactoryOptions::new( item_type, share_stream, @@ -513,6 +520,7 @@ async fn create_stream_response_details( &url, req_headers, streaming_strategy.input_headers.as_ref(), + disabled_headers.as_ref(), ); let reconnect_flag = provider_stream_factory_options.get_reconnect_flag_clone(); let provider_stream = match create_provider_stream( @@ -1127,6 +1135,11 @@ async fn fetch_resource_with_retry( // TODO: add max_attempts to config let max_attempts: u32 = 3; //&app_state.app_config.config.load().max_attempts; let backoff_ms: u64 = 250; + let config = app_state.app_config.config.load(); + let disabled_headers = config + .reverse_proxy + .as_ref() + .and_then(|r| r.disabled_header.clone()); for attempt in 0..max_attempts { let client = request::get_client_request( &app_state.http_client.load(), @@ -1134,6 +1147,7 @@ async fn fetch_resource_with_retry( input.map(|i| &i.headers), url, Some(req_headers), + disabled_headers.as_ref(), ); match client.send().await { Ok(response) => { diff --git a/backend/src/api/endpoints/download_api.rs b/backend/src/api/endpoints/download_api.rs index a1eea319a..bfc18180b 100644 --- a/backend/src/api/endpoints/download_api.rs +++ b/backend/src/api/endpoints/download_api.rs @@ -1,6 +1,6 @@ use crate::api::model::AppState; use crate::api::model::{DownloadQueue, FileDownload, FileDownloadRequest}; -use crate::model::{AppConfig, Config, VideoDownloadConfig}; +use crate::model::{AppConfig, VideoDownloadConfig}; use crate::utils::request; use tokio::sync::RwLock; use futures::stream::TryStreamExt; @@ -11,8 +11,6 @@ use std::io::{Write}; use std::ops::Deref; use std::sync::Arc; use std::{fs}; -use arc_swap::access::Access; -use arc_swap::ArcSwap; use axum::response::IntoResponse; use shared::utils::bytes_to_megabytes; use shared::error::to_io_error; @@ -76,7 +74,12 @@ async fn run_download_queue(cfg: &AppConfig, download_cfg: &VideoDownloadConfig, let next_download = download_queue.as_ref().queue.lock().await.pop_front(); if next_download.is_some() { { *download_queue.as_ref().active.write().await = next_download; } - let headers = request::get_request_headers(Some(&download_cfg.headers), None); + let config = cfg.config.load(); + let disabled_headers = config + .reverse_proxy + .as_ref() + .and_then(|r| r.disabled_header.clone()); + let headers = request::get_request_headers(Some(&download_cfg.headers), None, disabled_headers.as_ref()); let dq = Arc::clone(download_queue); match create_client(cfg).default_headers(headers).build() { @@ -127,7 +130,7 @@ pub async fn queue_download_file( ) -> impl axum::response::IntoResponse + Send { let app_config = &*app_state.app_config; - let config = > as Access>::load(&app_config.config); + let config = app_config.config.load(); if let Some(video_cfg) = config.video.as_ref() { if let Some(download_cfg) = video_cfg.download.as_ref() { if download_cfg.directory.is_empty() { diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index 821b9f65f..10e69ee96 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -98,10 +98,16 @@ pub(in crate::api) async fn handle_hls_stream_request( // Don't forward Range on playlist fetch; segments use original headers in provider path let filter_header: HeaderFilter = Some(Box::new(|name: &str| !name.eq_ignore_ascii_case("range"))); let forwarded = get_headers_from_request(req_headers, &filter_header); - let headers = request::get_request_headers(None, Some(&forwarded)); + let config = app_state.app_config.config.load(); + let disabled_headers = config + .reverse_proxy + .as_ref() + .and_then(|r| r.disabled_header.clone()); + let headers = request::get_request_headers(None, Some(&forwarded), disabled_headers.as_ref()); let input_source = InputSource::from(input).with_url(request_url); match request::download_text_content( Arc::clone(&app_state.http_client.load()), + disabled_headers.as_ref(), &input_source, Some(&headers), None, diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index 0f4dc5e14..44b41a15e 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -88,7 +88,11 @@ async fn geoip_update(axum::extract::State(app_state): axum::extract::State { let reader = Cursor::new(content); let mut geoip = GeoIp::new(); diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index c71e6dc9e..7bbb8f2ff 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -118,7 +118,12 @@ async fn config_batch_content( // The url is changed at this point, we need the raw url for the batch file if let Some(batch_url) = config_input.t_batch_url.as_ref() { let input_source = InputSource::from(&*config_input).with_url(batch_url.to_owned()); - return match download_text_content(Arc::clone(&app_state.http_client.load()), &input_source, None, None).await { + let config = app_state.app_config.config.load(); + let disabled_headers = config + .reverse_proxy + .as_ref() + .and_then(|r| r.disabled_header.clone()); + return match download_text_content(Arc::clone(&app_state.http_client.load()), disabled_headers.as_ref(), &input_source, None, None).await { Ok((content, _path)) => { // Return CSV with explicit content-type try_unwrap_body!(axum::response::Response::builder() diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index d0e9a7830..3418a193d 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -1071,6 +1071,7 @@ async fn xtream_get_short_epg( let input_source = InputSource::from(&*input).with_url(info_url); return match request::download_text_content( Arc::clone(&app_state.http_client.load()), + None, &input_source, None, None, diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index 77e9d4571..f45729074 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -4,7 +4,7 @@ use crate::api::model::StreamError; use crate::api::model::TimedClientStream; use crate::api::model::{create_channel_unavailable_stream, get_header_filter_for_item_type}; use crate::api::model::{BoxedProviderStream, ProviderStreamFactoryResponse}; -use crate::model::AppConfig; +use crate::model::{AppConfig, ReverseProxyDisabledHeaderConfig}; use crate::tools::atomic_once_flag::AtomicOnceFlag; use crate::utils::debug_if_enabled; use crate::utils::request::{classify_content_type, get_request_headers, MimeCategory}; @@ -50,6 +50,7 @@ impl ProviderStreamFactoryOptions { stream_url: &Url, req_headers: &HeaderMap, input_headers: Option<&HashMap>, + disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, ) -> Self { let buffer_size = if stream_options.buffer_enabled { stream_options.buffer_size @@ -63,7 +64,7 @@ impl ProviderStreamFactoryOptions { req_headers.remove("range"); // We merge configured input headers with the headers from the request. - let headers = get_request_headers(input_headers, Some(&req_headers)); + let headers = get_request_headers(input_headers, Some(&req_headers), disabled_headers); let url = stream_url.clone(); let range_bytes = Arc::new(range_start_bytes.map(AtomicUsize::new)); diff --git a/backend/src/model/config/reverse_proxy.rs b/backend/src/model/config/reverse_proxy.rs index 18184f6dc..d1e52563c 100644 --- a/backend/src/model/config/reverse_proxy.rs +++ b/backend/src/model/config/reverse_proxy.rs @@ -1,11 +1,33 @@ -use shared::model::ReverseProxyConfigDto; +use shared::model::{ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto}; use crate::model::config::cache::CacheConfig; use crate::model::{macros, GeoIpConfig, RateLimitConfig, StreamConfig}; +#[derive(Debug, Clone)] +pub struct ReverseProxyDisabledHeaderConfig { + pub referer_header: bool, + pub x_header: bool, + pub custom_header: Vec, +} + +impl ReverseProxyDisabledHeaderConfig { + pub fn should_remove(&self, header: &str) -> bool { + let header_lc = header.to_ascii_lowercase(); + if self.referer_header && header_lc == "referer" { + return true; + } + if self.x_header && header_lc.starts_with("x-") { + return true; + } + self.custom_header + .iter() + .any(|h| h.trim().eq_ignore_ascii_case(&header_lc)) + } +} + #[derive(Debug, Clone)] pub struct ReverseProxyConfig { pub resource_rewrite_disabled: bool, - pub disable_referer_header: bool, + pub disabled_header: Option, pub stream: Option, pub cache: Option, pub rate_limit: Option, @@ -18,7 +40,11 @@ impl From<&ReverseProxyConfigDto> for ReverseProxyConfig { fn from(dto: &ReverseProxyConfigDto) -> Self { Self { resource_rewrite_disabled: dto.resource_rewrite_disabled, - disable_referer_header: dto.disable_referer_header, + disabled_header: dto.disabled_header.as_ref().map(|d| ReverseProxyDisabledHeaderConfig { + referer_header: d.referer_header, + x_header: d.x_header, + custom_header: d.custom_header.clone(), + }), stream: dto.stream.as_ref().map(Into::into), cache: dto.cache.as_ref().map(Into::into), rate_limit: dto.rate_limit.as_ref().map(Into::into), @@ -31,7 +57,11 @@ impl From<&ReverseProxyConfig> for ReverseProxyConfigDto { fn from(instance: &ReverseProxyConfig) -> Self { Self { resource_rewrite_disabled: instance.resource_rewrite_disabled, - disable_referer_header: instance.disable_referer_header, + disabled_header: instance.disabled_header.as_ref().map(|d| ReverseProxyDisabledHeaderConfigDto { + referer_header: d.referer_header, + x_header: d.x_header, + custom_header: d.custom_header.clone(), + }), stream: instance.stream.as_ref().map(Into::into), cache: instance.cache.as_ref().map(Into::into), rate_limit: instance.rate_limit.as_ref().map(Into::into), diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index 185171304..b2c54ba0b 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -17,7 +17,7 @@ use url::Url; use shared::error::create_tuliprox_error_result; use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind}; use shared::model::{InputFetchMethod, DEFAULT_USER_AGENT}; -use crate::model::{format_elapsed_time, AppConfig, InputSource}; +use crate::model::{format_elapsed_time, AppConfig, InputSource, ReverseProxyDisabledHeaderConfig}; use crate::model::{ConfigInput}; use crate::repository::storage::{get_input_storage_path}; use crate::repository::storage_const; @@ -104,7 +104,7 @@ pub async fn get_input_text_content(client: Arc, input: &InputS debug_if_enabled!("getting input text content working_dir: {}, url: {}", working_dir, sanitize_sensitive_info(&input.url)); if input.url.parse::().is_ok() { - match download_text_content(client, input, None, persist_filepath).await { + match download_text_content(client, None, input, None, persist_filepath).await { Ok((content, _response_url)) => Ok(content), Err(e) => { error!("cant download input url: {} => {}", sanitize_sensitive_info(&input.url), sanitize_sensitive_info(e.to_string().as_str())); @@ -151,7 +151,8 @@ pub fn get_client_request method: InputFetchMethod, headers: Option<&HashMap>, url: &Url, - custom_headers: Option<&HashMap, S>>) -> reqwest::RequestBuilder { + custom_headers: Option<&HashMap, S>>, + disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> reqwest::RequestBuilder { let request = match method { InputFetchMethod::GET => client.get(url.clone()), InputFetchMethod::POST => { @@ -164,16 +165,19 @@ pub fn get_client_request client.post(url.clone()).form(¶ms) } }; - let headers = get_request_headers(headers, custom_headers); + let headers = get_request_headers(headers, custom_headers, disabled_headers); request.headers(headers) } -pub fn get_request_headers(request_headers: Option<&HashMap>, custom_headers: Option<&HashMap, S>>) -> HeaderMap { +pub fn get_request_headers(request_headers: Option<&HashMap>, custom_headers: Option<&HashMap, S>>, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> HeaderMap { let mut headers = HeaderMap::default(); if let Some(req_headers) = request_headers { for (key, value) in req_headers { if let (Ok(key), Ok(value)) = (HeaderName::from_bytes(key.as_bytes()), HeaderValue::from_bytes(value.as_bytes())) { if filter_request_header(key.as_str()) { + if disabled_headers.as_ref().is_some_and(|d| d.should_remove(key.as_str())) { + continue; + } headers.insert(key, value); } } @@ -184,6 +188,9 @@ pub fn get_request_headers(request_header for (key, value) in custom { let key_lc = key.to_lowercase(); if filter_request_header(key_lc.as_str()) { + if disabled_headers.as_ref().is_some_and(|d| d.should_remove(key)) { + continue; + } if header_keys.contains(key_lc.as_str()) { // debug_if_enabled!("Ignoring request header '{}={}'", key_lc, String::from_utf8_lossy(value)); } else if let (Ok(key), Ok(value)) = (HeaderName::from_bytes(key.as_bytes()), HeaderValue::from_bytes(value)) { @@ -226,7 +233,7 @@ pub fn get_local_file_content(file_path: &PathBuf) -> Result { async fn get_remote_content_as_file(client: Arc, input: &ConfigInput, url: &Url, file_path: &Path) -> Result { let start_time = Instant::now(); - let request = get_client_request(&client, input.method, Some(&input.headers), url, None); + let request = get_client_request(&client, input.method, Some(&input.headers), url, None, None); match request.send().await { Ok(response) => { if response.status().is_success() { @@ -266,7 +273,7 @@ pub async fn get_remote_content_as_stream( method: InputFetchMethod, headers: Option<&HashMap> ) -> Result<(DynReader, String), Error> { - let request = get_client_request(&client, method, headers, url, None); + let request = get_client_request(&client, method, headers, url, None, None); let response = request.send().await.map_err(std::io::Error::other)?; if !response.status().is_success() { @@ -302,12 +309,12 @@ pub async fn get_remote_content_as_stream( Ok((reader, response_url)) } -async fn get_remote_content(client: Arc, input: &InputSource, headers: Option<&HeaderMap>, url: &Url) -> Result<(String, String), Error> { +async fn get_remote_content(client: Arc, input: &InputSource, headers: Option<&HeaderMap>, url: &Url, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> Result<(String, String), Error> { let start_time = Instant::now(); let custom_headers = headers.map(|h| { h.iter().map(|(k, v)| (k.as_str().to_string(), v.as_bytes().to_vec())).collect::>()}); - let merged = get_request_headers(Some(&input.headers), custom_headers.as_ref()); + let merged = get_request_headers(Some(&input.headers), custom_headers.as_ref(), disabled_headers); let headers: HashMap = merged.iter().map(|(k, v)| (k.as_str().to_string(), String::from_utf8_lossy(v.as_bytes()).to_string())).collect(); let (mut stream, response_url) = get_remote_content_as_stream(client.clone(), url, input.method, Some(&headers)).await.map_err(|e| str_to_io_error(&format!("Failed to read content: {e}")))?; @@ -342,14 +349,14 @@ async fn download_epg_content_as_file(client: Arc, input: &Conf } } -pub async fn download_text_content(client: Arc, input: &InputSource, headers: Option<&HeaderMap>, persist_filepath: Option) -> Result<(String, String), Error> { +pub async fn download_text_content(client: Arc, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, headers: Option<&HeaderMap>, persist_filepath: Option) -> Result<(String, String), Error> { if let Ok(url) = input.url.parse::() { let result = if url.scheme() == "file" { url.to_file_path().map_or_else(|()| Err(str_to_io_error(&format!("Unknown file {}", sanitize_sensitive_info(&input.url)))), |file_path| get_local_file_content(&file_path).map(|c| (c, url.to_string())), ) } else { - get_remote_content(client, input, headers, &url).await + get_remote_content(client, input, headers, &url, disabled_headers).await }; match result { Ok((content, response_url)) => { @@ -365,9 +372,9 @@ pub async fn download_text_content(client: Arc, input: &InputSo } } -async fn download_json_content(client: Arc, input: &InputSource, persist_filepath: Option) -> Result { +async fn download_json_content(client: Arc, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option) -> Result { debug_if_enabled!("downloading json content from {}", sanitize_sensitive_info(&input.url)); - match download_text_content(client, input, None, persist_filepath).await { + match download_text_content(client, disabled_headers, input, None, persist_filepath).await { Ok((content, _response_url)) => { match serde_json::from_str::(&content) { Ok(value) => Ok(value), @@ -378,8 +385,8 @@ async fn download_json_content(client: Arc, input: &InputSource } } -pub async fn get_input_json_content(client: Arc, input: &InputSource, persist_filepath: Option) -> Result { - match download_json_content(client, input, persist_filepath).await { +pub async fn get_input_json_content(client: Arc, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option) -> Result { + match download_json_content(client, disabled_headers, input, persist_filepath).await { Ok(content) => Ok(content), Err(e) => create_tuliprox_error_result!(TuliproxErrorKind::Notify, "cant download input url: {} => {}", sanitize_sensitive_info(&input.url), sanitize_sensitive_info(e.to_string().as_str())) } @@ -439,7 +446,11 @@ pub fn create_client(cfg: &AppConfig) -> reqwest::ClientBuilder { } if let Some(rp_config) = config.reverse_proxy.as_ref() { - if rp_config.disable_referer_header { + if rp_config + .disabled_header + .as_ref() + .is_some_and(|d| d.referer_header) + { client = client.referer(false); } } diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 2917d7269..3f0fdb243 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -47,7 +47,7 @@ pub fn get_xtream_player_api_info_url(input: &ConfigInput, cluster: XtreamCluste pub async fn get_xtream_stream_info_content(client: Arc, input: &InputSource) -> Result { - match request::download_text_content(client, input, None, None).await { + match request::download_text_content(client, None, input, None, None).await { Ok((content, _response_url)) => Ok(content), Err(err) => Err(err) } @@ -136,11 +136,11 @@ const ACTIONS: [(XtreamCluster, &str, &str); 3] = [ (XtreamCluster::Series, crate::model::XC_ACTION_GET_SERIES_CATEGORIES, crate::model::XC_ACTION_GET_SERIES)]; async fn xtream_login(cfg: &Config, client: &Arc, input: &InputSource, username: &str) -> Result<(), TuliproxError> { - let content = if let Ok(content) = request::get_input_json_content(Arc::clone(client), input, None).await { + let content = if let Ok(content) = request::get_input_json_content(Arc::clone(client), None, input, None).await { content } else { let input_source_account_info = input.with_url(format!("{}&action={}", &input.url, crate::model::XC_ACTION_GET_ACCOUNT_INFO)); - match request::get_input_json_content(Arc::clone(client), &input_source_account_info, None).await { + match request::get_input_json_content(Arc::clone(client), None, &input_source_account_info, None).await { Ok(content) => content, Err(err) => { warn!("Failed to login xtream account {username} {err}"); @@ -220,8 +220,8 @@ pub async fn get_xtream_playlist(cfg: &Config, client: Arc, inp let stream_file_path = crate::utils::prepare_file_path(input.persist.as_deref(), working_dir, format!("{stream}_").as_str()); match futures::join!( - request::get_input_json_content(Arc::clone(&client), &input_source_category, category_file_path), - request::get_input_json_content(Arc::clone(&client), &input_source_stream, stream_file_path) + request::get_input_json_content(Arc::clone(&client), None, &input_source_category, category_file_path), + request::get_input_json_content(Arc::clone(&client), None, &input_source_stream, stream_file_path) ) { (Ok(category_content), Ok(stream_content)) => { match xtream::parse_xtream(input, diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index b65302fce..e24848e59 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -39,7 +39,11 @@ "SANITIZE_SENSITIVE_INFO": "Sanitize sensitive info", "ACTIVE_USER": "Active User", "RESOURCE_REWRITE_DISABLE": "Resource Rewrite disable", - "DISABLE_REFERER_HEADER": "Referer header disable", + "DISABLED_HEADER": "Disabled header", + "REFERER_HEADER": "Remove referer header", + "X_HEADER": "Remove X-* headers", + "CUSTOM_HEADERS": "Custom headers", + "ADD_HEADER": "Add header", "RECONNECT": "Reconnect", "THROTTLE": "Throttle", "GRACE_PERIOD": "Grace period ms", @@ -389,7 +393,10 @@ }, "PROXY": { "RESOURCE_REWRITE_DISABLE": "If activated, the URL rewrite of resources in reverse proxy mode is disabled", - "DISABLE_REFERER_HEADER": "If activated, the referer header is not sent to the provider", + "DISABLED_HEADER": "Remove headers before sending requests to the upstream provider", + "REFERER_HEADER": "If activated, the referer header is not sent to the provider", + "X_HEADER": "If activated, headers starting with X- are removed before proxying the request", + "CUSTOM_HEADERS": "Headers listed here are removed before sending the upstream request", "THROTTLE": "Supported units are KB/s,MB/s,KiB/s,MiB/s,kbps,mbps,Mibps" }, "WEB_UI": { diff --git a/frontend/src/app/components/config/reverse_proxy_config_view.rs b/frontend/src/app/components/config/reverse_proxy_config_view.rs index 76ff75caf..4a613866c 100644 --- a/frontend/src/app/components/config/reverse_proxy_config_view.rs +++ b/frontend/src/app/components/config/reverse_proxy_config_view.rs @@ -1,12 +1,14 @@ +#![allow(clippy::large_enum_variant)] + use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{CacheConfigDto, RateLimitConfigDto, StreamConfigDto, ReverseProxyConfigDto, GeoIpConfigDto}; +use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto}; use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::config::config_page::ConfigForm; use crate::app::components::config::macros::HasFormData; use crate::app::components::{Card}; -use crate::{config_field, config_field_bool, config_field_optional, edit_field_bool, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer}; +use crate::{config_field, config_field_bool, config_field_custom, config_field_optional, edit_field_bool, edit_field_list, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer}; const LABEL_CACHE: &str = "LABEL.CACHE"; const LABEL_ENABLED: &str = "LABEL.ENABLED"; @@ -25,8 +27,13 @@ const LABEL_RATE_LIMIT: &str = "LABEL.RATE_LIMIT"; const LABEL_PERIOD_MILLIS: &str = "LABEL.PERIOD_MILLIS"; const LABEL_BURST_SIZE: &str = "LABEL.BURST_SIZE"; +const LABEL_SETTINGS: &str = "LABEL.SETTINGS"; const LABEL_RESOURCE_REWRITE_DISABLED: &str = "LABEL.RESOURCE_REWRITE_DISABLED"; -const LABEL_DISABLE_REFERER_HEADER: &str = "LABEL.DISABLE_REFERER_HEADER"; +const LABEL_DISABLED_HEADER: &str = "LABEL.DISABLED_HEADER"; +const LABEL_REFERER_HEADER: &str = "LABEL.REFERER_HEADER"; +const LABEL_X_HEADER: &str = "LABEL.X_HEADER"; +const LABEL_CUSTOM_HEADERS: &str = "LABEL.CUSTOM_HEADERS"; +const LABEL_ADD_HEADER: &str = "LABEL.ADD_HEADER"; const LABEL_GEOIP: &str = "LABEL.GEOIP"; const LABEL_URL: &str = "LABEL.URL"; @@ -77,7 +84,16 @@ generate_form_reducer!( action_name: ReverseProxyConfigFormAction, fields { ResourceRewriteDisabled => resource_rewrite_disabled: bool, - DisableRefererHeader => disable_referer_header: bool, + } +); + +generate_form_reducer!( + state: ReverseProxyDisabledHeaderConfigFormState { form: ReverseProxyDisabledHeaderConfigDto }, + action_name: ReverseProxyDisabledHeaderConfigFormAction, + fields { + RefererHeader => referer_header: bool, + XHeader => x_header: bool, + CustomHeader => custom_header: Vec, } ); @@ -90,6 +106,9 @@ pub fn ReverseProxyConfigView() -> Html { let reverse_proxy_state: UseReducerHandle = use_reducer(|| { ReverseProxyConfigFormState { form: ReverseProxyConfigDto::default(), modified: false } }); + let disabled_header_state: UseReducerHandle = use_reducer(|| { + ReverseProxyDisabledHeaderConfigFormState { form: ReverseProxyDisabledHeaderConfigDto::default(), modified: false } + }); let cache_state: UseReducerHandle = use_reducer(|| { CacheConfigFormState { form: CacheConfigDto::default(), modified: false } }); @@ -107,21 +126,39 @@ pub fn ReverseProxyConfigView() -> Html { { let on_form_change = config_view_ctx.on_form_change.clone(); let reverse_proxy_state = reverse_proxy_state.clone(); + let disabled_header_state = disabled_header_state.clone(); let cache_state = cache_state.clone(); let rate_limit_state = rate_limit_state.clone(); let stream_state = stream_state.clone(); let geoip_state = geoip_state.clone(); use_effect_with( - (reverse_proxy_state, cache_state, rate_limit_state, stream_state, geoip_state), - move |(rp, cache, rl, stream, geoip)| { + ( + reverse_proxy_state, + disabled_header_state, + cache_state, + rate_limit_state, + stream_state, + geoip_state, + ), + move |(rp, disabled_header, cache, rl, stream, geoip)| { let mut form = rp.form.clone(); form.cache = Some(cache.form.clone()); form.rate_limit = Some(rl.form.clone()); form.stream = Some(stream.form.clone()); form.geoip = Some(geoip.form.clone()); + form.disabled_header = if disabled_header.form.is_empty() { + None + } else { + Some(disabled_header.form.clone()) + }; - let modified = rp.modified || cache.modified || rl.modified || stream.modified || geoip.modified; + let modified = rp.modified + || disabled_header.modified + || cache.modified + || rl.modified + || stream.modified + || geoip.modified; on_form_change.emit(ConfigForm::ReverseProxy(modified, form)); }, ); @@ -129,6 +166,7 @@ pub fn ReverseProxyConfigView() -> Html { { let reverse_proxy_state = reverse_proxy_state.clone(); + let disabled_header_state = disabled_header_state.clone(); let cache_state = cache_state.clone(); let rate_limit_state = rate_limit_state.clone(); let stream_state = stream_state.clone(); @@ -138,12 +176,14 @@ pub fn ReverseProxyConfigView() -> Html { use_effect_with((reverse_proxy_cfg, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { if let Some(rp) = cfg { reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll((*rp).clone())); + disabled_header_state.dispatch(ReverseProxyDisabledHeaderConfigFormAction::SetAll(rp.disabled_header.as_ref().map_or_else(ReverseProxyDisabledHeaderConfigDto::default, |d| d.clone()))); cache_state.dispatch(CacheConfigFormAction::SetAll(rp.cache.as_ref().map_or_else(CacheConfigDto::default, |c| c.clone()))); rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(rp.rate_limit.as_ref().map_or_else(RateLimitConfigDto::default, |rl| rl.clone()))); stream_state.dispatch(StreamConfigFormAction::SetAll(rp.stream.as_ref().map_or_else(StreamConfigDto::default, |s| s.clone()))); geoip_state.dispatch(GeoIpConfigFormAction::SetAll(rp.geoip.as_ref().map_or_else(GeoIpConfigDto::default, |s| s.clone()))); } else { reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll(ReverseProxyConfigDto::default())); + disabled_header_state.dispatch(ReverseProxyDisabledHeaderConfigFormAction::SetAll(ReverseProxyDisabledHeaderConfigDto::default())); cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default())); rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(RateLimitConfigDto::default())); stream_state.dispatch(StreamConfigFormAction::SetAll(StreamConfigDto::default())); @@ -198,30 +238,68 @@ pub fn ReverseProxyConfigView() -> Html { } }; - let render_view_mode = || { - html! { - <> -
+ let render_settings_view = || { + html! { + +

{translate.t(LABEL_SETTINGS)}

{ config_field_bool!(reverse_proxy_state.form, translate.t(LABEL_RESOURCE_REWRITE_DISABLED), resource_rewrite_disabled) } - { config_field_bool!(reverse_proxy_state.form, translate.t(LABEL_DISABLE_REFERER_HEADER), disable_referer_header) } -
-
+ + } + }; + + let render_settings_edit = || { + html! { + +

{translate.t(LABEL_SETTINGS)}

+ { edit_field_bool!(reverse_proxy_state, translate.t(LABEL_RESOURCE_REWRITE_DISABLED), resource_rewrite_disabled, ReverseProxyConfigFormAction::ResourceRewriteDisabled) } +
+ } + }; + + let render_disabled_header_view = || { + let custom_headers = if disabled_header_state.form.custom_header.is_empty() { + "-".to_string() + } else { + disabled_header_state.form.custom_header.join(", ") + }; + html! { + +

{translate.t(LABEL_DISABLED_HEADER)}

+ { config_field_bool!(disabled_header_state.form, translate.t(LABEL_REFERER_HEADER), referer_header) } + { config_field_bool!(disabled_header_state.form, translate.t(LABEL_X_HEADER), x_header) } + { config_field_custom!(translate.t(LABEL_CUSTOM_HEADERS), custom_headers) } +
+ } + }; + + let render_disabled_header_edit = || { + html! { + +

{translate.t(LABEL_DISABLED_HEADER)}

+ { edit_field_bool!(disabled_header_state, translate.t(LABEL_REFERER_HEADER), referer_header, ReverseProxyDisabledHeaderConfigFormAction::RefererHeader) } + { edit_field_bool!(disabled_header_state, translate.t(LABEL_X_HEADER), x_header, ReverseProxyDisabledHeaderConfigFormAction::XHeader) } + { edit_field_list!(disabled_header_state, translate.t(LABEL_CUSTOM_HEADERS), custom_header, ReverseProxyDisabledHeaderConfigFormAction::CustomHeader, translate.t(LABEL_ADD_HEADER)) } +
+ } + }; + + let render_view_mode = || { + html! { +
+ { render_settings_view() } + { render_disabled_header_view() } { render_cache() } { render_rate_limit() } { render_stream() } { render_geoip() } -
- +
} }; let render_edit_mode = || html! { - <> -
- { edit_field_bool!(reverse_proxy_state, translate.t(LABEL_RESOURCE_REWRITE_DISABLED), resource_rewrite_disabled, ReverseProxyConfigFormAction::ResourceRewriteDisabled) } - { edit_field_bool!(reverse_proxy_state, translate.t(LABEL_DISABLE_REFERER_HEADER), disable_referer_header, ReverseProxyConfigFormAction::DisableRefererHeader) } -
-
+
+ { render_settings_edit() } + { render_disabled_header_edit() }

{translate.t(LABEL_CACHE)}

{ edit_field_bool!(cache_state, translate.t(LABEL_ENABLED), enabled, CacheConfigFormAction::Enabled) } @@ -248,8 +326,7 @@ pub fn ReverseProxyConfigView() -> Html { { edit_field_bool!(geoip_state, translate.t(LABEL_ENABLED), enabled, GeoIpConfigFormAction::Enabled) } { edit_field_text!(geoip_state, translate.t(LABEL_URL), url, GeoIpConfigFormAction::Url) }
-
- +
}; html! { diff --git a/shared/src/model/config/reverse_proxy.rs b/shared/src/model/config/reverse_proxy.rs index 531b85e8b..c620a804f 100644 --- a/shared/src/model/config/reverse_proxy.rs +++ b/shared/src/model/config/reverse_proxy.rs @@ -2,13 +2,36 @@ use crate::error::TuliproxError; use crate::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, StreamConfigDto}; use log::warn; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReverseProxyDisabledHeaderConfigDto { + #[serde(default)] + pub referer_header: bool, + #[serde(default)] + pub x_header: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom_header: Vec, +} + +impl ReverseProxyDisabledHeaderConfigDto { + pub fn is_empty(&self) -> bool { + !self.referer_header + && !self.x_header + && self.custom_header.iter().all(|h| h.trim().is_empty()) + } + + pub fn clean(&mut self) { + self.custom_header.retain(|h| !h.trim().is_empty()); + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReverseProxyConfigDto { #[serde(default)] pub resource_rewrite_disabled: bool, - #[serde(default)] - pub disable_referer_header: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_header: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub stream: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -17,13 +40,12 @@ pub struct ReverseProxyConfigDto { pub rate_limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub geoip: Option, - } impl ReverseProxyConfigDto { pub fn is_empty(&self) -> bool { !self.resource_rewrite_disabled - && !self.disable_referer_header + && self.disabled_header.as_ref().is_none_or(|d| d.is_empty()) && (self.stream.is_none() || self.stream.as_ref().is_some_and(|s| s.is_empty())) && (self.cache.is_none() || self.cache.as_ref().is_some_and(|c| c.is_empty())) && (self.rate_limit.is_none() || self.rate_limit.as_ref().is_some_and(|r| r.is_empty())) @@ -31,6 +53,12 @@ impl ReverseProxyConfigDto { } pub fn clean(&mut self) { + if let Some(disabled) = self.disabled_header.as_mut() { + disabled.clean(); + if disabled.is_empty() { + self.disabled_header = None; + } + } if self.stream.as_ref().is_some_and(|s| s.is_empty()) { self.stream = None; }