Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run_unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
...
Expand All @@ -44,15 +44,15 @@ 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()
)
```

`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.

Expand Down
6 changes: 2 additions & 4 deletions src/nitlsconfig/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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__ = [
Expand Down
15 changes: 8 additions & 7 deletions src/nitlsconfig/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,7 +41,8 @@
import threading
from enum import Enum

_SERVICE_NAME = "ni-grpc-device"
from nitlsconfig.service import SERVICE_NAME

_ROLE = "Client"


Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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}'"

Expand Down
49 changes: 18 additions & 31 deletions src/nitlsconfig/grpc_channel.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -328,20 +322,13 @@ 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)
else:
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
12 changes: 12 additions & 0 deletions src/nitlsconfig/service.py
Original file line number Diff line number Diff line change
@@ -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"
17 changes: 9 additions & 8 deletions tests/unit/test_grpc_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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"),
]


Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_grpc_channel_real.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)],
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_grpc_channel_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
)
Expand Down
20 changes: 12 additions & 8 deletions tests/unit/test_nitlsconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Loading