From bd14af76b70c0003b9ef2956c8696b08ba59a4c9 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 28 Oct 2024 11:42:10 +0100 Subject: [PATCH 1/8] testing: Add a fixture to set $PATH to CLN versions Fewer demands on the environments, better managed, should make it simpler to use. --- libs/gl-testing/gltesting/fixtures.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/libs/gl-testing/gltesting/fixtures.py b/libs/gl-testing/gltesting/fixtures.py index 913439008..5d66f0783 100644 --- a/libs/gl-testing/gltesting/fixtures.py +++ b/libs/gl-testing/gltesting/fixtures.py @@ -14,6 +14,7 @@ from gltesting.network import node_factory from pyln.testing.fixtures import directory as str_directory from decimal import Decimal +from clnvm import ClnVersionManager logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) @@ -21,10 +22,35 @@ logging.getLogger("hpack").setLevel(logging.ERROR) logger = logging.getLogger(__name__) + +@pytest.fixture(autouse=True) +def paths(): + """A fixture to ensure that we have all CLN versions and that + `PATH` points to the latest one. + + If you'd like to test a development version rather than the + released ones, ensure that its executable path is in the PATH + before calling `pytest` since this appends to the end. + + """ + vm = ClnVersionManager() + versions = vm.get_versions() + + # Should be a no-op after the first run + vm.get_all() + + latest = [v for v in versions if 'gl' in v.tag][-1] + + os.environ['PATH'] += f":{vm.get_target_path(latest) / 'usr' / 'local' / 'bin'}" + + yield + + @pytest.fixture() def directory(str_directory : str) -> Path: return Path(str_directory) / "gl-testing" + @pytest.fixture() def cert_directory(directory): yield directory / "certs" From f51f0f7e0267f2f74371907610bdc31a98682164 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Thu, 24 Oct 2024 14:19:39 +0200 Subject: [PATCH 2/8] make: Add a `check-testing-py` to run `pytest` in `gl-testing` --- Makefile | 2 +- libs/gl-testing/Makefile | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a33e0c30e..aa77e979a 100644 --- a/Makefile +++ b/Makefile @@ -105,7 +105,7 @@ build-self: ensure-docker cd libs/gl-client-py; maturin develop #mypy examples/python -check-all: check-rs check-self check-py +check-all: check-rs check-self check-py check-testing-py check-self: ensure-docker build-self PYTHONPATH=/repo/libs/gl-testing \ diff --git a/libs/gl-testing/Makefile b/libs/gl-testing/Makefile index 7af435a79..8c16e3f78 100644 --- a/libs/gl-testing/Makefile +++ b/libs/gl-testing/Makefile @@ -18,6 +18,10 @@ GENALL += ${GEN_TESTING} ${TESTINGDIR}/gltesting/scheduler_grpc.py: testgrpc +check-testing-py: + #cd ${TESTINGDIR}; mypy gltesting + cd ${TESTINGDIR}; poetry install --with=dev; poetry run pytest tests -n 4 -vvv + testgrpc: ${REPO_ROOT}/libs/proto/glclient/scheduler.proto python -m grpc_tools.protoc ${TESTPROTOC_OPTS} glclient/scheduler.proto mv ${TESTINGDIR}/gltesting/glclient/scheduler_grpc.py ${TESTINGDIR}/gltesting/scheduler_grpc.py From 9ccb23313a64d7fb1343d98016412a9451dc911c Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Thu, 24 Oct 2024 14:20:21 +0200 Subject: [PATCH 3/8] testing: Ensure we have a CLN version to use in NodeFactory It turns out we were accessing the external `$PATH` which may or may not contain a valid `lightningd`.` --- libs/gl-testing/gltesting/fixtures.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/libs/gl-testing/gltesting/fixtures.py b/libs/gl-testing/gltesting/fixtures.py index 5d66f0783..3347e6a9a 100644 --- a/libs/gl-testing/gltesting/fixtures.py +++ b/libs/gl-testing/gltesting/fixtures.py @@ -160,3 +160,20 @@ def clients(directory, scheduler, nobody_id): directory=directory / "clients", scheduler=scheduler, nobody_id=nobody_id ) yield clients + + +@pytest.fixture(scope='session', autouse=True) +def cln_path() -> Path: + """Ensure that the latest CLN version is in PATH. + + Some tests use the NodeFactory directly, which relies on being + able to pick a recent CLN version from the `$PATH`. By appending + at the end we just ensure that there is a CLN version to be found. + + This is our Elephant in Cairo :-) + https://en.wikipedia.org/wiki/Elephant_in_Cairo + """ + manager = ClnVersionManager() + v = manager.latest() + os.environ['PATH'] += f":{v.bin_path}" + return v.bin_path From d936e8363d95a6b3dfc8670fb8bde6be34b8bde2 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Tue, 29 Oct 2024 16:36:20 +0100 Subject: [PATCH 4/8] testing: Migrate `gl-testing` from `poetry` to `uv` `uv` gives us more control, and fewer failure opportunities, so let's use it. --- libs/gl-testing/pyproject.toml | 66 ++++++++++++---------------------- pyproject.toml | 2 ++ 2 files changed, 24 insertions(+), 44 deletions(-) diff --git a/libs/gl-testing/pyproject.toml b/libs/gl-testing/pyproject.toml index 45f4a5304..1e97b5b65 100644 --- a/libs/gl-testing/pyproject.toml +++ b/libs/gl-testing/pyproject.toml @@ -1,51 +1,29 @@ -[tool.poetry] +[project] name = "gltesting" version = "0.3.0" description = "" -authors = ["Christian Decker "] -license = "MIT" - -packages = [ - { include = "gltesting" }, +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "cln-version-manager", + "flaky>=3.8.1", + "gl-client", + "grpcio-tools>=1.66", + "grpcio>=1.66.0", + "httpx>=0.27.2", + "purerpc>=0.8.0", + "pyln-testing==24.8.1", + "pytest-timeout>=2.3.1", + "pytest-xdist>=3.6.1", + "rich>=13.9.3", + "sh>=1.14.3", + "sonora>=0.2.3", ] -[tool.poetry.dependencies] -python = "^3.8" -cryptography = ">=36.0.1" -ephemeral-port-reserve = "^1.1.4" -sh = "^1.14.2" -pytest-timeout = "^2.1.0" -pytest-xdist = "^2.5.0" -pytest = "^7.1.2" -pytest-cov = "^3.0.0" -rich = "^12.5.1" -pyln-testing = "==24.02" -pyln-client = "==24.02" -pyln-grpc-proto = "^0.1" -protobuf = ">=3" -flaky = "^3" -anyio = "^3.7.1" -aiostream = "^0.5.1" -purerpc = { version = "^0.8.0", extras = ["grpc"] } -pytest-sugar = "^0.9.7" - -# The dependency below is defined in this repository -# PyPi cannot support relative paths. -# -# Users, who `pip install` this library retrieve the sources from `PyPi`. -# using the version numbers listed below -# -# Contributors should use `poetry install` in the project root -# to insure the installation is editable from local sources. -gl-client = "0.3.0" -cln-version-manager = "^0.1.0" -[tool.poetry.group.dev.dependencies] -mypy = "^1" -typed-ast = "^1.5.4" -cln-version-manager = { path="../cln-version-manager", develop=true } -grpcio-tools = "^1.62.1" +[tool.uv] +package = true -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +[tool.uv.sources] +gl-client = { path = "../gl-client-py" } +cln-version-manager = { path = "../cln-version-manager" } diff --git a/pyproject.toml b/pyproject.toml index 8c47ee4e5..76029b65b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,3 +39,5 @@ pillow = "^9.5.0" [tool.poetry.group.lsp_ide.dependencies] python-lsp-server = "^1.10.0" +[tool.uv.workspace] +members = ["libs/gl-testing"] From 970bb4152c3a2b5112722fde4b12d3ba55cda753 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Tue, 29 Oct 2024 16:39:58 +0100 Subject: [PATCH 5/8] testing: Add a simple grpc-web proxy This proxy is used in the local testing environment to provide node-access to browser based clients. It strips the transport authentication, and replaces it with the payload authentication already used for the signer context. --- libs/gl-testing/gltesting/grpcweb.py | 108 +++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 libs/gl-testing/gltesting/grpcweb.py diff --git a/libs/gl-testing/gltesting/grpcweb.py b/libs/gl-testing/gltesting/grpcweb.py new file mode 100644 index 000000000..2a7588744 --- /dev/null +++ b/libs/gl-testing/gltesting/grpcweb.py @@ -0,0 +1,108 @@ +# A simple grpc-web proxy enabling web-clients to talk to +# Greenlight. Unlike the direct grpc interface exposed by the node and +# the node domain proxy, the grpc-web proxy does not require a client +# certificate from the client, making it possible for browsers to talk +# to it. The client authentication via client certificates is no +# longer present, but the payloads are still signed by the authorized +# client, assuring authentication of the client. + +from gltesting.scheduler import Scheduler +from ephemeral_port_reserve import reserve +from threading import Thread, Event +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +import logging +import struct + + +class GrpcWebProxy(object): + def __init__(self, scheduler: Scheduler, grpc_port: int): + self.logger = logging.getLogger("gltesting.grpcweb.GrpcWebProxy") + self.scheduler = scheduler + self.web_port = reserve() + self._thread: None | Thread = None + self.running = False + self.grpc_port = grpc_port + self.httpd: None | ThreadingHTTPServer = None + self.logger.info( + f"GrpcWebProxy configured to forward requests from web_port={self.web_port} to grpc_port={self.grpc_port}" + ) + + def start(self): + self._thread = Thread(target=self.run, daemon=True) + self.logger.info(f"Starting grpc-web-proxy on port {self.web_port}") + self.running = True + server_address = ("127.0.0.1", self.web_port) + + self.httpd = ThreadingHTTPServer(server_address, Handler) + self.httpd.grpc_port = self.grpc_port + self.logger.debug(f"Server startup complete") + self._thread.start() + + def run(self): + self.httpd.serve_forever() + + def stop(self): + self.logger.info(f"Stopping grpc-web-proxy running on port {self.web_port}") + self.httpd.shutdown() + self._thread.join() + + +class Handler(BaseHTTPRequestHandler): + def __init__(self, *args, **kwargs): + self.logger = logging.getLogger("gltesting.grpcweb.Handler") + BaseHTTPRequestHandler.__init__(self, *args, **kwargs) + + def do_POST(self): + # We don't actually touch the payload, so we do not really + # care about the flags ourselves. The upstream sysmte will + # though. + flags = self.rfile.read(1) + + # We have the length from above already, but that includes the + # header. Ensure that the two values match up. + strlen = self.rfile.read(4) + (length,) = struct.unpack_from("!I", strlen) + l = int(self.headers.get("Content-Length")) + assert l == length + 5 + + # Now we can finally read the body, It is kept as is, so no + # need to decode it, and we can treat it as opaque blob. + body = self.rfile.read(length) + + # TODO extract the `glauthpubkey` and the `glauthsig`, then + # verify them. Fail the call if the verification fails, + # forward otherwise. + # This is just a test server, and we don't make use of the + # multiplexing support in `h2`, which simplifies this proxy + # quite a bit. The production server maintains a cache of + # connections and multiplexes correctly. + + import httpx + + url = f"http://localhost:{self.server.grpc_port}{self.path}" + self.logger.debug(f"Forwarding request to '{url}'") + headers = { + "te": "trailers", + "Content-Type": "application/grpc", + "grpc-accept-encoding": "idenity", + "user-agent": "My bloody hacked up script", + } + content = struct.pack("!cI", flags, length) + body + req = httpx.Request( + "POST", + url, + headers=headers, + content=content, + ) + client = httpx.Client(http1=False, http2=True) + + res = client.send(req) + res = client.send(req) + + canned = b"\n\rheklllo world" + l = struct.pack("!I", len(canned)) + self.wfile.write(b"HTTP/1.0 200 OK\n\n") + self.wfile.write(b"\x00") + self.wfile.write(l) + self.wfile.write(canned) + self.wfile.flush() From f12ceef481cadaef87a1a8252b1d5610f10ae9a9 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Tue, 29 Oct 2024 16:42:14 +0100 Subject: [PATCH 6/8] testing: Add a test for the grpc-web-proxy We create a standalone service and front it with the grpc-web-proxy. Since the proxy must not rely on the payload to make decisions we just implemented a simple test proto just for this case. --- libs/gl-testing/Makefile | 3 +- libs/gl-testing/gltesting/fixtures.py | 93 ++++++++++++++++------ libs/gl-testing/gltesting/test.proto | 17 ++++ libs/gl-testing/gltesting/test_grpc.py | 39 +++++++++ libs/gl-testing/gltesting/test_pb2.py | 30 +++++++ libs/gl-testing/gltesting/test_pb2.pyi | 17 ++++ libs/gl-testing/gltesting/test_pb2_grpc.py | 66 +++++++++++++++ libs/gl-testing/tests/test_grpc_web.py | 16 ++++ libs/gl-testing/tests/util/grpcserver.py | 36 +++++++++ 9 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 libs/gl-testing/gltesting/test.proto create mode 100644 libs/gl-testing/gltesting/test_grpc.py create mode 100644 libs/gl-testing/gltesting/test_pb2.py create mode 100644 libs/gl-testing/gltesting/test_pb2.pyi create mode 100644 libs/gl-testing/gltesting/test_pb2_grpc.py create mode 100644 libs/gl-testing/tests/test_grpc_web.py create mode 100644 libs/gl-testing/tests/util/grpcserver.py diff --git a/libs/gl-testing/Makefile b/libs/gl-testing/Makefile index 8c16e3f78..1f6b6d5d5 100644 --- a/libs/gl-testing/Makefile +++ b/libs/gl-testing/Makefile @@ -27,5 +27,6 @@ testgrpc: ${REPO_ROOT}/libs/proto/glclient/scheduler.proto mv ${TESTINGDIR}/gltesting/glclient/scheduler_grpc.py ${TESTINGDIR}/gltesting/scheduler_grpc.py rm -rf ${TESTINGDIR}/gltesting/glclient - +protoc: + uv run python3 -m grpc_tools.protoc -I. --python_out=. --pyi_out=. --purerpc_out=. --grpc_python_out=. gltesting/test.proto diff --git a/libs/gl-testing/gltesting/fixtures.py b/libs/gl-testing/gltesting/fixtures.py index 3347e6a9a..36ffce1d1 100644 --- a/libs/gl-testing/gltesting/fixtures.py +++ b/libs/gl-testing/gltesting/fixtures.py @@ -10,12 +10,23 @@ from pathlib import Path import logging import sys -from pyln.testing.fixtures import bitcoind, teardown_checks, node_cls, test_name, executor, db_provider, test_base_dir, jsonschemas +from pyln.testing.fixtures import ( + bitcoind, + teardown_checks, + node_cls, + test_name, + executor, + db_provider, + test_base_dir, + jsonschemas, +) from gltesting.network import node_factory from pyln.testing.fixtures import directory as str_directory from decimal import Decimal +from gltesting.grpcweb import GrpcWebProxy from clnvm import ClnVersionManager + logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) logging.getLogger("sh").setLevel(logging.ERROR) @@ -39,15 +50,15 @@ def paths(): # Should be a no-op after the first run vm.get_all() - latest = [v for v in versions if 'gl' in v.tag][-1] + latest = [v for v in versions if "gl" in v.tag][-1] - os.environ['PATH'] += f":{vm.get_target_path(latest) / 'usr' / 'local' / 'bin'}" + os.environ["PATH"] += f":{vm.get_target_path(latest) / 'usr' / 'local' / 'bin'}" - yield + yield @pytest.fixture() -def directory(str_directory : str) -> Path: +def directory(str_directory: str) -> Path: return Path(str_directory) / "gl-testing" @@ -105,31 +116,33 @@ def scheduler(scheduler_id, bitcoind): btcproxy = bitcoind.get_proxy() # Copied from pyln.testing.utils.NodeFactory.get_node - feerates=(15000, 11000, 7500, 3750) + feerates = (15000, 11000, 7500, 3750) def mock_estimatesmartfee(r): - params = r['params'] - if params == [2, 'CONSERVATIVE']: + params = r["params"] + if params == [2, "CONSERVATIVE"]: feerate = feerates[0] * 4 - elif params == [6, 'ECONOMICAL']: + elif params == [6, "ECONOMICAL"]: feerate = feerates[1] * 4 - elif params == [12, 'ECONOMICAL']: + elif params == [12, "ECONOMICAL"]: feerate = feerates[2] * 4 - elif params == [100, 'ECONOMICAL']: + elif params == [100, "ECONOMICAL"]: feerate = feerates[3] * 4 else: - warnings.warn("Don't have a feerate set for {}/{}.".format( - params[0], params[1], - )) + warnings.warn( + "Don't have a feerate set for {}/{}.".format( + params[0], + params[1], + ) + ) feerate = 42 return { - 'id': r['id'], - 'error': None, - 'result': { - 'feerate': Decimal(feerate) / 10**8 - }, + "id": r["id"], + "error": None, + "result": {"feerate": Decimal(feerate) / 10**8}, } - btcproxy.mock_rpc('estimatesmartfee', mock_estimatesmartfee) + + btcproxy.mock_rpc("estimatesmartfee", mock_estimatesmartfee) s = Scheduler(bitcoind=btcproxy, grpc_port=grpc_port, identity=scheduler_id) logger.debug(f"Scheduler is running at {s.grpc_addr}") @@ -149,9 +162,7 @@ def mock_estimatesmartfee(r): # here. if s.debugger.reports != []: - raise ValueError( - f"Some signer reported an error: {s.debugger.reports}" - ) + raise ValueError(f"Some signer reported an error: {s.debugger.reports}") @pytest.fixture() @@ -162,7 +173,7 @@ def clients(directory, scheduler, nobody_id): yield clients -@pytest.fixture(scope='session', autouse=True) +@pytest.fixture(scope="session", autouse=True) def cln_path() -> Path: """Ensure that the latest CLN version is in PATH. @@ -175,5 +186,37 @@ def cln_path() -> Path: """ manager = ClnVersionManager() v = manager.latest() - os.environ['PATH'] += f":{v.bin_path}" + os.environ["PATH"] += f":{v.bin_path}" return v.bin_path + + +@pytest.fixture() +def grpc_test_server(): + """Creates a hello world server over grpc to test the web proxy against. + + We explicitly do not use the real protos since the proxy must be + agnostic. + + """ + import anyio + from threading import Thread + import purerpc + from util.grpcserver import Server + + server = Server() + logging.getLogger("purerpc").setLevel(logging.DEBUG) + server.start() + + yield server + + server.stop() + + +@pytest.fixture() +def grpc_web_proxy(scheduler, grpc_test_server): + p = GrpcWebProxy(scheduler=scheduler, grpc_port=grpc_test_server.grpc_port) + p.start() + + yield p + + p.stop() diff --git a/libs/gl-testing/gltesting/test.proto b/libs/gl-testing/gltesting/test.proto new file mode 100644 index 000000000..c2f00bc86 --- /dev/null +++ b/libs/gl-testing/gltesting/test.proto @@ -0,0 +1,17 @@ +// Just a small grpc definition to test the grpcweb implementation. + +syntax = "proto3"; + +package gltesting; + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/libs/gl-testing/gltesting/test_grpc.py b/libs/gl-testing/gltesting/test_grpc.py new file mode 100644 index 000000000..e2b09c3df --- /dev/null +++ b/libs/gl-testing/gltesting/test_grpc.py @@ -0,0 +1,39 @@ +import purerpc +import gltesting.test_pb2 as gltesting_dot_test__pb2 + + +class GreeterServicer(purerpc.Servicer): + async def SayHello(self, input_message): + raise NotImplementedError() + + @property + def service(self) -> purerpc.Service: + service_obj = purerpc.Service( + "gltesting.Greeter" + ) + service_obj.add_method( + "SayHello", + self.SayHello, + purerpc.RPCSignature( + purerpc.Cardinality.UNARY_UNARY, + gltesting_dot_test__pb2.HelloRequest, + gltesting_dot_test__pb2.HelloReply, + ) + ) + return service_obj + + +class GreeterStub: + def __init__(self, channel): + self._client = purerpc.Client( + "gltesting.Greeter", + channel + ) + self.SayHello = self._client.get_method_stub( + "SayHello", + purerpc.RPCSignature( + purerpc.Cardinality.UNARY_UNARY, + gltesting_dot_test__pb2.HelloRequest, + gltesting_dot_test__pb2.HelloReply, + ) + ) \ No newline at end of file diff --git a/libs/gl-testing/gltesting/test_pb2.py b/libs/gl-testing/gltesting/test_pb2.py new file mode 100644 index 000000000..efa688191 --- /dev/null +++ b/libs/gl-testing/gltesting/test_pb2.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: gltesting/test.proto +# Protobuf Python Version: 4.25.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14gltesting/test.proto\x12\tgltesting\"\x1c\n\x0cHelloRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x1d\n\nHelloReply\x12\x0f\n\x07message\x18\x01 \x01(\t2E\n\x07Greeter\x12:\n\x08SayHello\x12\x17.gltesting.HelloRequest\x1a\x15.gltesting.HelloReplyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'gltesting.test_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + _globals['_HELLOREQUEST']._serialized_start=35 + _globals['_HELLOREQUEST']._serialized_end=63 + _globals['_HELLOREPLY']._serialized_start=65 + _globals['_HELLOREPLY']._serialized_end=94 + _globals['_GREETER']._serialized_start=96 + _globals['_GREETER']._serialized_end=165 +# @@protoc_insertion_point(module_scope) diff --git a/libs/gl-testing/gltesting/test_pb2.pyi b/libs/gl-testing/gltesting/test_pb2.pyi new file mode 100644 index 000000000..bf0bd395a --- /dev/null +++ b/libs/gl-testing/gltesting/test_pb2.pyi @@ -0,0 +1,17 @@ +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Optional as _Optional + +DESCRIPTOR: _descriptor.FileDescriptor + +class HelloRequest(_message.Message): + __slots__ = ("name",) + NAME_FIELD_NUMBER: _ClassVar[int] + name: str + def __init__(self, name: _Optional[str] = ...) -> None: ... + +class HelloReply(_message.Message): + __slots__ = ("message",) + MESSAGE_FIELD_NUMBER: _ClassVar[int] + message: str + def __init__(self, message: _Optional[str] = ...) -> None: ... diff --git a/libs/gl-testing/gltesting/test_pb2_grpc.py b/libs/gl-testing/gltesting/test_pb2_grpc.py new file mode 100644 index 000000000..395457cd5 --- /dev/null +++ b/libs/gl-testing/gltesting/test_pb2_grpc.py @@ -0,0 +1,66 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from gltesting import test_pb2 as gltesting_dot_test__pb2 + + +class GreeterStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.SayHello = channel.unary_unary( + '/gltesting.Greeter/SayHello', + request_serializer=gltesting_dot_test__pb2.HelloRequest.SerializeToString, + response_deserializer=gltesting_dot_test__pb2.HelloReply.FromString, + ) + + +class GreeterServicer(object): + """Missing associated documentation comment in .proto file.""" + + def SayHello(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_GreeterServicer_to_server(servicer, server): + rpc_method_handlers = { + 'SayHello': grpc.unary_unary_rpc_method_handler( + servicer.SayHello, + request_deserializer=gltesting_dot_test__pb2.HelloRequest.FromString, + response_serializer=gltesting_dot_test__pb2.HelloReply.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'gltesting.Greeter', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class Greeter(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def SayHello(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/gltesting.Greeter/SayHello', + gltesting_dot_test__pb2.HelloRequest.SerializeToString, + gltesting_dot_test__pb2.HelloReply.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/libs/gl-testing/tests/test_grpc_web.py b/libs/gl-testing/tests/test_grpc_web.py new file mode 100644 index 000000000..6feca29d5 --- /dev/null +++ b/libs/gl-testing/tests/test_grpc_web.py @@ -0,0 +1,16 @@ +# Tests that use a grpc-web client, without a client certificate, but +# payload signing for authentication. + +from gltesting.fixtures import * +from gltesting.test_pb2_grpc import GreeterStub +from gltesting.test_pb2 import HelloRequest +import sonora.client + +def test_start(grpc_web_proxy): + with sonora.client.insecure_web_channel( + f"http://localhost:{grpc_web_proxy.web_port}" + ) as channel: + stub = GreeterStub(channel) + req = HelloRequest(name="greenlight") + print(stub.SayHello(req)) + diff --git a/libs/gl-testing/tests/util/grpcserver.py b/libs/gl-testing/tests/util/grpcserver.py new file mode 100644 index 000000000..901ac6e05 --- /dev/null +++ b/libs/gl-testing/tests/util/grpcserver.py @@ -0,0 +1,36 @@ +# This is a simple grpc server serving the `gltesting/test.proto` +# protocol. It is used to test whether the grpc-web to grpc/h2 +# proxying is working. + +from gltesting.test_pb2 import HelloRequest, HelloReply +from gltesting.test_grpc import GreeterServicer +from ephemeral_port_reserve import reserve +import purerpc +from threading import Thread +import anyio + + + +class Server(GreeterServicer): + def __init__(self, *args, **kwargs): + GreeterServicer.__init__(self, *args, **kwargs) + self.grpc_port = reserve() + self.inner = purerpc.Server(self.grpc_port) + self.thread: Thread | None = None + self.inner.add_service(self.service) + + async def SayHello(self, message): + return HelloReply(message="Hello, " + message.name) + + def start(self): + def target(): + try: + anyio.run(self.inner.serve_async) + except Exception as e: + print("Error starting the grpc backend") + + self.thread = Thread(target=target, daemon=True) + self.thread.start() + + def stop(self): + self.inner.aclose From 9574d6335c1beb6ea9db30122725441524c096e0 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Wed, 6 Nov 2024 15:29:24 +0100 Subject: [PATCH 7/8] gltesting: Implement a grpc-web-proxy for node access Besides the grpc-web proxy functionality, the node grpc-web-proxy also needs the capability of locating and starting a node if it hasn't been started yet. It also needs to be configured with the correct client certificates. The `NodeHandler` class encapsulates that logic. We also test it by running a `GetInfo` call through the grpc-web-client, the node-grpc-web-proxy-, finally hitting the node, and back again. --- libs/gl-testing/gltesting/fixtures.py | 15 ++- libs/gl-testing/gltesting/grpcweb.py | 136 ++++++++++++++++++++--- libs/gl-testing/tests/test_grpc_web.py | 48 ++++++++ libs/gl-testing/tests/util/grpcserver.py | 2 +- 4 files changed, 182 insertions(+), 19 deletions(-) diff --git a/libs/gl-testing/gltesting/fixtures.py b/libs/gl-testing/gltesting/fixtures.py index 36ffce1d1..11ee3bbdf 100644 --- a/libs/gl-testing/gltesting/fixtures.py +++ b/libs/gl-testing/gltesting/fixtures.py @@ -23,7 +23,7 @@ from gltesting.network import node_factory from pyln.testing.fixtures import directory as str_directory from decimal import Decimal -from gltesting.grpcweb import GrpcWebProxy +from gltesting.grpcweb import GrpcWebProxy, NodeHandler from clnvm import ClnVersionManager @@ -220,3 +220,16 @@ def grpc_web_proxy(scheduler, grpc_test_server): yield p p.stop() + + +@pytest.fixture +def node_grpc_web_proxy(scheduler): + """A grpc-web proxy that knows how to talk to nodes. + """ + p = GrpcWebProxy(scheduler=scheduler, grpc_port=0) + p.handler_cls = NodeHandler + p.start() + + yield p + + p.stop() diff --git a/libs/gl-testing/gltesting/grpcweb.py b/libs/gl-testing/gltesting/grpcweb.py index 2a7588744..05a668dff 100644 --- a/libs/gl-testing/gltesting/grpcweb.py +++ b/libs/gl-testing/gltesting/grpcweb.py @@ -12,6 +12,10 @@ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler import logging import struct +import httpx +from dataclasses import dataclass +from typing import Dict +import ssl class GrpcWebProxy(object): @@ -26,6 +30,7 @@ def __init__(self, scheduler: Scheduler, grpc_port: int): self.logger.info( f"GrpcWebProxy configured to forward requests from web_port={self.web_port} to grpc_port={self.grpc_port}" ) + self.handler_cls = Handler def start(self): self._thread = Thread(target=self.run, daemon=True) @@ -33,8 +38,12 @@ def start(self): self.running = True server_address = ("127.0.0.1", self.web_port) - self.httpd = ThreadingHTTPServer(server_address, Handler) + self.httpd = ThreadingHTTPServer(server_address, self.handler_cls) self.httpd.grpc_port = self.grpc_port + + # Just a simple way to pass the scheduler to the handler + self.httpd.scheduler = self.scheduler + self.logger.debug(f"Server startup complete") self._thread.start() @@ -47,11 +56,49 @@ def stop(self): self._thread.join() +@dataclass +class Request: + body: bytes + headers: Dict[str, str] + flags: int + length: int + + +@dataclass +class Response: + body: bytes + + class Handler(BaseHTTPRequestHandler): def __init__(self, *args, **kwargs): self.logger = logging.getLogger("gltesting.grpcweb.Handler") BaseHTTPRequestHandler.__init__(self, *args, **kwargs) + def proxy(self, request) -> Response: + """Callback called with the request, implementing the proxying.""" + url = f"http://localhost:{self.server.grpc_port}{self.path}" + self.logger.debug(f"Forwarding request to '{url}'") + headers = { + "te": "trailers", + "Content-Type": "application/grpc", + "grpc-accept-encoding": "identity", + "user-agent": "gl-testing-grpc-web-proxy", + } + content = struct.pack("!cI", request.flags, request.length) + request.body + req = httpx.Request( + "POST", + url, + headers=headers, + content=content, + ) + client = httpx.Client(http1=False, http2=True) + res = client.send(req) + return Response(body=res.content) + + def auth(self, request: Request) -> bool: + """Authenticate the request. True means allow.""" + return True + def do_POST(self): # We don't actually touch the payload, so we do not really # care about the flags ourselves. The upstream sysmte will @@ -69,6 +116,25 @@ def do_POST(self): # need to decode it, and we can treat it as opaque blob. body = self.rfile.read(length) + req = Request(body=body, headers=self.headers, flags=flags, length=length) + if not self.auth(req): + self.wfile.write(b"HTTP/1.1 401 Unauthorized\r\n\r\n") + return + + response = self.proxy(req) + self.wfile.write(b"HTTP/1.0 200 OK\n\n") + self.wfile.write(response.body) + self.wfile.flush() + + +class NodeHandler(Handler): + """A handler that is aware of nodes, their auth and how they schedule.""" + + def __init__(self, *args, **kwargs): + self.logger = logging.getLogger("gltesting.grpcweb.NodeHandler") + BaseHTTPRequestHandler.__init__(self, *args, **kwargs) + + def auth(self, request: Request) -> bool: # TODO extract the `glauthpubkey` and the `glauthsig`, then # verify them. Fail the call if the verification fails, # forward otherwise. @@ -76,33 +142,69 @@ def do_POST(self): # multiplexing support in `h2`, which simplifies this proxy # quite a bit. The production server maintains a cache of # connections and multiplexes correctly. + pk = request.headers.get("glauthpubkey", None) + sig = request.headers.get("glauthsig", None) + ts = request.headers.get("glts", None) - import httpx + if not pk: + self.logger.warn(f"Missing public key header") + return False - url = f"http://localhost:{self.server.grpc_port}{self.path}" - self.logger.debug(f"Forwarding request to '{url}'") + if not sig: + self.logger.warn(f"Missing signature header") + return False + + if not ts: + self.logger.warn(f"Missing timestamp header") + return False + + # TODO Check the signature. + return True + + def proxy(self, request: Request): + # Fetch current location of the node + + pk = request.headers.get("glauthpubkey") + from base64 import b64decode + + pk = b64decode(pk) + + node = self.server.scheduler.get_node(pk) + self.logger.debug(f"Found node for node_id={pk.hex()}") + + # TODO Schedule node if not scheduled + + client_cert = node.identity.private_key + ca_path = node.identity.caroot_path + + # Load TLS client cert info client + ctx = httpx.create_ssl_context( + verify=ca_path, + http2=True, + cert=( + node.identity.cert_chain_path, + node.identity.private_key_path, + ), + ) + client = httpx.Client(http1=False, http2=True, verify=ctx) + + url = f"{node.process.grpc_uri}{self.path}" headers = { "te": "trailers", "Content-Type": "application/grpc", - "grpc-accept-encoding": "idenity", - "user-agent": "My bloody hacked up script", + "grpc-accept-encoding": "identity", + "user-agent": "gl-testing-grpc-web-proxy", } - content = struct.pack("!cI", flags, length) + body + content = struct.pack("!cI", request.flags, request.length) + request.body + + # Forward request req = httpx.Request( "POST", url, headers=headers, content=content, ) - client = httpx.Client(http1=False, http2=True) - - res = client.send(req) res = client.send(req) - canned = b"\n\rheklllo world" - l = struct.pack("!I", len(canned)) - self.wfile.write(b"HTTP/1.0 200 OK\n\n") - self.wfile.write(b"\x00") - self.wfile.write(l) - self.wfile.write(canned) - self.wfile.flush() + # Return response + return Response(body=res.content) diff --git a/libs/gl-testing/tests/test_grpc_web.py b/libs/gl-testing/tests/test_grpc_web.py index 6feca29d5..be37a3550 100644 --- a/libs/gl-testing/tests/test_grpc_web.py +++ b/libs/gl-testing/tests/test_grpc_web.py @@ -5,6 +5,32 @@ from gltesting.test_pb2_grpc import GreeterStub from gltesting.test_pb2 import HelloRequest import sonora.client +from pyln import grpc as clnpb +from base64 import b64encode +from time import time +import struct +from typing import Any + + +class GrpcWebClient: + """A simple grpc-web client that implements the calling convention.""" + + def __init__(self, node_grpc_web_proxy_uri, node_id: bytes): + self.node_id = node_id + self.node_grpc_web_proxy_uri = node_grpc_web_proxy_uri + self.channel = sonora.client.insecure_web_channel(node_grpc_web_proxy_uri) + self.stub = clnpb.NodeStub(self.channel) + + def call(self, method_name: str, req: Any) -> Any: + ts = struct.pack("!Q", int(time() * 1000)) + metadata = [ + ("glauthpubkey", b64encode(self.node_id).decode("ASCII")), + ("glauthsig", b64encode(b"\x00" * 64).decode("ASCII")), + ("glts", b64encode(ts).decode("ASCII")), + ] + func = self.stub.__dict__.get(method_name) + return func(req, metadata=metadata) + def test_start(grpc_web_proxy): with sonora.client.insecure_web_channel( @@ -14,3 +40,25 @@ def test_start(grpc_web_proxy): req = HelloRequest(name="greenlight") print(stub.SayHello(req)) + +def test_node_grpc_web(scheduler, node_grpc_web_proxy, clients): + """Ensure that the""" + # Start by creating a node + c = clients.new() + c.register(configure=True) + n = c.node() + info = n.get_info() + + # Now extract the TLS certificates, so we can sign the payload. + # TODO Configure the web client to sign its requests too + node_id = info.id + key_path = c.directory / "device-key.pem" + ca_path = c.directory / "ca.pem" + + proxy_uri = f"http://localhost:{node_grpc_web_proxy.web_port}" + web_client = GrpcWebClient(proxy_uri, node_id) + + # Issue a request to the node through the proxy. + req = clnpb.GetinfoRequest() + info = web_client.call("Getinfo", req) + print(info) diff --git a/libs/gl-testing/tests/util/grpcserver.py b/libs/gl-testing/tests/util/grpcserver.py index 901ac6e05..34fbc13fe 100644 --- a/libs/gl-testing/tests/util/grpcserver.py +++ b/libs/gl-testing/tests/util/grpcserver.py @@ -33,4 +33,4 @@ def target(): self.thread.start() def stop(self): - self.inner.aclose + pass From ed3179a622bc84e1129ef7a033beca112454b43f Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Thu, 21 Nov 2024 16:48:30 +0100 Subject: [PATCH 8/8] testing: Pin pyln-testing and pyln-client to 24.02 24.08 broke the plugin handling in CLN by no longer passing the description along. We'll need to unpin them again once we have switched to CLN 24.08 for testing. --- libs/gl-testing/pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/gl-testing/pyproject.toml b/libs/gl-testing/pyproject.toml index 1e97b5b65..b0df7cc16 100644 --- a/libs/gl-testing/pyproject.toml +++ b/libs/gl-testing/pyproject.toml @@ -12,7 +12,8 @@ dependencies = [ "grpcio>=1.66.0", "httpx>=0.27.2", "purerpc>=0.8.0", - "pyln-testing==24.8.1", + "pyln-client==24.2", + "pyln-testing==24.2", "pytest-timeout>=2.3.1", "pytest-xdist>=3.6.1", "rich>=13.9.3",