diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index 48fbed5..9f3f789 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -69,7 +69,7 @@ jobs: assert nitlsconfig.ClientConfig is not None try: - nitlsconfig.create_grpc_client_channel + nitlsconfig.create_grpc_device_channel except ImportError as exc: assert "pip install nitlsconfig[grpc]" in str(exc), exc else: diff --git a/README.md b/README.md index 89d4e0d..f3af3e9 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,16 @@ The gRPC channel factory additionally needs grpcio, which is an optional extra: ## Creating a gRPC channel -`create_grpc_client_channel` reads the local NI-TLS client configuration and returns a -`grpc.Channel` secured accordingly. The `server_address` hostname or address is used to -select matching target-specific NI-TLS settings. Pass the channel straight to any NI -gRPC Python API: +`create_grpc_device_channel` reads the local NI-TLS client configuration for the NI +gRPC Device Server and returns a `grpc.Channel` secured accordingly. The +`server_address` hostname or address is used to select matching target-specific NI-TLS +settings. Pass the channel straight to any NI gRPC Python API: ```python import nidcpower import nitlsconfig -with nitlsconfig.create_grpc_client_channel("localhost", 31763) as channel: +with nitlsconfig.create_grpc_device_channel("localhost", 31763) as channel: options = nidcpower.GrpcSessionOptions(channel, "") with nidcpower.Session("Dev1", grpc_options=options) as session: ... @@ -44,7 +44,7 @@ channel is owned by the caller - NI driver APIs never close it. Retries are opt-in: ```python -channel = nitlsconfig.create_grpc_client_channel( +channel = nitlsconfig.create_grpc_device_channel( "localhost", 31763, retry_policy=nitlsconfig.RetryPolicy() ) ``` @@ -52,7 +52,7 @@ channel = nitlsconfig.create_grpc_client_channel( `TlsConfigurationError` is raised when TLS is enabled but the configuration is unusable. It is always importable, since handling it does not require grpcio. -`create_grpc_client_channel` and `RetryPolicy` do require grpcio; accessing them +`create_grpc_device_channel` and `RetryPolicy` do require grpcio; accessing them without the `grpc` extra installed raises `ImportError` telling you which extra to install. diff --git a/src/nitlsconfig/__init__.py b/src/nitlsconfig/__init__.py index 93a5461..ceb96c4 100644 --- a/src/nitlsconfig/__init__.py +++ b/src/nitlsconfig/__init__.py @@ -41,9 +41,8 @@ if TYPE_CHECKING: # Imported eagerly for type checkers and editors, which do not run __getattr__. from nitlsconfig.grpc_channel import ( - DEFAULT_SERVICE_NAME, RetryPolicy, - create_grpc_client_channel, + create_grpc_device_channel, ) __version__ = version("nitlsconfig") @@ -52,9 +51,8 @@ # A plain list literal, because pyright only tracks __all__ through a small set # of literal forms; anything computed makes it give up on the export list. _GRPC_EXPORTS = [ - "DEFAULT_SERVICE_NAME", "RetryPolicy", - "create_grpc_client_channel", + "create_grpc_device_channel", ] __all__ = [ diff --git a/src/nitlsconfig/audit.py b/src/nitlsconfig/audit.py index 83494c5..8d20f3e 100644 --- a/src/nitlsconfig/audit.py +++ b/src/nitlsconfig/audit.py @@ -25,8 +25,8 @@ issues that RPC has to call ``audit_session_connect`` itself. Auditing covers the NI gRPC Device Server only, so the service name is fixed -here rather than accepted from callers. Should another service ever need audit -records, this module grows a service parameter again at that point. +package-wide rather than accepted from callers. Should another service ever need +audit records, this module grows a service parameter again at that point. Records report what this package observed, assuming the hosting process is not hostile. Nothing here can defend against code in the same process, which can @@ -41,7 +41,8 @@ import threading from enum import Enum -_SERVICE_NAME = "ni-grpc-device" +from nitlsconfig.service import SERVICE_NAME + _ROLE = "Client" @@ -119,7 +120,7 @@ def _make_logging_handler() -> logging.Handler: if sys.platform == "win32": from logging.handlers import NTEventLogHandler - return NTEventLogHandler(_SERVICE_NAME) + return NTEventLogHandler(SERVICE_NAME) from logging.handlers import SysLogHandler @@ -144,7 +145,7 @@ def _get_audit_logger() -> logging.Logger: """ global _logging_handler_attached - logger = logging.getLogger(f"nitlsconfig.audit.{_SERVICE_NAME}.{_ROLE}") + logger = logging.getLogger(f"nitlsconfig.audit.{SERVICE_NAME}.{_ROLE}") with _logging_handler_lock: if not _logging_handler_attached: @@ -154,7 +155,7 @@ def _get_audit_logger() -> logging.Logger: logger.propagate = False handler = _make_logging_handler() - handler.setFormatter(logging.Formatter(f"[{_SERVICE_NAME}][{_ROLE}] %(message)s")) + handler.setFormatter(logging.Formatter(f"[{SERVICE_NAME}][{_ROLE}] %(message)s")) logger.addHandler(handler) _logging_handler_attached = True @@ -168,7 +169,7 @@ def audit_transport_posture(peer_host: str, security: TransportSecurity) -> None """ try: peer_host = _audit_field(peer_host) - message = f"Client transport for service '{_SERVICE_NAME}'" + message = f"Client transport for service '{SERVICE_NAME}'" if peer_host: message += f" to '{peer_host}'" diff --git a/src/nitlsconfig/grpc_channel.py b/src/nitlsconfig/grpc_channel.py index 90d47b6..5ef2531 100644 --- a/src/nitlsconfig/grpc_channel.py +++ b/src/nitlsconfig/grpc_channel.py @@ -1,25 +1,26 @@ -"""Create gRPC client channels from NI-TLS (nitlsconfig) client configuration. +"""Create gRPC channels to the NI gRPC Device Server from NI-TLS (nitlsconfig) configuration. -Reads the local NI-TLS client configuration for a service and produces a -:class:`grpc.Channel` that is either secured with TLS/mTLS or, when TLS is not -configured, a plain insecure channel. +Reads the local NI-TLS client configuration for the NI gRPC Device Server and +produces a :class:`grpc.Channel` that is either secured with TLS/mTLS or, when +TLS is not configured, a plain insecure channel. The resulting channel is a normal ``grpc.Channel``. It can be handed directly to any NI gRPC Python API, for example:: - from nitlsconfig.grpc_channel import create_grpc_client_channel + from nitlsconfig.grpc_channel import create_grpc_device_channel - channel = create_grpc_client_channel("localhost", 31763) + channel = create_grpc_device_channel("localhost", 31763) options = nidcpower.GrpcSessionOptions(channel, "") with nidcpower.Session("Dev1", grpc_options=options) as session: ... Channel ownership stays with the caller, matching the NI Python driver APIs, which never close the channel themselves. ``grpc.Channel`` is already a context -manager, so ``with create_grpc_client_channel(...) as channel:`` works as expected. +manager, so ``with create_grpc_device_channel(...) as channel:`` works as expected. -The name carries the ``grpc`` prefix because this package also re-exports the -factory from its root, alongside any potential future non-gRPC transports. +The NI gRPC Device Server is the only service this factory builds channels for. +Other services can still be read through :class:`~nitlsconfig.cli.ClientConfig`; +a configurable service name can be added later without breaking this signature. ``server_mode`` Disabled selects a plain connection. Every other mode is treated exactly like ``TrustedCertificates``: the server certificate chain is verified @@ -55,19 +56,14 @@ LocationScheme, ) from nitlsconfig.errors import TlsConfigurationError +from nitlsconfig.service import SERVICE_NAME __all__ = [ - "DEFAULT_SERVICE_NAME", "RetryPolicy", "TlsConfigurationError", - "create_grpc_client_channel", + "create_grpc_device_channel", ] -# The nitlsconfig service name registered by the NI gRPC Device Server. It is -# the file stem of ni-grpc-device.client.caps.yml, which grpc-device installs -# into the nitlsconfig client.d directory. -DEFAULT_SERVICE_NAME = "ni-grpc-device" - # gRPC channel argument that carries a service config JSON document. _SERVICE_CONFIG_ARG = "grpc.service_config" @@ -277,17 +273,16 @@ def _make_client_credentials(settings: _ClientTlsSettings) -> grpc.ChannelCreden ) -def create_grpc_client_channel( +def create_grpc_device_channel( server_address: str, server_port: int, - service_name: str = DEFAULT_SERVICE_NAME, options: ChannelOptions = (), retry_policy: Optional[RetryPolicy] = None, ) -> grpc.Channel: """Create a gRPC channel to ``server_address:server_port`` using NI-TLS configuration. - Reads the NI-TLS client configuration for ``service_name`` and builds a - channel that verifies the server certificate and, when the configuration + Reads the NI-TLS client configuration for the NI gRPC Device Server and builds + a channel that verifies the server certificate and, when the configuration calls for mutual TLS, also presents the client certificate. Falls back to an insecure channel when the client's ``server_mode`` is Disabled, which is the default until the machine is configured. @@ -297,7 +292,6 @@ def create_grpc_client_channel( to resolve NI-TLS settings specific to this target. IPv6 literals may be passed with or without brackets. server_port: Port of the NI gRPC Device Server. - service_name: nitlsconfig service name to read configuration from. options: gRPC channel arguments, as ``(key, value)`` pairs. Use this to tune the channel, for example to raise message size limits or to set ``grpc.ssl_target_name_override`` when the server certificate's @@ -319,7 +313,7 @@ def create_grpc_client_channel( target = _format_target(server_address, server_port) channel_options = _apply_retry_policy(options, retry_policy) - settings = _load_client_tls_settings(ClientConfig(service_name, server_address)) + settings = _load_client_tls_settings(ClientConfig(SERVICE_NAME, server_address)) if settings is None: security = TransportSecurity.Unencrypted @@ -328,13 +322,7 @@ def create_grpc_client_channel( else: security = TransportSecurity.ServerAuthenticatedTls - # Auditing covers the NI gRPC Device Server only, so a channel for any other - # service goes unaudited rather than being recorded under the wrong service. - # The tag gates the session record the same way this gates the posture record. - audited = service_name == DEFAULT_SERVICE_NAME - - if audited: - audit_transport_posture(server_address, security) + audit_transport_posture(server_address, security) if settings is None: channel = grpc.insecure_channel(target, options=channel_options) @@ -342,6 +330,5 @@ def create_grpc_client_channel( channel = grpc.secure_channel( target, _make_client_credentials(settings), options=channel_options ) - if audited: - tag_channel_target(channel, target) + tag_channel_target(channel, target) return channel diff --git a/src/nitlsconfig/service.py b/src/nitlsconfig/service.py new file mode 100644 index 0000000..649a438 --- /dev/null +++ b/src/nitlsconfig/service.py @@ -0,0 +1,12 @@ +"""The NI-TLS services this package builds transports for. + +Only the NI gRPC Device Server is supported today. Anything else can still be +read through :class:`~nitlsconfig.cli.ClientConfig`, but has no channel factory. +""" + +from __future__ import annotations + +# The NI-TLS registered service name for the NI gRPC Device Server: the file stem of +# ni-grpc-device.client.caps.yml, the Event Log source, and the record tag are all this +# one name, so records can be tied back to the configuration they describe. +SERVICE_NAME = "ni-grpc-device" diff --git a/tests/unit/test_grpc_channel.py b/tests/unit/test_grpc_channel.py index 2f51798..66cb91a 100644 --- a/tests/unit/test_grpc_channel.py +++ b/tests/unit/test_grpc_channel.py @@ -72,7 +72,7 @@ def create_channel( "ClientConfig", lambda service_name, server_address: config, ) - channel = grpc_channel.create_grpc_client_channel("localhost", 31763, **kwargs) + channel = grpc_channel.create_grpc_device_channel("localhost", 31763, **kwargs) assert isinstance(channel, RecordedChannel) return channel @@ -110,7 +110,7 @@ def test_ipv6_addresses_are_bracketed( lambda service_name, server_address: FakeClientConfig(), ) - channel = grpc_channel.create_grpc_client_channel(server_address, 31763) + channel = grpc_channel.create_grpc_device_channel(server_address, 31763) assert isinstance(channel, RecordedChannel) assert channel.target == expected_target @@ -316,12 +316,13 @@ def test_invalid_configuration_raises( ) with pytest.raises(grpc_channel.TlsConfigurationError): - grpc_channel.create_grpc_client_channel("localhost", 31763) + grpc_channel.create_grpc_device_channel("localhost", 31763) -def test_service_name_and_address_are_forwarded( +def test_configuration_is_read_for_the_grpc_device_service( monkeypatch: pytest.MonkeyPatch, ) -> None: + """The service name is fixed; only the dialed address varies the lookup.""" requested: list[tuple[str, str]] = [] def record_client_config(service_name: str, server_address: str) -> FakeClientConfig: @@ -330,12 +331,12 @@ def record_client_config(service_name: str, server_address: str) -> FakeClientCo monkeypatch.setattr(grpc_channel, "ClientConfig", record_client_config) - grpc_channel.create_grpc_client_channel("localhost", 31763) - grpc_channel.create_grpc_client_channel("localhost", 31763, service_name="other-service") + grpc_channel.create_grpc_device_channel("localhost", 31763) + grpc_channel.create_grpc_device_channel("remote-host", 31763) assert requested == [ - (grpc_channel.DEFAULT_SERVICE_NAME, "localhost"), - ("other-service", "localhost"), + ("ni-grpc-device", "localhost"), + ("ni-grpc-device", "remote-host"), ] diff --git a/tests/unit/test_grpc_channel_real.py b/tests/unit/test_grpc_channel_real.py index a4afc74..ad5f94a 100644 --- a/tests/unit/test_grpc_channel_real.py +++ b/tests/unit/test_grpc_channel_real.py @@ -77,7 +77,7 @@ def test_returns_a_usable_grpc_channel( lambda service_name, server_address: config, ) - with grpc_channel.create_grpc_client_channel("localhost", 31763) as channel: + with grpc_channel.create_grpc_device_channel("localhost", 31763) as channel: # nimi-python and nidaqmx-python pass this straight to GrpcSessionOptions, # which requires a grpc.Channel and calls these factories on it. assert isinstance(channel, grpc.Channel) @@ -109,7 +109,7 @@ def test_channel_options_are_accepted_by_grpc( ) capfd.readouterr() - with grpc_channel.create_grpc_client_channel( + with grpc_channel.create_grpc_device_channel( "localhost", 31763, options=[("grpc.max_receive_message_length", 4 * 1024 * 1024)], diff --git a/tests/unit/test_grpc_channel_tls.py b/tests/unit/test_grpc_channel_tls.py index fe4e0ae..e3574b1 100644 --- a/tests/unit/test_grpc_channel_tls.py +++ b/tests/unit/test_grpc_channel_tls.py @@ -132,7 +132,7 @@ def test_mutual_tls_handshake_succeeds( lambda service_name, server_address: config, ) - with grpc_channel.create_grpc_client_channel("localhost", trusted_server) as channel: + with grpc_channel.create_grpc_device_channel("localhost", trusted_server) as channel: say = channel.unary_unary( _METHOD, request_serializer=_identity, response_deserializer=_identity ) @@ -157,7 +157,7 @@ def test_server_from_untrusted_ca_is_rejected( lambda service_name, server_address: config, ) - with grpc_channel.create_grpc_client_channel("localhost", untrusted_server) as channel: + with grpc_channel.create_grpc_device_channel("localhost", untrusted_server) as channel: say = channel.unary_unary( _METHOD, request_serializer=_identity, response_deserializer=_identity ) diff --git a/tests/unit/test_nitlsconfig.py b/tests/unit/test_nitlsconfig.py index 7368e40..1e33646 100644 --- a/tests/unit/test_nitlsconfig.py +++ b/tests/unit/test_nitlsconfig.py @@ -262,11 +262,12 @@ def test_real_config_drives_channel_credentials(monkeypatch: pytest.MonkeyPatch) Every other channel test substitutes a fake config, so this is the only one that exercises the seam: enum parsing, CertificateLocation.from_string and - the contents lookups all feed create_grpc_client_channel here. A mis-parsed + the contents lookups all feed create_grpc_device_channel here. A mis-parsed server_mode would silently produce an insecure channel and be invisible elsewhere in the suite. - ni-mqtt is configured for mutual TLS with SystemDefault trust anchors. + ni-mqtt is configured for mutual TLS with SystemDefault trust anchors, so the + fixture stands in for the service name the factory hardcodes. """ captured: dict[str, object] = {} real_ssl_channel_credentials = grpc.ssl_channel_credentials @@ -276,10 +277,9 @@ def spy(**kwargs: Optional[bytes]) -> grpc.ChannelCredentials: return real_ssl_channel_credentials(**kwargs) monkeypatch.setattr(grpc, "ssl_channel_credentials", spy) + monkeypatch.setattr(grpc_channel, "SERVICE_NAME", "ni-mqtt") - with grpc_channel.create_grpc_client_channel( - "localhost", 31763, service_name="ni-mqtt" - ) as channel: + with grpc_channel.create_grpc_device_channel("localhost", 31763) as channel: assert isinstance(channel, grpc.Channel) # SystemDefault trust anchors must arrive as None, not empty bytes. @@ -288,7 +288,11 @@ def spy(**kwargs: Optional[bytes]) -> grpc.ChannelCredentials: assert b"PRIVATE KEY" in cast(bytes, captured["private_key"]) -def test_real_config_with_unknown_server_mode_is_rejected() -> None: - """ni-test has no server_mode, which must be rejected rather than silently ignored.""" +def test_real_config_with_unknown_server_mode_is_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ni-test has no server_mode, so TLS stays on and unprovisioned material is rejected.""" + monkeypatch.setattr(grpc_channel, "SERVICE_NAME", "ni-test") + with pytest.raises(grpc_channel.TlsConfigurationError): - grpc_channel.create_grpc_client_channel("localhost", 31763, service_name="ni-test") + grpc_channel.create_grpc_device_channel("localhost", 31763)