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
16 changes: 16 additions & 0 deletions .readthedocs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# .readthedocs.yml

version: 2

build:
os: ubuntu-24.04
tools:
python: "3.11"
jobs:
post_create_environment:
- pip install poetry==2.1.4
post_install:
- VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --only main,docs

sphinx:
configuration: docs/conf.py
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# nitlsconfig

Python API that reads nitlsconfig configurations through the `nitlsconfig` command line,
and builds gRPC client channels from them.
and builds NI gRPC Device client channels from them.

Installed and imported as `nitlsconfig`; developed at
[ni/nitlsconfig-python](https://github.com/ni/nitlsconfig-python).
Expand All @@ -12,19 +12,24 @@ Installed and imported as `nitlsconfig`; developed at

## Install

Reading NI-TLS configuration is pure Python and has no third-party dependencies:
Reading NI TLS configuration is pure Python and has no third-party dependencies:

- `pip install nitlsconfig`

The gRPC channel factory additionally needs grpcio, which is an optional extra:

- `pip install nitlsconfig[grpc]`

Neither install provides the `nitlsconfig` runtime itself. This package reads
configuration by invoking the `nitlsconfig` command line interface, which ships with NI
driver software products that support NI TLS. Install a driver that provides it before using
this package; without it, calls raise `ExecutableNotFoundError`.

## Creating a gRPC channel

`create_grpc_device_channel` reads the local NI-TLS client configuration for the NI
`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
`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
Expand All @@ -37,9 +42,19 @@ with nitlsconfig.create_grpc_device_channel("localhost", 31763) as channel:
...
```

The channel is mutually authenticated, one-way TLS, or insecure depending on how
the machine is configured; no code change is needed to move between them. The
channel is owned by the caller - NI driver APIs never close it.
NI driver software provides the NI TLS configuration. Using `create_grpc_device_channel`
opts into mTLS.

Before `create_grpc_device_channel` can succeed, use NI Hardware Manager to perform a
certificate exchange with the remote system. See
[Managing mTLS](https://www.ni.com/docs/en-US/bundle/hardwaremanager/page/mtls-manage.html)
for details.

Weakening the security posture to one-way TLS or to an insecure connection
requires explicitly changing that configuration in NI Hardware Manager. Either way, no
code change is needed.

The channel is owned by the caller - NI driver APIs never close it.

Retries are opt-in:

Expand Down Expand Up @@ -89,7 +104,6 @@ if servers:
print(server_info.certificate_key_location.scheme)
print(server_info.trusted_certificates_location.scheme)
print(server_info.trusted_certificates_contents)
print(server_info.certificate_key_contents)

# Enumerate trusted certificates
for cert in server_info.trusted_certificates:
Expand Down
7 changes: 4 additions & 3 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
nitlsconfig Python API
======================
.. mdinclude:: ../README.md

.. toctree::
:maxdepth: 3
:caption: API Reference

autoapi/index

Indices and tables
------------------
==================
* :ref:`modindex`
* :ref:`search`
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "nitlsconfig"
version = "1.0.0a3"
license = "MIT"
description = "Python API for reading nitlsconfig configurations and creating gRPC client channels from them"
description = "Python API for reading nitlsconfig configurations and creating NI gRPC Device client channels from them"
authors = [{name = "NI", email = "opensource@ni.com"}]
maintainers = [
{name = "Philip Thong", email = "philip.thong@emerson.com"},
Expand All @@ -26,7 +26,7 @@ classifiers = [
requires-python = ">=3.9"
dynamic = ["dependencies"]

# Reading NI-TLS configuration is pure Python. Only nitlsconfig.grpc_channel
# Reading NI TLS configuration is pure Python. Only nitlsconfig.grpc_channel
# needs grpcio, so the binary wheel is opt-in. pywin32 rides along with it
# because audit records are only produced when a channel is created, and the
# Windows Event Log logging handler cannot be written to without it.
Expand All @@ -35,6 +35,7 @@ grpc = ["grpcio>=1.49.0,<2.0", "pywin32>=306; sys_platform == 'win32'"]

[project.urls]
repository = "https://github.com/ni/nitlsconfig-python"
documentation = "https://nitlsconfig-python.readthedocs.io"

[project.scripts]
nitlsconfig-read = "nitlsconfig.cli:nitlsconfig_main"
Expand Down
14 changes: 4 additions & 10 deletions src/nitlsconfig/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
"""Python package to read settings from nitlsconfig and build connections from them.
"""Python package to read settings from nitlsconfig and build NI gRPC Device channels from them.

Reading configuration is pure Python and has no third-party dependencies. The
gRPC channel factory needs grpcio, which is an optional extra::

pip install nitlsconfig[grpc]

The gRPC names below are therefore resolved lazily: importing this package never
imports grpcio, so a caller that only reads NI-TLS configuration does not pay
for a binary dependency it will not use. Additional transports can be added the
same way without changing what a bare install requires.
The gRPC names below are resolved lazily: importing this package never imports
grpcio, so a caller that only reads NI TLS configuration does not pay for a
binary dependency it will not use. See the project README for install options.
"""

from importlib.metadata import version
Expand Down
4 changes: 2 additions & 2 deletions src/nitlsconfig/service.py → src/nitlsconfig/_service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""The NI-TLS services this package builds transports for.
"""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
# 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"
75 changes: 34 additions & 41 deletions src/nitlsconfig/audit.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,12 @@
"""Audit logging for NI-TLS client transports.
"""Audit logging for NI TLS client transports.

Writes to the platform audit log similarly to other NI gRPC clients:
Records the security posture of transports this package creates, and the outcome of a
driver's gRPC session initialize RPC, to the platform audit log: the Windows Event Log
on Windows, syslog on Linux.

Windows: Windows Event Log
Linux: syslog

We use the record pattern ``[<service>][<role>] <message>``.

A record is emitted only for something that attests to the security posture of a
connection that actually existed: transport posture and session connect results.

Configuration errors are not audited, because no channel is created and nothing
is transmitted; :class:`TlsConfigurationError` carries that detail to the caller
directly.

We also do not record *which* server we authenticated, for example its
certificate subject. gRPC's Python client API takes certificates as input only:
it offers no callback during the handshake and no way to read the server's
certificate afterward.

Transport posture is emitted by the channel factory.
The session connect record cannot be: it reports the outcome of a driver's
initialize RPC, and a gRPC channel connects lazily, so the API layer that
issues that RPC has to call ``audit_session_connect`` itself.

Auditing covers the NI gRPC Device Server only, so the service name is fixed
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
call the standard library logger directly. Untrusted *values* reaching a record
are bounded and escaped below, because those do cross a trust boundary.
Transport posture is recorded by the channel factory. The session connect outcome
cannot be, because a gRPC channel connects lazily, so the driver API layer that issues
the initialize RPC calls :func:`audit_session_connect` itself.
"""

from __future__ import annotations
Expand All @@ -41,13 +16,30 @@
import threading
from enum import Enum

from nitlsconfig._service import SERVICE_NAME
from nitlsconfig.channel_tag import get_channel_target
from nitlsconfig.service import SERVICE_NAME

# A record is emitted only for something that attests to the security posture of a
# connection that actually existed. Configuration errors are not audited, because no
# channel is created and nothing is transmitted; TlsConfigurationError carries that
# detail to the caller directly. We also do not record *which* server we authenticated,
# for example its certificate subject: gRPC's Python client API takes certificates as
# input only, offering no handshake callback and no way to read the server's certificate
# afterward.
#
# Auditing covers the NI gRPC Device Server only, so the service name is fixed
# 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 call the standard
# library logger directly. Untrusted *values* reaching a record are bounded and escaped
# below, because those do cross a trust boundary.

_ROLE = "Client"


class TransportSecurity(Enum):
class _TransportSecurity(Enum):
"""Security posture of a created transport."""

Unencrypted = "unencrypted"
Expand Down Expand Up @@ -114,7 +106,7 @@ def _make_logging_handler() -> logging.Handler:
# The platform logging handler failed, not `logging` itself; this diagnostic
# goes to the host application's ordinary logger, never to the audit channel.
logging.getLogger(__name__).warning(
"NI-TLS audit logging is unavailable on this system; audit events "
"NI TLS audit logging is unavailable on this system; audit events "
"will not be recorded.",
exc_info=True,
)
Expand All @@ -140,14 +132,15 @@ def _get_audit_logger() -> logging.Logger:
logger.propagate = False

handler = _make_logging_handler()
# Record pattern: [<service>][<role>] <message>
handler.setFormatter(logging.Formatter(f"[{SERVICE_NAME}][{_ROLE}] %(message)s"))
logger.addHandler(handler)
_logging_handler_attached = True

return logger


def audit_transport_posture(peer_host: str, security: TransportSecurity) -> None:
def _audit_transport_posture(peer_host: str, security: _TransportSecurity) -> None:
"""Record the security posture of a client transport.

Never raises: auditing must not disrupt transport creation.
Expand All @@ -158,16 +151,16 @@ def audit_transport_posture(peer_host: str, security: TransportSecurity) -> None
if peer_host:
message += f" to '{peer_host}'"

if security is TransportSecurity.Unencrypted:
if security is _TransportSecurity.Unencrypted:
message += " is unencrypted (TLS disabled)."
elif security is TransportSecurity.ServerAuthenticatedTls:
elif security is _TransportSecurity.ServerAuthenticatedTls:
message += " uses one-way TLS. Not presenting a client certificate."
else:
message += " uses mutual TLS. Presenting a client certificate."

logger = _get_audit_logger()
# Mutual TLS is the secure baseline; weaker postures are auditable warnings.
if security is TransportSecurity.MutualTls:
if security is _TransportSecurity.MutualTls:
logger.info(message)
else:
logger.warning(message)
Expand All @@ -183,9 +176,9 @@ def audit_session_connect(driver_name: str, channel: object, connected: bool) ->

Channels this package did not create are ignored, so drivers can call this
unconditionally. A caller who built their own channel never went through
NI-TLS, so there is no transport posture record to pair the outcome with and
NI TLS, so there is no transport posture record to pair the outcome with and
nothing to attest to; auditing it anyway would also register an Event Log
source on machines not using NI-TLS at all.
source on machines not using NI TLS at all.
"""
try:
target = get_channel_target(channel)
Expand Down
4 changes: 2 additions & 2 deletions src/nitlsconfig/channel_tag.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Marks gRPC channels this package created.
"""Marks NI gRPC Device channels this package created.

A caller holding only a channel cannot tell whether NI-TLS had any part in
A caller holding only a channel cannot tell whether NI TLS had any part in
building it, so the channel factory tags what it creates. Two features read the
tag: audit records name the address, and connection-error elaboration speaks only
for channels we built.
Expand Down
Loading