"""TLS termination, which belongs to the proxy layer because decrypting means terminating. The engine decrypts from the client and re-encrypts towards the service inside the process that filters, so a TLS service occupies no port beyond the one it already answered on. That replaced nginx terminating on one derived loopback port and re-encrypting from a second — a pair of *chosen* ports per address, produced by hashing `ip:port`, which could collide with something real. """ import time import pytest from integration.conftest import SETTLE, add_regex_filter, start_and_settle from helpers.certs import (UNPARSEABLE_CERT, UNPARSEABLE_KEY, ecdsa_cert, ed25519_cert, rsa_cert) from helpers.tls_helpers import tls_alpn_choice, tls_connect_send_recv from helpers.traffic import Channel pytestmark = [pytest.mark.instance, pytest.mark.tls] def test_tls_is_refused_on_a_layer_that_cannot_carry_it(api, stand_in, certificate): """Two of the three combinations the old boolean made expressible are now unrepresentable — TLS is one of the protocol's values, beside `tcp` and `udp`, not a flag on top of one. The third is caught at creation, with an empty chain, because a row that is created happily and then refuses to start every time is a trap.""" cert, key = certificate() server = stand_in() why = api.services_add_error( name=f"nfq-tls-{server.port}", transport="nfqueue", proto="tls", addresses=[{"ip_int": "127.0.0.1", "port": server.port}], tls_cert=cert, tls_key=key, ) assert why is not None, "a TLS service was created on the NFQUEUE layer" def test_a_decrypted_service_filters_the_plaintext(api, protected, tls_layer): service_id, server, port = protected(tls_layer, name="tlsflt") add_regex_filter(api, service_id, "BLOCKME") start_and_settle(api, service_id) channel = Channel(server, port, tls_layer.ipv6, tls=True) assert channel.gets_through(b"harmless traffic") assert channel.is_blocked(b"carrying BLOCKME"), \ "the filters are not seeing the decrypted stream" @pytest.mark.parametrize("mint,label", [ (lambda ip: rsa_cert(ip, 2048), "rsa-2048"), (lambda ip: rsa_cert(ip, 4096), "rsa-4096"), (lambda ip: ecdsa_cert(ip), "ecdsa-p256"), (lambda ip: ed25519_cert(ip), "ed25519"), ]) def test_every_key_the_engine_will_sign_with_is_accepted(api, service, stand_in, mint, label): """RSA at 2048 bits or more, ECDSA, or Ed25519 — what `ring` will sign with.""" cert, key = mint("127.0.0.1") server = stand_in(tls=(cert, key)) service_id = service(f"key-{label}-{server.port}", "127.0.0.1", server.port, "proxy", proto="tls", tls_cert=cert, tls_key=key) start_and_settle(api, service_id) payload = f"hello from {label}".encode() assert tls_connect_send_recv(server.port, False, payload) == payload def test_a_key_too_small_for_the_engine_is_refused_carrying_its_own_reason( api, service, stand_in, certificate): """nginx could be told to take an under-2048-bit RSA key with `@SECLEVEL=1`. rustls has no equivalent — its signing backend refuses such a key outright — so this is a limit rather than an unconfigured setting. What matters is that the operator is told that, rather than being handed "the proxy engine did not report a listening port" and left to find the reason in a container log. """ cert, key = certificate("127.0.0.1", key_size=1024) server = stand_in(tls=(cert, key)) service_id = service(f"weak-{server.port}", "127.0.0.1", server.port, "proxy", proto="tls", tls_cert=cert, tls_key=key) why = api.services_start_error(service_id) assert why is not None, "a 1024-bit key was accepted" assert "key" in str(why).lower(), why @pytest.mark.parametrize("offers,speaks,expected,why", [ (["h2", "http/1.1"], ["h2", "http/1.1"], "h2", "both ends want h2"), (["h2", "http/1.1"], ["http/1.1"], "http/1.1", "the service does not speak h2, so neither does the client"), (["http/1.1"], ["h2", "http/1.1"], "http/1.1", "the client only offered one"), (["h2"], ["http/1.1"], None, "nobody agrees, and no agreement is invented"), (["h2", "http/1.1"], None, None, "the service offers no ALPN at all"), ]) def test_alpn_is_the_services_answer_carried_not_the_proxys( api, service, stand_in, certificate, offers, speaks, expected, why): """Terminating in the middle of a connection means answering for a service. Answering something the service did not say is how a proxy breaks a protocol it was only supposed to carry: a client told `h2` against an HTTP/1.1 service sends frames nothing can read. So the ClientHello is held open, the service is asked with exactly the client's list, and the client is told exactly what came back — including nothing. """ cert, key = certificate() server = stand_in(tls=(cert, key), alpn=speaks) service_id = service(f"alpn-{server.port}", "127.0.0.1", server.port, "proxy", proto="tls", tls_cert=cert, tls_key=key) start_and_settle(api, service_id, wait=1.5) assert tls_alpn_choice(server.port, False, offers) == expected, why def test_one_broken_tls_service_is_one_broken_tls_service(api, service, stand_in, certificate): """It used to be able to take every other one down with it. nginx parses its configuration as a unit and refuses to start if any one `ssl_certificate` will not load, so a service whose material was damaged left *every* TLS service on the instance refusing connections on ports that had been working. Each service now carries its certificate into its own engine process, so the blast radius is structural — which is worth a test precisely because nothing in the code says so any more. """ cert, key = certificate() neighbour = stand_in(tls=(cert, key)) broken_server = stand_in() good = service(f"ok-{neighbour.port}", "127.0.0.1", neighbour.port, "proxy", proto="tls", tls_cert=cert, tls_key=key) bad = service(f"brk-{broken_server.port}", "127.0.0.1", broken_server.port, "proxy") refusal = api.services_edit_error(bad, proto="tls") assert refusal is not None, "switching to TLS with nothing stored was accepted" # Forced past that refusal the only way left: material whose envelope is right and # whose body will not parse. The envelope check cannot judge this one, so it is the # engine that has to say so. assert api.services_edit(bad, proto="tls", tls_cert=UNPARSEABLE_CERT, tls_key=UNPARSEABLE_KEY) start_and_settle(api, good) why = api.services_start_error(bad) assert why is not None, "a service with unparseable material started" assert "certificate" in str(why).lower() or "key" in str(why).lower(), why time.sleep(SETTLE) assert tls_connect_send_recv(neighbour.port, False, b"still here") == b"still here", \ "the broken service took its neighbour down with it"