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),)