Allow http block inclusion at top-level nginx.conf (#10178)

Fixes #9928.

Thanks to @vanviethieuanh for tracking down the source of the issue!
This commit is contained in:
ohemorange
2025-02-04 11:15:17 -08:00
committed by GitHub
parent b5f7fe179e
commit e32f4fc5fb
6 changed files with 50 additions and 22 deletions
@@ -77,11 +77,11 @@ class NginxHttp01(common.ChallengePerformer):
""" """
included = False included = False
include_directive = ['\n', 'include', ' ', self.challenge_conf] include_directive = ['\n', 'include', ' ', self.challenge_conf]
root = self.configurator.parser.config_root http_path = self.configurator.parser.http_path
bucket_directive = ['\n', 'server_names_hash_bucket_size', ' ', '128'] bucket_directive = ['\n', 'server_names_hash_bucket_size', ' ', '128']
main = self.configurator.parser.parsed[root] main = self.configurator.parser.parsed[http_path]
# insert include directive # insert include directive
for line in main: for line in main:
if line[0] == ['http']: if line[0] == ['http']:
@@ -126,6 +126,7 @@ class NginxHttp01(common.ChallengePerformer):
body.insert(0, bucket_directive) body.insert(0, bucket_directive)
break break
root = self.configurator.parser.config_root
if not included: if not included:
raise errors.MisconfigurationError( raise errors.MisconfigurationError(
'Certbot could not find a block to include ' 'Certbot could not find a block to include '
+28 -15
View File
@@ -1,4 +1,5 @@
"""NginxParser is a member object of the NginxConfigurator class.""" """NginxParser is a member object of the NginxConfigurator class."""
from __future__ import annotations
import copy import copy
import functools import functools
import glob import glob
@@ -41,6 +42,7 @@ class NginxParser:
self.parsed: Dict[str, UnspacedList] = {} self.parsed: Dict[str, UnspacedList] = {}
self.root = os.path.abspath(root) self.root = os.path.abspath(root)
self.config_root = self._find_config_root() self.config_root = self._find_config_root()
self._http_path: str | None = None
# Parse nginx.conf and included files. # Parse nginx.conf and included files.
# TODO: Check sites-available/ as well. For now, the configurator does # TODO: Check sites-available/ as well. For now, the configurator does
@@ -54,6 +56,14 @@ class NginxParser:
self.parsed = {} self.parsed = {}
self._parse_recursively(self.config_root) self._parse_recursively(self.config_root)
@property
def http_path(self) -> str:
""" Filepath of file containing nginx http block. Set in self._parse_recursively
"""
if self._http_path is None:
raise errors.MisconfigurationError('No nginx http block found')
return self._http_path
def _parse_recursively(self, filepath: str) -> None: def _parse_recursively(self, filepath: str) -> None:
"""Parses nginx config files recursively by looking at 'include' """Parses nginx config files recursively by looking at 'include'
directives inside 'http' and 'server' blocks. Note that this only directives inside 'http' and 'server' blocks. Note that this only
@@ -64,13 +74,16 @@ class NginxParser:
""" """
# pylint: disable=too-many-nested-blocks # pylint: disable=too-many-nested-blocks
filepath = self.abs_path(filepath) filepath = self.abs_path(filepath)
trees = self._parse_files(filepath) trees: dict[str, UnspacedList] = self._parse_files(filepath)
for tree in trees: for filename, tree in trees.items():
for entry in tree: for entry in tree:
if _is_include_directive(entry): if _is_include_directive(entry):
# Parse the top-level included file # Parse the top-level included file
self._parse_recursively(entry[1]) self._parse_recursively(entry[1])
elif entry[0] == ['http'] or entry[0] == ['server']: elif entry[0] == ['http'] or entry[0] == ['server']:
# Note http block location for http_01.py
if entry[0] == ['http']:
self._http_path = filename
# Look for includes in the top-level 'http'/'server' context # Look for includes in the top-level 'http'/'server' context
for subentry in entry[1]: for subentry in entry[1]:
if _is_include_directive(subentry): if _is_include_directive(subentry):
@@ -193,35 +206,35 @@ class NginxParser:
pass pass
return result return result
def _parse_files(self, filepath: str, override: bool = False) -> List[UnspacedList]: def _parse_files(self, filepath: str, override: bool = False) -> dict[str, UnspacedList]:
"""Parse files from a glob """Parse files from a glob
:param str filepath: Nginx config file path :param str filepath: Nginx config file path
:param bool override: Whether to parse a file that has been parsed :param bool override: Whether to parse a file that has been parsed
:returns: list of parsed tree structures :returns: dict of parsed tree structures indexed by filename
:rtype: list :rtype: dict[str, UnspacedList]
""" """
files = glob.glob(filepath) # nginx on unix calls glob(3) for this files: list[str] = glob.glob(filepath) # nginx on unix calls glob(3) for this
# XXX Windows nginx uses FindFirstFile, and # XXX Windows nginx uses FindFirstFile, and
# should have a narrower call here # should have a narrower call here
trees = [] trees: dict[str, UnspacedList] = {}
for item in files: for filename in files:
if item in self.parsed and not override: if filename in self.parsed and not override:
continue continue
try: try:
with open(item, "r", encoding="utf-8") as _file: with open(filename, "r", encoding="utf-8") as _file:
parsed = nginxparser.load(_file) parsed = nginxparser.load(_file)
self.parsed[item] = parsed self.parsed[filename] = parsed
trees.append(parsed) trees[filename] = parsed
except OSError: except OSError:
logger.warning("Could not open file: %s", item) logger.warning("Could not open file: %s", filename)
except UnicodeDecodeError: except UnicodeDecodeError:
logger.warning("Could not read file: %s due to invalid " logger.warning("Could not read file: %s due to invalid "
"character. Only UTF-8 encoding is " "character. Only UTF-8 encoding is "
"supported.", item) "supported.", filename)
except pyparsing.ParseException as err: except pyparsing.ParseException as err:
logger.warning("Could not parse file: %s due to %s", item, err) logger.warning("Could not parse file: %s due to %s", filename, err)
return trees return trees
def _find_config_root(self) -> str: def _find_config_root(self) -> str:
@@ -134,7 +134,7 @@ class NginxConfiguratorTest(util.NginxTest):
['server_name', 'example.*'], ['server_name', 'example.*'],
['listen', '5001', 'ssl'], ['listen', '5001', 'ssl'],
['#', parser.COMMENT]]]] == \ ['#', parser.COMMENT]]]] == \
parsed[0] parsed[filep]
def test_choose_vhosts_alias(self): def test_choose_vhosts_alias(self):
self._test_choose_vhosts_common('alias', 'server_conf') self._test_choose_vhosts_common('alias', 'server_conf')
@@ -74,6 +74,15 @@ class NginxParserTest(util.NginxTest):
nparser.parsed[nparser.abs_path( nparser.parsed[nparser.abs_path(
'sites-enabled/example.com')] 'sites-enabled/example.com')]
def test_included_load(self):
# Test for when the root file doesn't have http in it
nparser = parser.NginxParser(self.config_path)
nparser.config_root = os.path.join(self.config_path, "nginx-include.conf")
nparser.load()
assert len(nparser.parsed) > 1
assert len(nparser.parsed[nparser.config_root]) == 4
assert os.path.join(self.config_path, "nginx.conf") in nparser.parsed
def test_abs_path(self): def test_abs_path(self):
nparser = parser.NginxParser(self.config_path) nparser = parser.NginxParser(self.config_path)
if os.name != 'nt': if os.name != 'nt':
@@ -99,7 +108,7 @@ class NginxParserTest(util.NginxTest):
['listen', '127.0.0.1'], ['listen', '127.0.0.1'],
['server_name', '.example.com'], ['server_name', '.example.com'],
['server_name', 'example.*']]]] == \ ['server_name', 'example.*']]]] == \
parsed[0] parsed[nparser.abs_path('sites-enabled/example.com.test')]
def test__do_for_subarray(self): def test__do_for_subarray(self):
# pylint: disable=protected-access # pylint: disable=protected-access
@@ -490,8 +499,8 @@ class NginxParserTest(util.NginxTest):
nparser = parser.NginxParser(self.config_path) nparser = parser.NginxParser(self.config_path)
path = nparser.abs_path('valid_unicode_comments.conf') path = nparser.abs_path('valid_unicode_comments.conf')
parsed = nparser._parse_files(path) # pylint: disable=protected-access parsed = nparser._parse_files(path) # pylint: disable=protected-access
assert ['server'] == parsed[0][2][0] assert ['server'] == parsed[path][2][0]
assert ['listen', '80'] == parsed[0][2][1][3] assert ['listen', '80'] == parsed[path][2][1][3]
def test_valid_unicode_roundtrip(self): def test_valid_unicode_roundtrip(self):
"""This tests the parser's ability to load and save a config containing Unicode""" """This tests the parser's ability to load and save a config containing Unicode"""
@@ -507,7 +516,7 @@ class NginxParserTest(util.NginxTest):
path = nparser.abs_path('invalid_unicode_comments.conf') path = nparser.abs_path('invalid_unicode_comments.conf')
parsed = nparser._parse_files(path) # pylint: disable=protected-access parsed = nparser._parse_files(path) # pylint: disable=protected-access
assert [] == parsed assert {} == parsed
assert any( assert any(
('invalid character' in output) and ('UTF-8' in output) ('invalid character' in output) and ('UTF-8' in output)
for output in log.output for output in log.output
@@ -0,0 +1,4 @@
# just a comment on top
# so we're not at the very top
include nginx.conf;
# and another comment for fun
+1
View File
@@ -30,6 +30,7 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
3.1.0. 3.1.0.
* When adding ssl listen directives in nginx server blocks, IP addresses are now * When adding ssl listen directives in nginx server blocks, IP addresses are now
preserved. preserved.
* Nginx configurations can now have the http block in files other than the root (nginx.conf)
More details about these changes can be found on our GitHub repo. More details about these changes can be found on our GitHub repo.