@@ -236,6 +236,15 @@ pub struct NginxConfig {
236236 pub upstreams : HashMap < String , Upstream > ,
237237 pub servers : Vec < ServerBlock > ,
238238 pub includes : Vec < PathBuf > ,
239+ /// Human-readable problems found while resolving `include` directives:
240+ /// a glob that matched no files, an explicit include whose file is
241+ /// missing, or an invalid glob pattern. These are NOT part of the proxy's
242+ /// routing state — they exist purely so `wolfproxy --test` and the startup
243+ /// log can tell the operator their include pulled in nothing, instead of
244+ /// failing silently (the wabil report, 2026-06-13). Real nginx errors
245+ /// loudly here; WolfProxy used to swallow it.
246+ #[ serde( default ) ]
247+ pub include_warnings : Vec < String > ,
239248}
240249
241250/// Parse an nginx configuration file
@@ -275,15 +284,43 @@ pub fn parse_nginx_content(content: &str, base_dir: Option<&Path>) -> NginxConfi
275284
276285 // Handle glob patterns
277286 if include. contains ( '*' ) {
278- if let Ok ( paths) = glob:: glob ( & include_path. to_string_lossy ( ) ) {
279- for entry in paths. flatten ( ) {
280- let sub_config = parse_nginx_config ( & entry) ;
281- merge_configs ( & mut config, sub_config) ;
287+ match glob:: glob ( & include_path. to_string_lossy ( ) ) {
288+ Ok ( paths) => {
289+ let mut matched = 0usize ;
290+ for entry in paths. flatten ( ) {
291+ let sub_config = parse_nginx_config ( & entry) ;
292+ merge_configs ( & mut config, sub_config) ;
293+ matched += 1 ;
294+ }
295+ // A glob matching nothing is legal in nginx (not an
296+ // error) but is almost always a mistake — e.g.
297+ // `sites-enabled/*.conf` against Debian's extensionless
298+ // symlinks. Surface it so it isn't silent.
299+ if matched == 0 {
300+ config. include_warnings . push ( format ! (
301+ "include '{}' matched no files — check the path and that the \
302+ filenames match the pattern (Debian sites-enabled entries often \
303+ have no .conf suffix, so '*.conf' matches nothing; try '*')",
304+ include_path. display( )
305+ ) ) ;
306+ }
282307 }
308+ Err ( e) => config. include_warnings . push ( format ! (
309+ "include '{}' is not a valid glob pattern: {}" , include, e
310+ ) ) ,
283311 }
284312 } else if include_path. exists ( ) {
285313 let sub_config = parse_nginx_config ( & include_path) ;
286314 merge_configs ( & mut config, sub_config) ;
315+ } else {
316+ // An explicit (non-glob) include of a file that doesn't exist:
317+ // real nginx treats this as a fatal `-t` error. We surface it
318+ // loudly but don't refuse to load — a proxy that's been running
319+ // shouldn't suddenly fail to start on upgrade (see main.rs).
320+ config. include_warnings . push ( format ! (
321+ "include '{}' refers to a file that does not exist" ,
322+ include_path. display( )
323+ ) ) ;
287324 }
288325 }
289326 }
@@ -395,12 +432,12 @@ fn parse_upstream_server(line: &str) -> Option<UpstreamServer> {
395432 // Parse additional options
396433 for part in parts. iter ( ) . skip ( 2 ) {
397434 let part = part. trim_end_matches ( ';' ) ;
398- if part. starts_with ( "weight=" ) {
399- server. weight = part [ 7 .. ] . parse ( ) . unwrap_or ( 1 ) ;
400- } else if part. starts_with ( "max_fails=" ) {
401- server. max_fails = part [ 10 .. ] . parse ( ) . unwrap_or ( 1 ) ;
402- } else if part. starts_with ( "fail_timeout=" ) {
403- let timeout_str = part [ 13 .. ] . trim_end_matches ( 's' ) ;
435+ if let Some ( v ) = part. strip_prefix ( "weight=" ) {
436+ server. weight = v . parse ( ) . unwrap_or ( 1 ) ;
437+ } else if let Some ( v ) = part. strip_prefix ( "max_fails=" ) {
438+ server. max_fails = v . parse ( ) . unwrap_or ( 1 ) ;
439+ } else if let Some ( v ) = part. strip_prefix ( "fail_timeout=" ) {
440+ let timeout_str = v . trim_end_matches ( 's' ) ;
404441 server. fail_timeout = timeout_str. parse ( ) . unwrap_or ( 300 ) ;
405442 } else if part == "backup" {
406443 server. backup = true ;
@@ -483,15 +520,15 @@ fn parse_server_content(content: &str, base_dir: Option<&Path>) -> Option<Server
483520 } ;
484521
485522 // Parse line by line, handling nested blocks
486- let mut lines = content. lines ( ) . peekable ( ) ;
523+ let lines = content. lines ( ) . peekable ( ) ;
487524 let mut current_location: Option < LocationBlock > = None ;
488525 let mut location_depth = 0 ;
489526 let mut location_content = String :: new ( ) ;
490527 let mut in_if = false ;
491528 let mut if_content = String :: new ( ) ;
492529 let mut if_condition = String :: new ( ) ;
493530
494- while let Some ( line) = lines. next ( ) {
531+ for line in lines {
495532 let trimmed = line. trim ( ) ;
496533
497534 // Handle if blocks
@@ -863,10 +900,25 @@ fn parse_server_directive(server: &mut ServerBlock, line: &str, base_dir: Option
863900 for include_line in content. lines ( ) {
864901 let include_trimmed = include_line. trim ( ) ;
865902 if !include_trimmed. is_empty ( ) && !include_trimmed. starts_with ( '#' ) {
866- parse_server_directive ( server, include_trimmed, Some ( & full_path. parent ( ) . unwrap_or ( Path :: new ( "/" ) ) ) ) ;
903+ parse_server_directive ( server, include_trimmed, Some ( full_path. parent ( ) . unwrap_or ( Path :: new ( "/" ) ) ) ) ;
867904 }
868905 }
869906 }
907+ } else if include_path. contains ( '*' ) {
908+ // Server-context includes pull directives (snippets) into
909+ // the surrounding server; globbing them isn't supported
910+ // here — top-level includes are the place for globs.
911+ eprintln ! (
912+ "wolfproxy: include '{}' inside a server block uses a glob, which is only \
913+ supported for top-level includes — the directives were not loaded",
914+ include_path
915+ ) ;
916+ } else {
917+ eprintln ! (
918+ "wolfproxy: include '{}' inside a server block refers to a file that does \
919+ not exist — the directives were not loaded",
920+ full_path. display( )
921+ ) ;
870922 }
871923 }
872924 }
@@ -914,8 +966,8 @@ fn parse_listen_directive(line: &str) -> Option<ListenDirective> {
914966 let addr_part = parts[ 0 ] . trim_end_matches ( ';' ) ;
915967
916968 // Check for [::] IPv6 notation
917- if addr_part. starts_with ( "[::]:" ) {
918- listen. port = addr_part [ 5 .. ] . parse ( ) . unwrap_or ( 80 ) ;
969+ if let Some ( port_str ) = addr_part. strip_prefix ( "[::]:" ) {
970+ listen. port = port_str . parse ( ) . unwrap_or ( 80 ) ;
919971 listen. address = Some ( "[::]:" . to_string ( ) ) ;
920972 } else if addr_part. starts_with ( "[::]" ) {
921973 listen. port = 80 ;
@@ -975,14 +1027,14 @@ fn parse_rewrite_rule(line: &str) -> Option<RewriteRule> {
9751027
9761028fn parse_time_value ( s : & str ) -> Option < u64 > {
9771029 let s = s. trim ( ) ;
978- if s . ends_with ( 's' ) {
979- s [ ..s . len ( ) - 1 ] . parse ( ) . ok ( )
980- } else if s . ends_with ( 'm' ) {
981- s [ ..s . len ( ) - 1 ] . parse :: < u64 > ( ) . ok ( ) . map ( |v| v * 60 )
982- } else if s . ends_with ( 'h' ) {
983- s [ ..s . len ( ) - 1 ] . parse :: < u64 > ( ) . ok ( ) . map ( |v| v * 3600 )
984- } else if s . ends_with ( 'd' ) {
985- s [ ..s . len ( ) - 1 ] . parse :: < u64 > ( ) . ok ( ) . map ( |v| v * 86400 )
1030+ if let Some ( n ) = s . strip_suffix ( 's' ) {
1031+ n . parse ( ) . ok ( )
1032+ } else if let Some ( n ) = s . strip_suffix ( 'm' ) {
1033+ n . parse :: < u64 > ( ) . ok ( ) . map ( |v| v * 60 )
1034+ } else if let Some ( n ) = s . strip_suffix ( 'h' ) {
1035+ n . parse :: < u64 > ( ) . ok ( ) . map ( |v| v * 3600 )
1036+ } else if let Some ( n ) = s . strip_suffix ( 'd' ) {
1037+ n . parse :: < u64 > ( ) . ok ( ) . map ( |v| v * 86400 )
9861038 } else {
9871039 s. parse ( ) . ok ( )
9881040 }
@@ -1005,6 +1057,9 @@ fn merge_configs(main: &mut NginxConfig, other: NginxConfig) {
10051057 main. upstreams . extend ( other. upstreams ) ;
10061058 main. servers . extend ( other. servers ) ;
10071059 main. includes . extend ( other. includes ) ;
1060+ // Bubble include problems up from nested includes so `--test` and the log
1061+ // see every one, no matter how deep the include chain.
1062+ main. include_warnings . extend ( other. include_warnings ) ;
10081063}
10091064
10101065/// Load the proxy's configuration the way nginx itself does.
@@ -1057,7 +1112,7 @@ pub fn load_nginx_sites(nginx_dir: &Path) -> NginxConfig {
10571112 if let Ok ( entries) = fs:: read_dir ( & conf_d) {
10581113 for entry in entries. flatten ( ) {
10591114 let path = entry. path ( ) ;
1060- if path. extension ( ) . map_or ( false , |ext| ext == "conf" ) {
1115+ if path. extension ( ) . is_some_and ( |ext| ext == "conf" ) {
10611116 let sub_config = parse_nginx_config ( & path) ;
10621117 merge_configs ( & mut config, sub_config) ;
10631118 }
@@ -1151,4 +1206,51 @@ mod tests {
11511206 let _ = fs:: remove_dir_all ( & dir) ;
11521207 let _ = fs:: remove_dir_all ( & dir2) ;
11531208 }
1209+
1210+ #[ test]
1211+ fn test_include_problems_are_surfaced_not_swallowed ( ) {
1212+ // A root.conf whose includes resolve to nothing must produce
1213+ // include_warnings — silence is what left the operator stranded.
1214+ let dir = std:: env:: temp_dir ( ) . join ( format ! ( "wolfproxy-incwarn-{}" , std:: process:: id( ) ) ) ;
1215+ let sites = dir. join ( "sites-enabled" ) ;
1216+ let _ = fs:: remove_dir_all ( & dir) ;
1217+ fs:: create_dir_all ( & sites) . unwrap ( ) ;
1218+ // The file exists but has NO .conf suffix (Debian symlink style), so a
1219+ // `*.conf` glob matches nothing, and an explicit include points at a
1220+ // file that isn't there.
1221+ fs:: write ( sites. join ( "sonarr" ) , "server {\n listen 80;\n server_name s.example.com;\n }\n " ) . unwrap ( ) ;
1222+ fs:: write (
1223+ dir. join ( "root.conf" ) ,
1224+ format ! (
1225+ "http {{\n include {sites}/*.conf;\n include {sites}/missing.conf;\n }}\n " ,
1226+ sites = sites. display( )
1227+ ) ,
1228+ )
1229+ . unwrap ( ) ;
1230+
1231+ let config = load_nginx_config ( & dir) ;
1232+ assert_eq ! ( config. servers. len( ) , 0 , "neither include should have matched a server" ) ;
1233+ assert_eq ! ( config. include_warnings. len( ) , 2 ,
1234+ "both the zero-match glob and the missing explicit include must warn; got {:?}" ,
1235+ config. include_warnings) ;
1236+ assert ! ( config. include_warnings. iter( ) . any( |w| w. contains( "matched no files" ) ) ,
1237+ "zero-match glob warning missing: {:?}" , config. include_warnings) ;
1238+ assert ! ( config. include_warnings. iter( ) . any( |w| w. contains( "does not exist" ) ) ,
1239+ "missing-file warning missing: {:?}" , config. include_warnings) ;
1240+
1241+ // Fixing the glob to `*` (which matches the extensionless file) loads
1242+ // the server AND clears the glob warning.
1243+ fs:: write (
1244+ dir. join ( "root.conf" ) ,
1245+ format ! ( "http {{\n include {sites}/*;\n }}\n " , sites = sites. display( ) ) ,
1246+ )
1247+ . unwrap ( ) ;
1248+ let ok = load_nginx_config ( & dir) ;
1249+ assert ! ( ok. servers. iter( ) . any( |s| s. server_names. iter( ) . any( |n| n == "s.example.com" ) ) ,
1250+ "`*` should match the extensionless file and load its server" ) ;
1251+ assert ! ( ok. include_warnings. is_empty( ) ,
1252+ "a healthy include must produce no warnings; got {:?}" , ok. include_warnings) ;
1253+
1254+ let _ = fs:: remove_dir_all ( & dir) ;
1255+ }
11541256}
0 commit comments