use super::htaccess; use super::htaccess::Config as HtaccessConfig; #[cfg(unix)] use super::filesystem::open_root_directory; use anyhow::{ensure, Context, Result}; use http::{HeaderName, HeaderValue, StatusCode}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashSet}, fs::File, path::PathBuf, str::FromStr, sync::Arc, time::Duration, }; use tokio::sync::Semaphore; const MAX_INDEX_FILES: usize = 16; const MAX_INDEX_NAME_BYTES: usize = 128; const MAX_SERVICE_NAME_BYTES: usize = 96; const MAX_MIME_TYPES: usize = 64; const MAX_MIME_EXTENSION_BYTES: usize = 32; const MAX_MIME_VALUE_BYTES: usize = 256; const MAX_CACHE_CONTROL_BYTES: usize = 1024; const MAX_TRY_FILES: usize = 16; const MAX_TRY_FILE_BYTES: usize = 512; const MAX_REWRITE_RULES: usize = 32; const MAX_REWRITE_PATTERN_BYTES: usize = 512; const MAX_REWRITE_REPLACEMENT_BYTES: usize = 1024; const MAX_CACHE_RULES: usize = 64; const MAX_RESPONSE_HEADERS: usize = 32; const MAX_HEADER_NAME_BYTES: usize = 128; pub(super) const MAX_HEADER_VALUE_BYTES: usize = 8192; const MAX_DOWNLOAD_EXTENSIONS: usize = 64; const MAX_PHP_EXTENSIONS: usize = 16; const MAX_PHP_CUSTOM_PARAMS: usize = 64; const MAX_PHP_VALUES: usize = 64; const MAX_FASTCGI_PARAM_NAME_BYTES: usize = 64; const MAX_FASTCGI_PARAM_VALUE_BYTES: usize = 8192; const DEFAULT_FASTCGI_HEADER_BYTES: usize = 64 * 1024; const DEFAULT_FASTCGI_STDERR_BYTES: usize = 4096; fn default_index() -> Vec { vec!["index.html".into(), "index.htm".into()] } fn default_true() -> bool { true } fn default_php_extensions() -> Vec { vec!["php".into()] } fn default_php_connect_timeout_ms() -> u64 { 1_000 } fn default_php_request_timeout_ms() -> u64 { 30_000 } fn default_php_max_concurrent_requests() -> usize { 64 } fn default_php_pass_authorization() -> bool { true } fn default_php_max_response_header_bytes() -> usize { DEFAULT_FASTCGI_HEADER_BYTES } fn default_php_stderr_log_bytes() -> usize { DEFAULT_FASTCGI_STDERR_BYTES } fn valid_extension(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_MIME_EXTENSION_BYTES && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) } fn normalize_extensions(values: Vec, label: &str) -> Result> { let mut seen = HashSet::new(); let mut normalized = Vec::with_capacity(values.len()); for value in values { let value = value.trim().trim_start_matches('.').to_ascii_lowercase(); ensure!(valid_extension(&value), "{label} is invalid"); ensure!(seen.insert(value.clone()), "duplicate {label}"); normalized.push(value); } Ok(normalized) } fn reserved_fastcgi_param(name: &str) -> bool { name.starts_with("HTTP_") || matches!( name, "REQUEST_METHOD" | "SCRIPT_FILENAME" | "SCRIPT_NAME" | "QUERY_STRING" | "REQUEST_URI" | "DOCUMENT_ROOT" | "SERVER_PROTOCOL" | "SERVER_NAME" | "SERVER_PORT" | "REMOTE_ADDR" | "REMOTE_PORT" | "HTTPS" | "GATEWAY_INTERFACE" | "SERVER_SOFTWARE" | "REDIRECT_STATUS" | "CONTENT_TYPE" | "CONTENT_LENGTH" | "PATH_INFO" | "PATH_TRANSLATED" | "PHP_VALUE" | "PHP_ADMIN_VALUE" ) } fn normalize_fastcgi_params(values: BTreeMap) -> Result> { ensure!( values.len() <= MAX_PHP_CUSTOM_PARAMS, "Web Service PHP allows at most 64 custom FastCGI parameters" ); let mut normalized = BTreeMap::new(); for (name, value) in values { let name = name.trim().to_ascii_uppercase(); let value = value.trim().to_owned(); ensure!( !name.is_empty() && name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES && name.bytes().enumerate().all(|(index, byte)| { byte.is_ascii_uppercase() || byte.is_ascii_digit() && index > 0 || byte == b'_' && index > 0 }), "Web Service PHP custom FastCGI parameter name is invalid" ); ensure!( !reserved_fastcgi_param(&name), "Web Service PHP custom FastCGI parameter uses a reserved name" ); ensure!( !value.is_empty() && value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES && !value.chars().any(char::is_control), "Web Service PHP custom FastCGI parameter value is invalid" ); ensure!( normalized.insert(name, value).is_none(), "duplicate Web Service PHP custom FastCGI parameter" ); } Ok(normalized) } fn normalize_php_values(values: BTreeMap) -> Result> { ensure!( values.len() <= MAX_PHP_VALUES, "Web Service PHP allows at most 64 PHP INI overrides" ); let mut normalized = BTreeMap::new(); for (name, value) in values { let name = name.trim().to_ascii_lowercase(); let value = value.trim().to_owned(); ensure!( !name.is_empty() && name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES && name.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-') }), "Web Service PHP INI override name is invalid" ); ensure!( !value.is_empty() && value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES && !value.chars().any(char::is_control), "Web Service PHP INI override value is invalid" ); ensure!( normalized.insert(name, value).is_none(), "duplicate Web Service PHP INI override" ); } Ok(normalized) } fn protected_service_header(name: &HeaderName) -> bool { matches!( name.as_str(), "connection" | "content-length" | "content-encoding" | "content-range" | "transfer-encoding" | "upgrade" | "keep-alive" | "proxy-connection" | "vary" ) } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum SymlinkPolicy { #[default] Deny, InsideRoot, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, deny_unknown_fields)] pub struct RewriteRule { pub pattern: String, pub replacement: String, /// `None` means an internal rewrite. Redirects are limited to 301/302/307/308. pub status: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, deny_unknown_fields)] pub struct CacheRule { /// `ext:` or `mime:`. pub matcher: String, pub cache_control: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Precompressed { pub enabled: bool, #[serde(default = "default_true")] pub brotli: bool, #[serde(default = "default_true")] pub gzip: bool, } impl Default for Precompressed { fn default() -> Self { Self { enabled: false, brotli: true, gzip: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Php { pub enabled: bool, /// Safely split `/script.php/extra/path` into SCRIPT_FILENAME + PATH_INFO. /// Disabled by default because it changes URL-to-script resolution semantics. pub path_info: bool, #[serde(default = "default_php_extensions")] pub extensions: Vec, /// `unix:/run/php/php-fpm.sock` or `tcp:127.0.0.1:9000`. pub endpoint: String, #[serde(default = "default_php_connect_timeout_ms")] pub connect_timeout_ms: u64, #[serde(default = "default_php_request_timeout_ms")] pub request_timeout_ms: u64, #[serde(default = "default_php_max_concurrent_requests")] pub max_concurrent_requests: usize, /// Forward the inbound Authorization header as HTTP_AUTHORIZATION. #[serde(default = "default_php_pass_authorization")] pub pass_authorization: bool, /// Optional RustProxy-side request-body limit for PHP. 0 keeps the body unlimited here. pub max_request_body_bytes: u64, /// Maximum bytes accepted before the CGI response-header terminator. #[serde(default = "default_php_max_response_header_bytes")] pub max_response_header_bytes: usize, /// Maximum PHP-FPM stderr bytes retained for one request diagnostic log entry. #[serde(default = "default_php_stderr_log_bytes")] pub stderr_log_bytes: usize, /// Trusted operator-defined static FastCGI parameters. Core CGI/HTTP/PHP_* names are reserved. pub custom_params: BTreeMap, /// Trusted operator-defined PHP INI overrides encoded through a single PHP_VALUE parameter. pub php_values: BTreeMap, pub php_admin_values: BTreeMap, } impl Default for Php { fn default() -> Self { Self { enabled: false, path_info: false, extensions: default_php_extensions(), endpoint: "unix:/run/php/php-fpm.sock".into(), connect_timeout_ms: default_php_connect_timeout_ms(), request_timeout_ms: default_php_request_timeout_ms(), max_concurrent_requests: default_php_max_concurrent_requests(), pass_authorization: default_php_pass_authorization(), max_request_body_bytes: 0, max_response_header_bytes: default_php_max_response_header_bytes(), stderr_log_bytes: default_php_stderr_log_bytes(), custom_params: BTreeMap::new(), php_values: BTreeMap::new(), php_admin_values: BTreeMap::new(), } } } /// Reusable PHP-FPM configuration. A Web Service can reference one profile or /// keep its local `php` settings. Profiles are part of the revisioned Config. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, deny_unknown_fields)] pub struct PhpFpmProfile { pub id: String, pub name: String, pub php: Php, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, deny_unknown_fields)] pub struct Hardening { /// Reject high-confidence exploit probes and sensitive control/backup paths. pub block_common_attacks: bool, /// Remove implementation/fingerprint response headers from Web Server/PHP responses. pub strip_unnecessary_headers: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Service { /// Stable configuration identifier referenced by vhosts/locations. pub id: String, /// Human-readable service name shown in the administration UI. pub name: String, pub document_root: PathBuf, pub index: Vec, pub directory_listing: bool, /// Optional per-extension MIME overrides. Keys are extensions without a leading dot. pub mime_types: BTreeMap, /// Fallback MIME type used when neither an override nor a built-in mapping exists. pub default_mime_type: String, /// Browser/proxy cache policy emitted for static file responses. Location/vhost /// response-header rules run later and can intentionally override this value. pub cache_control: Option, /// More specific cache policy rules evaluated before `cache_control`. pub cache_rules: Vec, /// Fixed response headers emitted by this Web Service. Vhost/location response /// headers still run later and remain authoritative. pub response_headers: BTreeMap, /// Nginx-style file candidates, e.g. `$uri`, `$uri/`, `/index.html`. pub try_files: Vec, /// Regex rewrite/redirect rules evaluated before `try_files`. pub rewrites: Vec, /// Serve pre-generated `.br`/`.gz` sidecars when accepted by the client. pub precompressed: Precompressed, /// Optional downstream dynamic-compression override. Codecs remain global. pub compression: crate::compression::Overrides, /// Extensions that should be downloaded as attachments. pub download_extensions: Vec, /// Symlink handling. The conservative default is `deny`. pub symlink_policy: SymlinkPolicy, /// Optional reference to a shared FPM profile; `php` stays the standalone /// configuration and is preserved when attaching/detaching a profile. #[serde(default, skip_serializing_if = "Option::is_none")] pub php_profile_id: Option, /// Optional FastCGI/PHP-FPM execution for configured script extensions. pub php: Php, /// Optional bounded Apache .htaccess compatibility layer compiled at Web Service apply time. pub htaccess: HtaccessConfig, /// Optional Web Service hardening. This is intentionally narrower than a generic WAF. pub hardening: Hardening, } impl Default for Service { fn default() -> Self { Self { id: String::new(), name: String::new(), document_root: PathBuf::new(), index: default_index(), directory_listing: false, mime_types: BTreeMap::new(), default_mime_type: "application/octet-stream".into(), cache_control: None, cache_rules: Vec::new(), response_headers: BTreeMap::new(), try_files: Vec::new(), rewrites: Vec::new(), precompressed: Precompressed::default(), compression: crate::compression::Overrides::default(), download_extensions: Vec::new(), symlink_policy: SymlinkPolicy::Deny, php_profile_id: None, php: Php::default(), htaccess: HtaccessConfig::default(), hardening: Hardening::default(), } } }