diff --git a/node_cli/configs/__init__.py b/node_cli/configs/__init__.py index d65e4613..494d2042 100644 --- a/node_cli/configs/__init__.py +++ b/node_cli/configs/__init__.py @@ -64,8 +64,20 @@ STATIC_PARAMS_FILEPATH = os.path.join(CONTAINER_CONFIG_PATH, 'static_params.yaml') FAIR_STATIC_PARAMS_FILEPATH = os.path.join(CONTAINER_CONFIG_PATH, 'fair_static_params.yaml') -NGINX_TEMPLATE_FILEPATH = os.path.join(CONTAINER_CONFIG_PATH, 'nginx.conf.j2') -NGINX_CONFIG_FILEPATH = os.path.join(NODE_DATA_PATH, 'nginx.conf') +NGINX_TEMPLATE_DIR = os.path.join(CONTAINER_CONFIG_PATH, 'nginx') +NGINX_TEMPLATE_FILEPATH = os.path.join(NGINX_TEMPLATE_DIR, 'nginx.conf.j2') +NGINX_BASE_TEMPLATE_FILEPATH = os.path.join(NGINX_TEMPLATE_DIR, 'base.conf.j2') +NGINX_NJS_SOURCE_PATH = os.path.join(NGINX_TEMPLATE_DIR, 'njs') +LEGACY_NGINX_TEMPLATE_FILEPATH = os.path.join(CONTAINER_CONFIG_PATH, 'nginx.conf.j2') +NGINX_DIR = os.path.join(NODE_DATA_PATH, 'nginx') +NGINX_CONFIG_FILEPATH = os.path.join(NGINX_DIR, 'nginx.conf') +NGINX_CONF_D_PATH = os.path.join(NGINX_DIR, 'conf.d') +NGINX_BASE_CONFIG_FILEPATH = os.path.join(NGINX_CONF_D_PATH, 'base.conf') +NGINX_CHAINS_PATH = os.path.join(NGINX_CONF_D_PATH, 'chains') +NGINX_NJS_PATH = os.path.join(NGINX_DIR, 'njs') +# skale-admin takes the same lock for its nginx changes +NGINX_LOCK_PATH = os.path.join(NGINX_DIR, '.chains.lock') +LEGACY_NGINX_CONFIG_FILEPATH = os.path.join(NODE_DATA_PATH, 'nginx.conf') NGINX_CONTAINER_NAME = 'sk_nginx' LOG_PATH = os.path.join(NODE_DATA_PATH, 'log') diff --git a/node_cli/core/host.py b/node_cli/core/host.py index a040b8c4..3b97611c 100644 --- a/node_cli/core/host.py +++ b/node_cli/core/host.py @@ -50,6 +50,7 @@ SKALE_TMP_DIR, UFW_CONFIG_PATH, UFW_IPV6_BEFORE_INPUT_CHAIN, + LEGACY_NGINX_CONFIG_FILEPATH, NGINX_CONFIG_FILEPATH, ) from node_cli.configs.cli_logger import LOG_DATA_PATH @@ -88,7 +89,8 @@ def prepare_host(env_type: EnvType, allocation: bool = False) -> None: def is_node_inited() -> bool: - return os.path.isfile(NGINX_CONFIG_FILEPATH) + # the single-file config marks nodes that were not updated to node_data/nginx/ yet + return os.path.isfile(NGINX_CONFIG_FILEPATH) or os.path.isfile(LEGACY_NGINX_CONFIG_FILEPATH) def make_dirs(): diff --git a/node_cli/core/nftables.py b/node_cli/core/nftables.py index 0d8cf0ba..d08bc3a1 100644 --- a/node_cli/core/nftables.py +++ b/node_cli/core/nftables.py @@ -26,6 +26,8 @@ from pathlib import Path from typing import Optional +from filelock import FileLock + from node_cli.configs import ( DEFAULT_NODE_BASE_PORT, ENV, @@ -36,7 +38,13 @@ NFTABLES_USER_CONFIG_PATH, NODE_CONFIG_PATH, ) -from node_cli.utils.helper import cleanup_dir_content, get_ssh_ports, read_json, run_cmd +from node_cli.utils.helper import ( + check_ssl_certs, + cleanup_dir_content, + get_ssh_ports, + read_json, + run_cmd, +) logger = logging.getLogger(__name__) @@ -72,6 +80,9 @@ class SGXPort: ZMQ: int = 1031 +# nginx listens on these only once the node has certificates, see base.conf.j2 +TLS_SERVICE_PORTS = (ServicePort.HTTPS, ServicePort.WATCHDOG_HTTPS) + LEGACY_CHAIN = 'INPUT' LEGACY_FAMILY = 'ip' LEGACY_TABLE = 'filter' @@ -609,37 +620,66 @@ def _setup_user_chain(self) -> None: self.apply_user_rules() self.remove_user_rules_from_main_chain() - def remove_monitoring_accepts(self) -> None: - """Remove every legacy monitoring accept from the managed base chain.""" + def _remove_tcp_accepts(self, ports: tuple[int, ...]) -> None: + """Remove every accept of the ports from the managed base chain, ssh ports stay open.""" ssh_ports = get_ssh_ports() - monitoring_exprs = [ + exprs = [ Rule(chain=self.chain, protocol='tcp', first_port=port).to_expr() - for port in (ServicePort.EXPORTER, ServicePort.CADVISOR) + for port in ports if port not in ssh_ports ] for rule in self.get_rules(self.chain): if ( - self._normalized_expr(rule.get('expr', [])) in monitoring_exprs + self._normalized_expr(rule.get('expr', [])) in exprs and rule.get('handle') is not None ): self.delete_rule_by_handle(rule['handle']) + def remove_monitoring_accepts(self) -> None: + """Remove every legacy monitoring accept from the managed base chain.""" + self._remove_tcp_accepts((ServicePort.EXPORTER, ServicePort.CADVISOR)) + def _add_service_accepts(self) -> None: self._ensure_rule(self.chain, conntrack_accept_expr(), label='connection tracking rule') tcp_ports = [ *get_ssh_ports(), ServicePort.DNS, - ServicePort.HTTPS, ServicePort.HTTP, ServicePort.WATCHDOG_HTTP, - ServicePort.WATCHDOG_HTTPS, ] for port in tcp_ports: self.add_rule(Rule(chain=self.chain, protocol='tcp', first_port=port)) + self.sync_tls_accepts() self.remove_misordered_udp_drop() self.add_rule(Rule(chain=self.chain, protocol='udp', first_port=ServicePort.DNS)) self._ensure_rule(self.chain, loopback_accept_expr(), label='loopback rule') + def sync_tls_accepts(self) -> None: + """Open the TLS service ports with certificates, close them without.""" + path = Path(NFTABLES_CHAIN_FOLDER_PATH) / 'tls-ports.conf' + with FileLock(path.with_suffix('.lock'), timeout=120): + ports = ', '.join(map(str, TLS_SERVICE_PORTS)) if check_ssl_certs() else '' + elements = f' elements = {{ {ports} }};' if ports else '' + declaration = f'set skale_tls_ports {{ type inet_service;{elements} }}' + commands = ( + f'add set {self.family} {self.table} skale_tls_ports {{ type inet_service; }}\n' + f'flush set {self.family} {self.table} skale_tls_ports\n' + ) + if ports: + commands += ( + f'add element {self.family} {self.table} skale_tls_ports {{ {ports} }}\n' + ) + rc, _, error = self.nft.cmd(commands) + if rc != 0: + raise NFTablesError(f'Failed to sync TLS ports: {error}') + temporary = path.with_suffix('.tmp') + temporary.write_text(declaration + '\n') + temporary.replace(path) + expr = [dport_match('tcp', 0, 0), {'counter': None}, {'accept': None}] + expr[0]['match']['right'] = '@skale_tls_ports' + self._ensure_rule(self.chain, expr, label='TLS service ports') + self._remove_tcp_accepts(TLS_SERVICE_PORTS) + def _add_icmp_accepts(self) -> None: self.remove_source_quench_rule() for icmp_type in ICMP_ACCEPT_TYPES: @@ -840,6 +880,13 @@ def configure_nftables(keep_accept_policy: bool = False) -> None: remove_legacy_saved_rules() +def sync_tls_ports() -> None: + """Make a configured firewall follow the certificates and save it for the next boot.""" + nft_mgr = NFTablesManager() + nft_mgr.sync_tls_accepts() + save_nftables_base_rules(nft_mgr.get_base_ruleset()) + + def cleanup_nftables() -> None: """Reset the firewall after node cleanup and persist the minimal state.""" logger.info('Cleaning up firewall rules') @@ -870,8 +917,7 @@ def save_nftables_base_rules(ruleset: str) -> None: f'\t\tinclude "{NFTABLES_USER_CONFIG_PATH}"', '\t}', ] - ruleset_lines[1:1] = user_chain_lines - ruleset_lines.insert(-2, chain_include_line) + ruleset_lines[1:1] = [chain_include_line, *user_chain_lines] with open(NFTABLES_SKALE_BASE_CONFIG_PATH, 'w') as f: f.write('\n'.join(ruleset_lines)) logger.info('Rules saved successfully to %s', NFTABLES_SKALE_BASE_CONFIG_PATH) diff --git a/node_cli/core/nginx.py b/node_cli/core/nginx.py index 340e3299..b6f13e64 100644 --- a/node_cli/core/nginx.py +++ b/node_cli/core/nginx.py @@ -17,14 +17,45 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import glob +import hashlib import logging import os.path +import shutil +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Callable, Optional + +from filelock import FileLock, Timeout +from jinja2 import Environment from node_cli.cli.info import TYPE -from node_cli.configs import NODE_CERTS_PATH, NGINX_TEMPLATE_FILEPATH, NGINX_CONFIG_FILEPATH +from node_cli.configs import ( + LEGACY_NGINX_CONFIG_FILEPATH, + LEGACY_NGINX_TEMPLATE_FILEPATH, + NGINX_BASE_CONFIG_FILEPATH, + NGINX_BASE_TEMPLATE_FILEPATH, + NGINX_CHAINS_PATH, + NGINX_CONFIG_FILEPATH, + NGINX_LOCK_PATH, + NGINX_NJS_PATH, + NGINX_NJS_SOURCE_PATH, + NGINX_TEMPLATE_DIR, + NGINX_TEMPLATE_FILEPATH, + NODE_CERTS_PATH, +) +from node_cli.core.nftables import ServicePort +from node_cli.migrations.nginx_layout import migrate_nginx_layout from node_cli.utils.node_type import NodeType -from node_cli.utils.docker_utils import restart_nginx_container, docker_client -from node_cli.utils.helper import process_template +from node_cli.utils.docker_utils import ( + NginxConfigError, + docker_client, + nginx_answer, + reload_nginx_container, +) +from node_cli.utils.helper import check_ssl_certs, process_template, read_file, safe_mkdir logger = logging.getLogger(__name__) @@ -32,29 +63,110 @@ SSL_KEY_NAME = 'ssl_key' SSL_CRT_NAME = 'ssl_cert' +# loopback-only location in base.conf that answers with the file's fingerprint +PROBE_PATH = '/.skale-proxy' +BASE_HEADER = '# node base config, rendered by node-cli and skale-admin: fingerprint ' +# nginx binds all listeners of a new config or none, so one base.conf server speaks for all +BASE_PROBE_URL = f'http://127.0.0.1:{ServicePort.WATCHDOG_HTTP}{PROBE_PATH}' +# nginx retries a busy listener port five times, 500 ms apart, before it keeps the old config +APPLY_TIMEOUT_SECONDS = 10 +POLL_INTERVAL_SECONDS = 0.5 +LOCK_TIMEOUT_SECONDS = 120 + -def generate_nginx_config() -> None: +def generate_nginx_config() -> Optional[str]: + """Renders the nginx config, returns base.conf or None for streams without it""" ssl_on = check_ssl_certs() skale_node = is_skale_node_nginx() template_data = { 'ssl': ssl_on, 'skale_node': skale_node, } - logger.info(f'Processing nginx template. ssl: {ssl_on}, skale_node: {skale_node}') + logger.info(f'Processing nginx templates. ssl: {ssl_on}, skale_node: {skale_node}') + if not os.path.isdir(NGINX_TEMPLATE_DIR): + # a config stream from before node_data/nginx/ mounts one server file into conf.d + process_template( + LEGACY_NGINX_TEMPLATE_FILEPATH, LEGACY_NGINX_CONFIG_FILEPATH, template_data + ) + return None + # chains/ is filled by skale-admin, njs/ gets the static handlers from the config repo + safe_mkdir(NGINX_CHAINS_PATH) + safe_mkdir(NGINX_NJS_PATH) process_template(NGINX_TEMPLATE_FILEPATH, NGINX_CONFIG_FILEPATH, template_data) + base = render_base_config(ssl_on, skale_node) + write_atomic(NGINX_BASE_CONFIG_FILEPATH, base) + for script in glob.glob(os.path.join(NGINX_NJS_SOURCE_PATH, '*.js')): + shutil.copy(script, NGINX_NJS_PATH) + migrate_nginx_layout() + return base + + +def render_base_config(ssl_on: bool, skale_node: bool) -> str: + """base.conf fingerprinted with its certificate, rendered identically by skale-admin""" + template = Environment().from_string(read_file(NGINX_BASE_TEMPLATE_FILEPATH)) + data = {'ssl': ssl_on, 'skale_node': skale_node} + draft = template.render(data, fingerprint='') + cert_path = Path(NODE_CERTS_PATH, SSL_CRT_NAME) + cert = cert_path.read_bytes() if ssl_on and cert_path.is_file() else b'' + fingerprint = hashlib.sha256(draft.encode() + cert).hexdigest()[:16] + return template.render(data, fingerprint=fingerprint) + + +def write_atomic(path: str, text: str) -> None: + """nginx never reads a half-written file: conf.d/*.conf does not match the temporary name""" + tmp_path = f'{path}.tmp' + Path(tmp_path).write_text(text) + os.replace(tmp_path, path) -def check_ssl_certs(): - crt_path = os.path.join(NODE_CERTS_PATH, SSL_CRT_NAME) - key_path = os.path.join(NODE_CERTS_PATH, SSL_KEY_NAME) - return os.path.exists(crt_path) and os.path.exists(key_path) +def base_fingerprint(text: str) -> Optional[str]: + first_line = text.split('\n', 1)[0] + return first_line.removeprefix(BASE_HEADER) if first_line.startswith(BASE_HEADER) else None + + +def wait_for(predicate: Callable[[], bool], timeout: float) -> bool: + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() >= deadline: + return False + time.sleep(POLL_INTERVAL_SECONDS) + return True def is_skale_node_nginx() -> bool: return TYPE == NodeType.SKALE +@contextmanager +def nginx_lock() -> Iterator[None]: + """Serialises with skale-admin, which reloads nginx for chain files and certificates""" + try: + with FileLock(NGINX_LOCK_PATH, timeout=LOCK_TIMEOUT_SECONDS): + yield + except Timeout as err: + raise NginxConfigError('skale-admin holds the nginx lock') from err + + def reload_nginx() -> None: + """Re-render and reload; `nginx -s reload` exits 0 even when nginx keeps its old config""" dutils = docker_client() - generate_nginx_config() - restart_nginx_container(dutils=dutils) + base_path = Path(NGINX_BASE_CONFIG_FILEPATH) + with nginx_lock(): + previous = base_path.read_text() if base_path.is_file() else None + base = generate_nginx_config() + try: + reload_nginx_container(dutils=dutils) + served = base is None or wait_for( + lambda: base_served(base, dutils), APPLY_TIMEOUT_SECONDS + ) + if not served: + raise NginxConfigError('nginx kept its old configuration, see docker logs sk_nginx') + except Exception: + if previous is not None: + # a file nginx never ran could stop it at its next start + write_atomic(NGINX_BASE_CONFIG_FILEPATH, previous) + raise + + +def base_served(base: str, dutils) -> bool: + return nginx_answer(BASE_PROBE_URL, dutils=dutils) == f'base {base_fingerprint(base)}' diff --git a/node_cli/core/ssl/upload.py b/node_cli/core/ssl/upload.py index 5da9b20d..e44bba8e 100644 --- a/node_cli/core/ssl/upload.py +++ b/node_cli/core/ssl/upload.py @@ -22,12 +22,17 @@ from node_cli.core.ssl.check import check_cert_openssl from node_cli.core.ssl.utils import is_ssl_folder_empty, copy_cert_key_pair +from node_cli.utils.docker_utils import NginxConfigError from node_cli.utils.helper import ok_result, err_result +from node_cli.core.nftables import sync_tls_ports from node_cli.core.nginx import reload_nginx logger = logging.getLogger(__name__) +# the certificates are already in place, so a plain retry is refused as a second upload +RETRY_HINT = 'Run the upload again with --force' + def upload_cert(cert_path, key_path, force, no_client=False): try: @@ -38,5 +43,19 @@ def upload_cert(cert_path, key_path, force, no_client=False): if not is_ssl_folder_empty() and not force: return err_result(CERTS_UPLOADED_ERR_MSG) copy_cert_key_pair(cert_path, key_path) - reload_nginx() + try: + reload_nginx() + except NginxConfigError as err: + logger.exception('nginx did not apply the new certificates') + return err_result( + f'Certificates are saved, but nginx is not serving them. {err}. {RETRY_HINT}' + ) + try: + sync_tls_ports() + except Exception as err: + logger.exception('Firewall does not follow the new certificates') + return err_result( + f'Certificates are saved, but the firewall may still block the TLS ports. {err}. ' + f'{RETRY_HINT}' + ) return ok_result() diff --git a/node_cli/migrations/nginx_layout.py b/node_cli/migrations/nginx_layout.py new file mode 100644 index 00000000..24753a5b --- /dev/null +++ b/node_cli/migrations/nginx_layout.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# This file is part of node-cli +# +# Copyright (C) 2026-Present SKALE Labs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import logging +import os + +from node_cli.configs import LEGACY_NGINX_CONFIG_FILEPATH + +logger = logging.getLogger(__name__) + +LEGACY_NGINX_BACKUP_FILEPATH = f'{LEGACY_NGINX_CONFIG_FILEPATH}.bak' + + +def migrate_nginx_layout() -> None: + """Move the single-file nginx config aside, node_data/nginx/ replaces it""" + if os.path.isfile(LEGACY_NGINX_CONFIG_FILEPATH): + logger.info('Moving %s to %s', LEGACY_NGINX_CONFIG_FILEPATH, LEGACY_NGINX_BACKUP_FILEPATH) + os.replace(LEGACY_NGINX_CONFIG_FILEPATH, LEGACY_NGINX_BACKUP_FILEPATH) diff --git a/node_cli/operations/base.py b/node_cli/operations/base.py index d2bb5c57..7f89034b 100644 --- a/node_cli/operations/base.py +++ b/node_cli/operations/base.py @@ -364,6 +364,7 @@ def turn_on( configure_docker() configure_nftables() + generate_nginx_config() save_internal_settings(node_type=node_type, node_mode=node_mode, backup_run=backup_run) logger.info('Launching containers on the node...') diff --git a/node_cli/utils/docker_utils.py b/node_cli/utils/docker_utils.py index b9b8e547..c8d5e562 100644 --- a/node_cli/utils/docker_utils.py +++ b/node_cli/utils/docker_utils.py @@ -388,10 +388,36 @@ def compose_up( ) -def restart_nginx_container(dutils=None): +class NginxConfigError(Exception): + pass + + +def nginx_answer(url: str, dutils=None) -> Optional[str]: + """What nginx itself answers on url, asked from inside its container""" + dutils = dutils or docker_client() + try: + result = dutils.containers.get(NGINX_CONTAINER_NAME).exec_run( + ['curl', '-skf', '-m', '2', url] + ) + except Exception: + logger.debug('Could not ask nginx for %s', url, exc_info=True) + return None + return result.output.decode(errors='replace').strip() if result.exit_code == 0 else None + + +def reload_nginx_container(dutils=None) -> None: + """Test the config inside sk_nginx and reload it; restart the container if it is not running""" dutils = dutils or docker_client() nginx_container = dutils.containers.get(NGINX_CONTAINER_NAME) - nginx_container.restart() + if nginx_container.status != 'running': + logger.info('%s is not running, restarting it', NGINX_CONTAINER_NAME) + nginx_container.restart() + return + # a failed test leaves nginx on the old config, a restart would take it down + for cmd in (['nginx', '-t'], ['nginx', '-s', 'reload']): + result = nginx_container.exec_run(cmd) + if result.exit_code != 0: + raise NginxConfigError(result.output.decode(errors='replace')) def remove_images(images, dclient=None): diff --git a/node_cli/utils/helper.py b/node_cli/utils/helper.py index 2ae9ab50..88451c5f 100644 --- a/node_cli/utils/helper.py +++ b/node_cli/utils/helper.py @@ -58,6 +58,7 @@ STREAM_LOG_FORMAT, ) from node_cli.configs.routes import get_route +from node_cli.configs.ssl import SSL_CERT_FILEPATH, SSL_KEY_FILEPATH from node_cli.utils.api_auth import APIAuthError, get_api_headers from node_cli.utils.exit_codes import CLIExitCodes from node_cli.utils.global_config import get_system_user, read_g_config @@ -484,6 +485,11 @@ def get_ssh_port(ssh_service_name='ssh') -> int: return DEFAULT_SSH_PORT +def check_ssl_certs() -> bool: + """TLS is configured once the certificate and its key are in the node's ssl folder""" + return os.path.exists(SSL_CERT_FILEPATH) and os.path.exists(SSL_KEY_FILEPATH) + + def is_btrfs_subvolume(path: str) -> bool: """Check if the given path is a Btrfs subvolume.""" try: diff --git a/tests/core/monitoring_firewall_test.py b/tests/core/monitoring_firewall_test.py index d85f56b2..fefde8f8 100644 --- a/tests/core/monitoring_firewall_test.py +++ b/tests/core/monitoring_firewall_test.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from unittest.mock import patch @@ -16,6 +17,7 @@ def monitoring_firewall(monkeypatch, tmp_path): monkeypatch.setattr(firewall, 'NODE_CONFIG_PATH', str(tmp_path / 'node.json')) monkeypatch.setattr(firewall, 'NFTABLES_USER_CONFIG_PATH', str(tmp_path / 'user.conf')) monkeypatch.setattr(firewall, 'NFTABLES_SKALE_BASE_CONFIG_PATH', str(tmp_path / 'base.conf')) + monkeypatch.setattr(firewall, 'NFTABLES_CHAIN_FOLDER_PATH', str(tmp_path / 'chains')) monkeypatch.setattr(firewall, 'NFTABLES_CHAIN_CONFIG_WILDCARD', str(tmp_path / 'chains/*')) (tmp_path / 'chains').mkdir() (tmp_path / 'user.conf').touch() @@ -78,3 +80,54 @@ def test_monitoring_cleanup_preserves_custom_ssh_port(monitoring_firewall, monke delete.assert_not_called() manager.verify_critical_accepts() assert manager.rule_exists(manager.chain, Rule(manager.chain, 'tcp', 9100).to_expr()) + + +def tls_ports(manager): + rc, output, error = manager.nft.cmd( + f'list set {manager.family} {manager.table} skale_tls_ports' + ) + assert rc == 0, error + items = json.loads(output)['nftables'] + return sorted(next(item['set'] for item in items if 'set' in item).get('elem', [])) + + +def test_tls_ports_open_only_with_certificates(monitoring_firewall, monkeypatch): + manager = monitoring_firewall + expressions = [Rule(manager.chain, 'tcp', port).to_expr() for port in (443, 311)] + # a firewall set up before the ports depended on certificates accepts both + for expr in expressions * 2: + manager._execute_rule_with_op('add', manager.chain, expr) + + monkeypatch.setattr(firewall, 'check_ssl_certs', lambda: False) + manager.setup_firewall() + assert manager.get_chain_policy(manager.chain) == 'drop' + for expr in expressions: + assert not manager.rule_exists(manager.chain, expr) + assert tls_ports(manager) == [] + manager.verify_critical_accepts() + + monkeypatch.setattr(firewall, 'check_ssl_certs', lambda: True) + manager.sync_tls_accepts() + assert tls_ports(manager) == [311, 443] + + firewall.save_nftables_base_rules(manager.get_base_ruleset()) + manager.execute_cmd( + {'nftables': [{'delete': {'table': {'family': manager.family, 'name': manager.table}}}]} + ) + rc, _, error = manager.nft.cmd(f'include "{firewall.NFTABLES_SKALE_BASE_CONFIG_PATH}"') + assert rc == 0, error + assert tls_ports(manager) == [311, 443] + + monkeypatch.setattr(firewall, 'check_ssl_certs', lambda: False) + manager.setup_firewall() + assert tls_ports(manager) == [] + + +def test_closing_tls_ports_preserves_ssh_on_443(monitoring_firewall, monkeypatch): + monkeypatch.setenv('SSH_PORT', '443') + monkeypatch.setattr(firewall, 'check_ssl_certs', lambda: False) + manager = monitoring_firewall + manager.setup_firewall() + manager.verify_critical_accepts() + assert manager.rule_exists(manager.chain, Rule(manager.chain, 'tcp', 443).to_expr()) + assert not manager.rule_exists(manager.chain, Rule(manager.chain, 'tcp', 311).to_expr()) diff --git a/tests/core/nftables_test.py b/tests/core/nftables_test.py index c5e30617..3cb164a9 100644 --- a/tests/core/nftables_test.py +++ b/tests/core/nftables_test.py @@ -522,10 +522,12 @@ def test_setup_firewall(mock_execute, nft_manager, monkeypatch, tmp_path): get_chain_policy=Mock(return_value='accept'), update_chain_policy=Mock(), apply_user_rules=Mock(), + sync_tls_accepts=Mock(), ): nft_manager.setup_firewall() assert mock_execute.called NFTablesManager.apply_user_rules.assert_called_once() + NFTablesManager.sync_tls_accepts.assert_called_once_with() added_exprs = [ call.args[0]['nftables'][0]['add']['rule']['expr'] @@ -575,6 +577,7 @@ def test_setup_firewall_default_drop_disabled(mock_execute, nft_manager, monkeyp ensure_default_accept=Mock(), update_chain_policy=Mock(), apply_user_rules=Mock(), + sync_tls_accepts=Mock(), ): nft_manager.setup_firewall() NFTablesManager.validate_dynamic_ranges.assert_not_called() @@ -597,6 +600,7 @@ def test_setup_firewall_keep_accept_policy(mock_execute, nft_manager, monkeypatc ensure_default_accept=Mock(), update_chain_policy=Mock(), apply_user_rules=Mock(), + sync_tls_accepts=Mock(), ): nft_manager.setup_firewall(keep_accept_policy=True) NFTablesManager.ensure_default_drop.assert_not_called() diff --git a/tests/core/nginx_test.py b/tests/core/nginx_test.py index a867b0b0..8a68ca28 100644 --- a/tests/core/nginx_test.py +++ b/tests/core/nginx_test.py @@ -1,18 +1,52 @@ import os +import shutil from pathlib import Path +import docker import pytest import mock +from node_cli.core.host import is_node_inited +from node_cli.core.nftables import NFTablesError +from filelock import FileLock, Timeout + from node_cli.core.nginx import ( + BASE_PROBE_URL, + base_fingerprint, generate_nginx_config, check_ssl_certs, is_skale_node_nginx, + reload_nginx, + render_base_config, SSL_KEY_NAME, SSL_CRT_NAME, ) +from node_cli.core.ssl.upload import upload_cert +from node_cli.migrations.nginx_layout import LEGACY_NGINX_BACKUP_FILEPATH +from node_cli.utils.docker_utils import NginxConfigError, nginx_answer, reload_nginx_container from node_cli.utils.node_type import NodeType, NodeMode -from node_cli.configs import NGINX_TEMPLATE_FILEPATH, NGINX_CONFIG_FILEPATH, NODE_CERTS_PATH +from node_cli.configs import ( + LEGACY_NGINX_CONFIG_FILEPATH, + LEGACY_NGINX_TEMPLATE_FILEPATH, + NGINX_BASE_CONFIG_FILEPATH, + NGINX_BASE_TEMPLATE_FILEPATH, + NGINX_CHAINS_PATH, + NGINX_CONFIG_FILEPATH, + NGINX_DIR, + NGINX_LOCK_PATH, + NGINX_NJS_PATH, + NGINX_NJS_SOURCE_PATH, + NGINX_TEMPLATE_DIR, + NGINX_TEMPLATE_FILEPATH, + NODE_CERTS_PATH, +) + +TEST_NGINX_MAIN_TEMPLATE = """ +events {} +http { + include /etc/nginx/conf.d/*.conf; +} +""" TEST_NGINX_TEMPLATE = """ server { @@ -43,17 +77,33 @@ @pytest.fixture def nginx_template(): - """Create a temporary nginx template file.""" + """Create temporary main and base nginx templates and an njs handler.""" os.makedirs(os.path.dirname(NGINX_TEMPLATE_FILEPATH), exist_ok=True) + os.makedirs(NGINX_NJS_SOURCE_PATH, exist_ok=True) with open(NGINX_TEMPLATE_FILEPATH, 'w') as f: + f.write(TEST_NGINX_MAIN_TEMPLATE) + with open(NGINX_BASE_TEMPLATE_FILEPATH, 'w') as f: f.write(TEST_NGINX_TEMPLATE) + with open(os.path.join(NGINX_NJS_SOURCE_PATH, 'rpc.js'), 'w') as f: + f.write('export default {};\n') + try: + yield + finally: + shutil.rmtree(NGINX_TEMPLATE_DIR, ignore_errors=True) + shutil.rmtree(NGINX_DIR, ignore_errors=True) + for path in (LEGACY_NGINX_CONFIG_FILEPATH, LEGACY_NGINX_BACKUP_FILEPATH): + if os.path.isfile(path): + os.remove(path) + + +@pytest.fixture +def legacy_nginx_template(nginx_template): + shutil.rmtree(NGINX_TEMPLATE_DIR) + Path(LEGACY_NGINX_TEMPLATE_FILEPATH).write_text(TEST_NGINX_TEMPLATE) try: yield finally: - if os.path.isfile(NGINX_TEMPLATE_FILEPATH): - os.remove(NGINX_TEMPLATE_FILEPATH) - if os.path.isfile(NGINX_CONFIG_FILEPATH): - os.remove(NGINX_CONFIG_FILEPATH) + Path(LEGACY_NGINX_TEMPLATE_FILEPATH).unlink() @pytest.mark.parametrize( @@ -94,7 +144,8 @@ def test_generate_nginx_config( generate_nginx_config() assert os.path.exists(NGINX_CONFIG_FILEPATH) - with open(NGINX_CONFIG_FILEPATH) as f: + assert os.path.exists(NGINX_BASE_CONFIG_FILEPATH) + with open(NGINX_BASE_CONFIG_FILEPATH) as f: rendered_config = f.read() rendered_config = rendered_config.strip() @@ -144,3 +195,279 @@ def test_is_skale_node_nginx(mock_type, node_type, node_mode, expected_result): mock_type.__ne__.side_effect = lambda other: node_type != other assert is_skale_node_nginx() is expected_result + + +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +def test_generate_nginx_config_layout(mock_check_ssl, nginx_template): + generate_nginx_config() + assert os.path.isdir(NGINX_CHAINS_PATH) + assert os.path.isfile(os.path.join(NGINX_NJS_PATH, 'rpc.js')) + with open(NGINX_CONFIG_FILEPATH) as f: + assert 'include /etc/nginx/conf.d/*.conf;' in f.read() + + +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +def test_generate_nginx_config_migrates_legacy_file(mock_check_ssl, nginx_template): + Path(LEGACY_NGINX_CONFIG_FILEPATH).write_text('server {}') + assert is_node_inited() + generate_nginx_config() + assert not os.path.exists(LEGACY_NGINX_CONFIG_FILEPATH) + with open(LEGACY_NGINX_BACKUP_FILEPATH) as f: + assert f.read() == 'server {}' + assert is_node_inited() + + +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +def test_generate_nginx_config_legacy_stream(mock_check_ssl, legacy_nginx_template): + generate_nginx_config() + assert os.path.isfile(LEGACY_NGINX_CONFIG_FILEPATH) + assert not os.path.exists(NGINX_CONFIG_FILEPATH) + assert is_node_inited() + + +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +def test_generate_nginx_config_missing_base_template(mock_check_ssl, nginx_template): + os.remove(NGINX_BASE_TEMPLATE_FILEPATH) + with pytest.raises(FileNotFoundError): + generate_nginx_config() + assert not os.path.exists(LEGACY_NGINX_CONFIG_FILEPATH) + + +def test_is_node_inited_marker(nginx_template): + assert not is_node_inited() + Path(LEGACY_NGINX_CONFIG_FILEPATH).write_text('server {}') + assert is_node_inited() + os.remove(LEGACY_NGINX_CONFIG_FILEPATH) + os.makedirs(NGINX_DIR, exist_ok=True) + Path(NGINX_CONFIG_FILEPATH).touch() + assert is_node_inited() + + +def nginx_container_mock(status='running', test_code=0, reload_code=0): + container = mock.Mock(status=status) + results = { + ('nginx', '-t'): mock.Mock(exit_code=test_code, output=b'test output'), + ('nginx', '-s', 'reload'): mock.Mock(exit_code=reload_code, output=b'no master'), + } + container.exec_run.side_effect = lambda cmd: results[tuple(cmd)] + dclient = mock.Mock() + dclient.containers.get.return_value = container + return dclient, container + + +def test_reload_nginx_container(): + dclient, container = nginx_container_mock() + reload_nginx_container(dutils=dclient) + assert [c.args[0] for c in container.exec_run.call_args_list] == [ + ['nginx', '-t'], + ['nginx', '-s', 'reload'], + ] + container.restart.assert_not_called() + + +def test_reload_nginx_container_bad_config(): + dclient, container = nginx_container_mock(test_code=1) + with pytest.raises(NginxConfigError): + reload_nginx_container(dutils=dclient) + # nginx keeps serving the old config + assert container.exec_run.call_count == 1 + container.restart.assert_not_called() + + +def test_reload_nginx_container_reload_fails(): + dclient, container = nginx_container_mock(reload_code=1) + with pytest.raises(NginxConfigError, match='no master'): + reload_nginx_container(dutils=dclient) + + +def test_reload_nginx_container_not_running(): + dclient, container = nginx_container_mock(status='exited') + reload_nginx_container(dutils=dclient) + container.restart.assert_called_once() + container.exec_run.assert_not_called() + + +def test_nginx_answer(): + dclient, container = nginx_container_mock() + container.exec_run.side_effect = None + container.exec_run.return_value = mock.Mock(exit_code=0, output=b'base 0123\n') + assert nginx_answer('http://127.0.0.1:3009/.skale-proxy', dutils=dclient) == 'base 0123' + container.exec_run.return_value = mock.Mock(exit_code=7, output=b'') + assert nginx_answer('https://127.0.0.1:311/.skale-proxy', dutils=dclient) is None + dclient.containers.get.side_effect = docker.errors.NotFound('sk_nginx') + assert nginx_answer('http://127.0.0.1:3009/.skale-proxy', dutils=dclient) is None + + +FINGERPRINT_TEMPLATE = """# node base config, rendered by node-cli and skale-admin: fingerprint {{ fingerprint }} +server { + listen 3009; + {% if ssl %}listen 311 ssl;{% endif %} + location = /.skale-proxy { return 200 'base {{ fingerprint }}'; } +} +""" + + +@pytest.fixture +def fingerprint_template(nginx_template): + with open(NGINX_BASE_TEMPLATE_FILEPATH, 'w') as f: + f.write(FINGERPRINT_TEMPLATE) + + +def test_render_base_config_fingerprint(fingerprint_template, ssl_folder): + plain = render_base_config(ssl_on=False, skale_node=True) + assert render_base_config(ssl_on=False, skale_node=True) == plain + fingerprint = base_fingerprint(plain) + assert len(fingerprint) == 16 + assert f"return 200 'base {fingerprint}';" in plain + + cert_path = Path(NODE_CERTS_PATH, SSL_CRT_NAME) + cert_path.write_text('first certificate') + with_ssl = render_base_config(ssl_on=True, skale_node=True) + assert base_fingerprint(with_ssl) != fingerprint + # a rotated certificate changes the file even though the listeners stay the same + cert_path.write_text('second certificate') + rotated = render_base_config(ssl_on=True, skale_node=True) + assert base_fingerprint(rotated) != base_fingerprint(with_ssl) + assert rotated.replace(base_fingerprint(rotated), '') == with_ssl.replace( + base_fingerprint(with_ssl), '' + ) + + +def read_base() -> str: + with open(NGINX_BASE_CONFIG_FILEPATH) as f: + return f.read() + + +def serve_base_on_disk(url, dutils=None): + return f'base {base_fingerprint(read_base())}' + + +@mock.patch('node_cli.core.nginx.POLL_INTERVAL_SECONDS', 0) +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +@mock.patch('node_cli.core.nginx.docker_client') +@mock.patch('node_cli.core.nginx.reload_nginx_container') +def test_reload_nginx_waits_for_nginx_to_serve_new_base( + mock_reload, mock_client, mock_ssl, fingerprint_template +): + stale = ['base 0000000000000000'] + + def nginx_catches_up(url, dutils=None): + # skale-admin waits until the probe is over + with pytest.raises(Timeout): + FileLock(NGINX_LOCK_PATH, blocking=False).acquire() + return stale.pop() if stale else serve_base_on_disk(url) + + with mock.patch('node_cli.core.nginx.nginx_answer', side_effect=nginx_catches_up) as answer: + reload_nginx() + mock_reload.assert_called_once() + assert [call.args[0] for call in answer.call_args_list] == [BASE_PROBE_URL] * 2 + + +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +@mock.patch('node_cli.core.nginx.docker_client') +@mock.patch('node_cli.core.nginx.reload_nginx_container') +def test_reload_nginx_legacy_stream(mock_reload, mock_client, mock_ssl, legacy_nginx_template): + with mock.patch('node_cli.core.nginx.nginx_answer') as answer: + reload_nginx() + mock_reload.assert_called_once() + answer.assert_not_called() + + +@mock.patch('node_cli.core.nginx.APPLY_TIMEOUT_SECONDS', 0.2) +@mock.patch('node_cli.core.nginx.POLL_INTERVAL_SECONDS', 0.05) +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +@mock.patch('node_cli.core.nginx.docker_client') +@mock.patch('node_cli.core.nginx.reload_nginx_container') +def test_reload_nginx_restores_base_nginx_did_not_take( + mock_reload, mock_client, mock_ssl, fingerprint_template +): + os.makedirs(os.path.dirname(NGINX_BASE_CONFIG_FILEPATH), exist_ok=True) + with open(NGINX_BASE_CONFIG_FILEPATH, 'w') as f: + f.write('# the base.conf nginx still runs\n') + # the reload signal was sent, but nginx still answers with the old file + with mock.patch('node_cli.core.nginx.nginx_answer', return_value='base 0000000000000000'): + with pytest.raises(NginxConfigError): + reload_nginx() + assert read_base() == '# the base.conf nginx still runs\n' + mock_reload.assert_called_once() + + +@pytest.mark.parametrize('previous', [None, '# the base.conf nginx still runs\n']) +@mock.patch('node_cli.core.nginx.check_ssl_certs', return_value=False) +@mock.patch('node_cli.core.nginx.docker_client') +@mock.patch('node_cli.core.nginx.reload_nginx_container') +def test_reload_nginx_with_config_nginx_rejects( + mock_reload, mock_client, mock_ssl, fingerprint_template, previous +): + os.makedirs(os.path.dirname(NGINX_BASE_CONFIG_FILEPATH), exist_ok=True) + if previous is not None: + Path(NGINX_BASE_CONFIG_FILEPATH).write_text(previous) + mock_reload.side_effect = NginxConfigError('nginx -t failed') + with pytest.raises(NginxConfigError, match='nginx -t failed'): + reload_nginx() + # the earlier file comes back, a first render stays for the next attempt + assert read_base() == (previous or render_base_config(ssl_on=False, skale_node=True)) + + +@mock.patch('node_cli.core.nginx.LOCK_TIMEOUT_SECONDS', 0.1) +@mock.patch('node_cli.core.nginx.docker_client') +@mock.patch('node_cli.core.nginx.reload_nginx_container') +def test_reload_nginx_waits_for_skale_admin_lock(mock_reload, mock_client, fingerprint_template): + with FileLock(NGINX_LOCK_PATH), pytest.raises(NginxConfigError, match='nginx lock'): + reload_nginx() + mock_reload.assert_not_called() + + +@mock.patch('node_cli.core.ssl.upload.copy_cert_key_pair') +@mock.patch('node_cli.core.ssl.upload.check_cert_openssl') +@mock.patch('node_cli.core.ssl.upload.is_ssl_folder_empty', return_value=True) +def test_upload_cert_reports_certificates_nginx_does_not_serve(mock_empty, mock_check, mock_copy): + with mock.patch( + 'node_cli.core.ssl.upload.reload_nginx', side_effect=NginxConfigError('kept old config') + ): + status, payload = upload_cert('cert.pem', 'key.pem', force=False) + assert status == 'error' + assert payload.startswith('Certificates are saved, but nginx is not serving them.') + assert payload.endswith('Run the upload again with --force') + mock_copy.assert_called_once() + + +@mock.patch('node_cli.core.ssl.upload.copy_cert_key_pair') +@mock.patch('node_cli.core.ssl.upload.check_cert_openssl') +@mock.patch('node_cli.core.ssl.upload.is_ssl_folder_empty', return_value=True) +def test_upload_cert_opens_tls_ports_once_nginx_serves_them(mock_empty, mock_check, mock_copy): + steps = mock.Mock() + with ( + mock.patch('node_cli.core.ssl.upload.reload_nginx', steps.reload_nginx), + mock.patch('node_cli.core.ssl.upload.sync_tls_ports', steps.sync_tls_ports), + ): + assert upload_cert('cert.pem', 'key.pem', force=False) == ('ok', None) + assert steps.mock_calls == [mock.call.reload_nginx(), mock.call.sync_tls_ports()] + + steps.reset_mock() + steps.reload_nginx.side_effect = NginxConfigError('kept old config') + with ( + mock.patch('node_cli.core.ssl.upload.reload_nginx', steps.reload_nginx), + mock.patch('node_cli.core.ssl.upload.sync_tls_ports', steps.sync_tls_ports), + ): + status, _ = upload_cert('cert.pem', 'key.pem', force=False) + assert status == 'error' + steps.sync_tls_ports.assert_not_called() + + +@mock.patch('node_cli.core.ssl.upload.copy_cert_key_pair') +@mock.patch('node_cli.core.ssl.upload.check_cert_openssl') +@mock.patch('node_cli.core.ssl.upload.is_ssl_folder_empty', return_value=True) +@mock.patch('node_cli.core.ssl.upload.reload_nginx') +def test_upload_cert_reports_firewall_that_was_not_updated( + mock_reload, mock_empty, mock_check, mock_copy +): + with mock.patch( + 'node_cli.core.ssl.upload.sync_tls_ports', side_effect=NFTablesError('not permitted') + ): + status, payload = upload_cert('cert.pem', 'key.pem', force=False) + assert status == 'error' + assert payload == ( + 'Certificates are saved, but the firewall may still block the TLS ports. not permitted. ' + 'Run the upload again with --force' + ) diff --git a/tests/core/ssh_firewall_test.py b/tests/core/ssh_firewall_test.py index 7689c881..16dea474 100644 --- a/tests/core/ssh_firewall_test.py +++ b/tests/core/ssh_firewall_test.py @@ -19,6 +19,7 @@ def test_allow_all_ssh_ports(manager): patch('node_cli.core.nftables.get_ssh_ports', return_value=[2200, 2222]), patch.object(manager, '_ensure_rule'), patch.object(manager, 'remove_misordered_udp_drop'), + patch.object(manager, 'sync_tls_accepts'), patch.object(manager, 'add_rule', side_effect=rules.append), ): manager._add_service_accepts() diff --git a/tests/core/tls_firewall_persistence_test.py b/tests/core/tls_firewall_persistence_test.py new file mode 100644 index 00000000..66acb9d6 --- /dev/null +++ b/tests/core/tls_firewall_persistence_test.py @@ -0,0 +1,59 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from node_cli.core import nftables as firewall + + +@pytest.mark.parametrize('certificates', [False, True]) +def test_tls_ports_are_saved_for_restore(tmp_path, monkeypatch, certificates): + nft = Mock() + nft.cmd.return_value = (0, '', '') + nft.json_cmd.return_value = (0, '', '') + monkeypatch.setattr(firewall, 'nftables', SimpleNamespace(Nftables=lambda: nft), raising=False) + manager = firewall.NFTablesManager() + monkeypatch.setattr(firewall, 'NFTablesManager', lambda: manager) + monkeypatch.setattr(manager, 'get_rules', lambda chain: []) + monkeypatch.setattr( + manager, + 'get_base_ruleset', + lambda: ( + 'table inet firewall {\n' + '\tchain skale {\n' + '\t\ttcp dport @skale_tls_ports counter accept\n' + '\t}\n' + '}\n' + ), + ) + chains = tmp_path / 'chains' + chains.mkdir() + user_config = tmp_path / 'user.conf' + user_config.touch() + base_config = tmp_path / 'base.conf' + monkeypatch.setattr(firewall, 'NFTABLES_CHAIN_FOLDER_PATH', chains) + monkeypatch.setattr(firewall, 'NFTABLES_CHAIN_CONFIG_WILDCARD', str(chains / '*.conf')) + monkeypatch.setattr(firewall, 'NFTABLES_USER_CONFIG_PATH', str(user_config)) + monkeypatch.setattr(firewall, 'NFTABLES_SKALE_BASE_CONFIG_PATH', str(base_config)) + monkeypatch.setattr(firewall, 'check_ssl_certs', lambda: certificates) + monkeypatch.setattr(firewall, 'get_ssh_ports', lambda: [22]) + + for certificates in (certificates, True, True, False): + firewall.sync_tls_ports() + elements = ' elements = { 443, 311 };' if certificates else '' + assert (chains / 'tls-ports.conf').read_text() == ( + f'set skale_tls_ports {{ type inet_service;{elements} }}\n' + ) + commands = nft.cmd.call_args.args[0] + assert 'flush set inet firewall skale_tls_ports\n' in commands + if certificates: + assert 'add element inet firewall skale_tls_ports { 443, 311 }\n' in commands + else: + assert 'add element' not in commands + + saved = base_config.read_text() + include = f'include "{chains}/*.conf"' + assert saved.count(include) == 1 + assert saved.index(include) < saved.index('tcp dport @skale_tls_ports') + assert f'include "{user_config}"' in saved + assert sorted(path.name for path in chains.glob('*.conf')) == ['tls-ports.conf'] diff --git a/tests/core/tls_firewall_test.py b/tests/core/tls_firewall_test.py new file mode 100644 index 00000000..0b56ee8d --- /dev/null +++ b/tests/core/tls_firewall_test.py @@ -0,0 +1,105 @@ +from unittest.mock import Mock, patch + +import pytest + +from node_cli.core.nftables import NFTablesError, NFTablesManager, Rule, sync_tls_ports + +TLS_RULES = [Rule('skale', 'tcp', 443), Rule('skale', 'tcp', 311)] + + +@pytest.fixture +def manager(tmp_path, monkeypatch): + # These checks exercise rule generation without accessing the host firewall. + manager = NFTablesManager.__new__(NFTablesManager) + manager.chain = 'skale' + manager.family = 'inet' + manager.table = 'firewall' + manager.nft = Mock() + manager.nft.cmd.return_value = (0, '', '') + monkeypatch.setattr('node_cli.core.nftables.NFTABLES_CHAIN_FOLDER_PATH', tmp_path) + return manager + + +def service_accepts(manager, certs, ssh_ports, installed=()): + """Rules added and handles deleted by one pass over a chain holding the installed rules""" + added, deleted = [], [] + rules = [{'handle': handle, 'expr': rule.to_expr()} for handle, rule in enumerate(installed)] + with ( + patch('node_cli.core.nftables.check_ssl_certs', return_value=certs), + patch('node_cli.core.nftables.get_ssh_ports', return_value=ssh_ports), + patch.object(manager, '_ensure_rule'), + patch.object(manager, 'remove_misordered_udp_drop'), + patch.object(manager, 'get_rules', return_value=rules), + patch.object(manager, 'add_rule', side_effect=added.append), + patch.object(manager, 'delete_rule_by_handle', side_effect=deleted.append), + ): + manager._add_service_accepts() + return added, deleted + + +def test_tls_ports_open_with_certificates(manager, tmp_path): + added, deleted = service_accepts(manager, certs=True, ssh_ports=[22]) + assert not any(rule in added for rule in TLS_RULES) + assert deleted == [] + assert 'elements = { 443, 311 }' in (tmp_path / 'tls-ports.conf').read_text() + assert ( + 'add element inet firewall skale_tls_ports { 443, 311 }' + in (manager.nft.cmd.call_args.args[0]) + ) + + +def test_tls_ports_closed_without_certificates(manager, tmp_path): + # a setup that opened the ports unconditionally left accepts behind, some of them twice + installed = [Rule('skale', 'tcp', 22), *TLS_RULES, Rule('skale', 'tcp', 80), TLS_RULES[0]] + added, deleted = service_accepts(manager, certs=False, ssh_ports=[22], installed=installed) + assert not any(rule in added for rule in TLS_RULES) + assert deleted == [1, 2, 4] + # the plain HTTP services do not depend on certificates + assert Rule('skale', 'tcp', 80) in added + assert Rule('skale', 'tcp', 3009) in added + assert (tmp_path / 'tls-ports.conf').read_text() == ( + 'set skale_tls_ports { type inet_service; }\n' + ) + assert 'add element' not in manager.nft.cmd.call_args.args[0] + + +def test_closing_tls_ports_keeps_ssh_reachable(manager): + added, deleted = service_accepts(manager, certs=False, ssh_ports=[443], installed=TLS_RULES) + assert Rule('skale', 'tcp', 443) in added + assert deleted == [1] + + +def test_sync_tls_ports_saves_the_rules_for_the_next_boot(): + with ( + patch('node_cli.core.nftables.NFTablesManager') as manager_cls, + patch('node_cli.core.nftables.save_nftables_base_rules') as save, + ): + manager_cls.return_value.get_base_ruleset.return_value = 'table inet firewall {}' + sync_tls_ports() + manager_cls.return_value.sync_tls_accepts.assert_called_once_with() + save.assert_called_once_with('table inet firewall {}') + + +def test_tls_set_failure_preserves_saved_ports(manager, tmp_path): + path = tmp_path / 'tls-ports.conf' + path.write_text('previous') + manager.nft.cmd.return_value = (1, '', 'permission denied') + with patch('node_cli.core.nftables.check_ssl_certs', return_value=True): + with pytest.raises(NFTablesError, match='permission denied'): + manager.sync_tls_accepts() + assert path.read_text() == 'previous' + + +def test_tls_set_accept_precedes_removal_of_legacy_rules(manager): + events = [] + with ( + patch('node_cli.core.nftables.check_ssl_certs', return_value=True), + patch.object(manager, '_ensure_rule', side_effect=lambda *a, **kw: events.append(a)), + patch.object(manager, '_remove_tcp_accepts', side_effect=lambda *a: events.append(a)), + ): + manager.sync_tls_accepts() + chain, expr = events[0] + assert chain == 'skale' + assert expr[0]['match']['right'] == '@skale_tls_ports' + assert expr[-1] == {'accept': None} + assert events[1] == ((443, 311),)