nitlsconfig 1.0.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nitlsconfig/__init__.py +97 -0
- nitlsconfig/cli.py +653 -0
- nitlsconfig/grpc_channel.py +374 -0
- nitlsconfig-1.0.0a1.dist-info/LICENSE +20 -0
- nitlsconfig-1.0.0a1.dist-info/METADATA +127 -0
- nitlsconfig-1.0.0a1.dist-info/RECORD +8 -0
- nitlsconfig-1.0.0a1.dist-info/WHEEL +4 -0
- nitlsconfig-1.0.0a1.dist-info/entry_points.txt +3 -0
nitlsconfig/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Python package to read settings from nitlsconfig and build connections from them.
|
|
2
|
+
|
|
3
|
+
Reading configuration is pure Python and has no third-party dependencies. The
|
|
4
|
+
gRPC channel factory needs grpcio, which is an optional extra::
|
|
5
|
+
|
|
6
|
+
pip install nitlsconfig[grpc]
|
|
7
|
+
|
|
8
|
+
The gRPC names below are therefore resolved lazily: importing this package never
|
|
9
|
+
imports grpcio, so a caller that only reads NI-TLS configuration does not pay
|
|
10
|
+
for a binary dependency it will not use. Additional transports can be added the
|
|
11
|
+
same way without changing what a bare install requires.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import version
|
|
15
|
+
from importlib.util import find_spec
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from nitlsconfig.cli import (
|
|
19
|
+
CertificateLocation,
|
|
20
|
+
ClientCertMode,
|
|
21
|
+
ClientConfig,
|
|
22
|
+
ClientServerMode,
|
|
23
|
+
CommandFailedError,
|
|
24
|
+
ExecutableNotFoundError,
|
|
25
|
+
InvalidOutputError,
|
|
26
|
+
LocationScheme,
|
|
27
|
+
NitlsconfigCliError,
|
|
28
|
+
ServerCertMode,
|
|
29
|
+
ServerClientMode,
|
|
30
|
+
ServerConfig,
|
|
31
|
+
TrustedCertificateData,
|
|
32
|
+
KnownServerData,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
# Imported eagerly for type checkers and editors, which do not run __getattr__.
|
|
37
|
+
from nitlsconfig.grpc_channel import (
|
|
38
|
+
DEFAULT_SERVICE_NAME,
|
|
39
|
+
RetryPolicy,
|
|
40
|
+
TlsConfigurationError,
|
|
41
|
+
create_grpc_client_channel,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = version("nitlsconfig")
|
|
45
|
+
|
|
46
|
+
# Names re-exported from nitlsconfig.grpc_channel, which requires grpcio.
|
|
47
|
+
# A plain list literal, because pyright only tracks __all__ through a small set
|
|
48
|
+
# of literal forms; anything computed makes it give up on the export list.
|
|
49
|
+
_GRPC_EXPORTS = [
|
|
50
|
+
"DEFAULT_SERVICE_NAME",
|
|
51
|
+
"RetryPolicy",
|
|
52
|
+
"TlsConfigurationError",
|
|
53
|
+
"create_grpc_client_channel",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"__version__",
|
|
58
|
+
"CertificateLocation",
|
|
59
|
+
"ClientCertMode",
|
|
60
|
+
"ClientConfig",
|
|
61
|
+
"ClientServerMode",
|
|
62
|
+
"LocationScheme",
|
|
63
|
+
"ServerCertMode",
|
|
64
|
+
"ServerClientMode",
|
|
65
|
+
"ServerConfig",
|
|
66
|
+
"NitlsconfigCliError",
|
|
67
|
+
"ExecutableNotFoundError",
|
|
68
|
+
"CommandFailedError",
|
|
69
|
+
"InvalidOutputError",
|
|
70
|
+
"TrustedCertificateData",
|
|
71
|
+
"KnownServerData",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# The gRPC names are public API, but only on an install that can supply them.
|
|
75
|
+
# Listing them unconditionally would make `from nitlsconfig import *` raise
|
|
76
|
+
# ImportError without the grpc extra, since star-import resolves every name in
|
|
77
|
+
# __all__. find_spec only locates grpcio; it does not import it, so the lazy
|
|
78
|
+
# __getattr__ below still decides when grpcio is actually loaded.
|
|
79
|
+
if find_spec("grpc") is not None:
|
|
80
|
+
# pyright only tracks __all__ through inline literals, so it cannot follow
|
|
81
|
+
# this and warns that the export list may be incomplete. The TYPE_CHECKING
|
|
82
|
+
# block above already declares these names for static consumers.
|
|
83
|
+
__all__ += _GRPC_EXPORTS # pyright: ignore[reportUnsupportedDunderAll]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def __getattr__(name: str) -> Any:
|
|
87
|
+
"""Resolve gRPC exports on first use, so importing this package does not need grpcio."""
|
|
88
|
+
if name in _GRPC_EXPORTS:
|
|
89
|
+
try:
|
|
90
|
+
from nitlsconfig import grpc_channel
|
|
91
|
+
except ImportError as exc: # pragma: no cover - requires an install without the extra
|
|
92
|
+
raise ImportError(
|
|
93
|
+
f"nitlsconfig.{name} requires grpcio, which is not installed. "
|
|
94
|
+
"Install it with: pip install nitlsconfig[grpc]"
|
|
95
|
+
) from exc
|
|
96
|
+
return getattr(grpc_channel, name)
|
|
97
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
nitlsconfig/cli.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""Read-only configuration from nitlsconfig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import platform
|
|
10
|
+
import subprocess # nosec B404 - required to invoke the trusted nitlsconfig CLI
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Optional, Tuple, TypeVar
|
|
15
|
+
|
|
16
|
+
ALLOWED_SCOPES: Tuple[str, ...] = ("client", "server")
|
|
17
|
+
|
|
18
|
+
NITLSCONFIG_CLI_ENV_VAR = "NITLSCONFIG_CLI"
|
|
19
|
+
|
|
20
|
+
# Expected JSON root keys from nitlsconfig output.
|
|
21
|
+
ROLE_TO_JSON_ROOT_KEY = {
|
|
22
|
+
"client": "client",
|
|
23
|
+
"server": "server",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Command templates are based on nitlsconfigcli README and fixture scripts.
|
|
28
|
+
LIST_COMMAND_TEMPLATE: Tuple[str, ...] = ("{role}", "list")
|
|
29
|
+
|
|
30
|
+
CLIENT_BATCH_READ_COMMAND_TEMPLATE: Tuple[str, ...] = (
|
|
31
|
+
"--output-format=json",
|
|
32
|
+
"client",
|
|
33
|
+
"batch-read",
|
|
34
|
+
"conf",
|
|
35
|
+
"version",
|
|
36
|
+
"raw",
|
|
37
|
+
"display_name_en",
|
|
38
|
+
"conf",
|
|
39
|
+
"certificate_mode",
|
|
40
|
+
"conf",
|
|
41
|
+
"certificate_chain_location",
|
|
42
|
+
"conf",
|
|
43
|
+
"certificate_chain_contents",
|
|
44
|
+
"conf",
|
|
45
|
+
"certificate_key_location",
|
|
46
|
+
"conf",
|
|
47
|
+
"certificate_key_contents",
|
|
48
|
+
"conf",
|
|
49
|
+
"server_mode",
|
|
50
|
+
"conf",
|
|
51
|
+
"server_name",
|
|
52
|
+
"conf",
|
|
53
|
+
"trusted_certificates_location",
|
|
54
|
+
"conf",
|
|
55
|
+
"trusted_certificates_contents",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
SERVER_BATCH_READ_COMMAND_TEMPLATE: Tuple[str, ...] = (
|
|
59
|
+
"--output-format=json",
|
|
60
|
+
"server",
|
|
61
|
+
"batch-read",
|
|
62
|
+
"conf",
|
|
63
|
+
"version",
|
|
64
|
+
"raw",
|
|
65
|
+
"display_name_en",
|
|
66
|
+
"conf",
|
|
67
|
+
"certificate_mode",
|
|
68
|
+
"conf",
|
|
69
|
+
"certificate_chain_location",
|
|
70
|
+
"conf",
|
|
71
|
+
"certificate_chain_contents",
|
|
72
|
+
"conf",
|
|
73
|
+
"certificate_key_location",
|
|
74
|
+
"conf",
|
|
75
|
+
"certificate_key_contents",
|
|
76
|
+
"conf",
|
|
77
|
+
"client_mode",
|
|
78
|
+
"conf",
|
|
79
|
+
"trusted_certificates_location",
|
|
80
|
+
"conf",
|
|
81
|
+
"trusted_certificates_contents",
|
|
82
|
+
"raw",
|
|
83
|
+
"trusted_certificate_location",
|
|
84
|
+
"raw",
|
|
85
|
+
"trusted_certificate_contents",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NitlsconfigCliError(RuntimeError):
|
|
90
|
+
"""Base error for nitlsconfig command invocation failures."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ExecutableNotFoundError(NitlsconfigCliError):
|
|
94
|
+
"""Raised when a usable nitlsconfig executable cannot be found."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CommandFailedError(NitlsconfigCliError):
|
|
98
|
+
"""Raised when nitlsconfig exits with a non-zero return code."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class InvalidOutputError(NitlsconfigCliError):
|
|
102
|
+
"""Raised when command output cannot be parsed as expected."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ServerCertMode(str, Enum):
|
|
106
|
+
"Server TLS certificate mode."
|
|
107
|
+
|
|
108
|
+
Disabled = "Disabled"
|
|
109
|
+
Unmanaged = "Unmanaged"
|
|
110
|
+
ManagedSelfSigned = "ManagedSelfSigned"
|
|
111
|
+
Unknown = "Unknown"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ServerClientMode(str, Enum):
|
|
115
|
+
"Server TLS client mode."
|
|
116
|
+
|
|
117
|
+
Disabled = "Disabled"
|
|
118
|
+
Unmanaged = "Unmanaged"
|
|
119
|
+
ManagedSelfSigned = "ManagedSelfSigned"
|
|
120
|
+
Unknown = "Unknown"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ClientCertMode(str, Enum):
|
|
124
|
+
"Client TLS certificate mode."
|
|
125
|
+
|
|
126
|
+
Disabled = "Disabled"
|
|
127
|
+
Unmanaged = "Unmanaged"
|
|
128
|
+
Managed = "Managed"
|
|
129
|
+
Unknown = "Unknown"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ClientServerMode(str, Enum):
|
|
133
|
+
"Client TLS server mode."
|
|
134
|
+
|
|
135
|
+
Disabled = "Disabled"
|
|
136
|
+
TrustedCertificates = "TrustedCertificates"
|
|
137
|
+
SkipHostnameValidation = "SkipHostnameValidation"
|
|
138
|
+
TrustAlways = "TrustAlways"
|
|
139
|
+
Unknown = "Unknown"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class LocationScheme(str, Enum):
|
|
143
|
+
"Location scheme for certificate and key locations."
|
|
144
|
+
|
|
145
|
+
File = "File"
|
|
146
|
+
Directory = "Directory"
|
|
147
|
+
SystemDefault = "SystemDefault"
|
|
148
|
+
Unknown = "Unknown"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class CertificateLocation:
|
|
153
|
+
"Typed wrapper for certificate and key locations."
|
|
154
|
+
|
|
155
|
+
scheme: LocationScheme
|
|
156
|
+
path: str = ""
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_string(cls, value: str) -> "CertificateLocation":
|
|
160
|
+
"Parse a location string into a typed CertificateLocation object."
|
|
161
|
+
if value.startswith("File://"):
|
|
162
|
+
return cls(LocationScheme.File, value[len("File://") :])
|
|
163
|
+
if value.startswith("Directory://"):
|
|
164
|
+
return cls(LocationScheme.Directory, value[len("Directory://") :])
|
|
165
|
+
if value == "SystemDefault":
|
|
166
|
+
return cls(LocationScheme.SystemDefault, "")
|
|
167
|
+
return cls(LocationScheme.Unknown, value)
|
|
168
|
+
|
|
169
|
+
def to_string(self) -> str:
|
|
170
|
+
"Output string representation."
|
|
171
|
+
if self.scheme == LocationScheme.File:
|
|
172
|
+
return f"File://{self.path}"
|
|
173
|
+
if self.scheme == LocationScheme.Directory:
|
|
174
|
+
return f"Directory://{self.path}"
|
|
175
|
+
if self.scheme == LocationScheme.SystemDefault:
|
|
176
|
+
return "SystemDefault"
|
|
177
|
+
return self.path
|
|
178
|
+
|
|
179
|
+
def __str__(self) -> str:
|
|
180
|
+
"Auto string conversion."
|
|
181
|
+
return self.to_string()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
EnumT = TypeVar("EnumT", bound=Enum)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_enum(value: str, enum_cls: type[EnumT], unknown_member: EnumT) -> EnumT:
|
|
188
|
+
"Parse a string value into an Enum member, returning unknown_member if not found."
|
|
189
|
+
try:
|
|
190
|
+
return enum_cls(value)
|
|
191
|
+
except ValueError:
|
|
192
|
+
return unknown_member
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class KnownServerData:
|
|
197
|
+
"""Typed wrapper for known server objects from CLI JSON."""
|
|
198
|
+
|
|
199
|
+
raw: dict[str, Any]
|
|
200
|
+
display_name: str = field()
|
|
201
|
+
certificate_mode: ClientCertMode = field()
|
|
202
|
+
certificate_chain_location: CertificateLocation = field()
|
|
203
|
+
certificate_chain_contents: str = field()
|
|
204
|
+
certificate_key_location: CertificateLocation = field()
|
|
205
|
+
certificate_key_contents: str = field()
|
|
206
|
+
server_mode: ClientServerMode = field()
|
|
207
|
+
server_name: str = field()
|
|
208
|
+
trusted_certificates_location: CertificateLocation = field()
|
|
209
|
+
trusted_certificates_contents: str = field()
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def from_json_obj(cls, obj: dict[str, Any]) -> "KnownServerData":
|
|
213
|
+
"Parse a known server object from CLI JSON into KnownServerData."
|
|
214
|
+
return cls(
|
|
215
|
+
raw=obj,
|
|
216
|
+
display_name=obj.get("display_name_en", ""),
|
|
217
|
+
certificate_mode=_parse_enum(
|
|
218
|
+
obj.get("certificate_mode", ""),
|
|
219
|
+
ClientCertMode,
|
|
220
|
+
ClientCertMode.Unknown,
|
|
221
|
+
),
|
|
222
|
+
certificate_chain_location=CertificateLocation.from_string(
|
|
223
|
+
obj.get("certificate_chain_location", "")
|
|
224
|
+
),
|
|
225
|
+
certificate_chain_contents=obj.get("certificate_chain_contents", ""),
|
|
226
|
+
certificate_key_location=CertificateLocation.from_string(
|
|
227
|
+
obj.get("certificate_key_location", "")
|
|
228
|
+
),
|
|
229
|
+
certificate_key_contents=obj.get("certificate_key_contents", ""),
|
|
230
|
+
server_mode=_parse_enum(
|
|
231
|
+
obj.get("server_mode", ""),
|
|
232
|
+
ClientServerMode,
|
|
233
|
+
ClientServerMode.Unknown,
|
|
234
|
+
),
|
|
235
|
+
server_name=obj.get("server_name", ""),
|
|
236
|
+
trusted_certificates_location=CertificateLocation.from_string(
|
|
237
|
+
obj.get("trusted_certificates_location", "")
|
|
238
|
+
),
|
|
239
|
+
trusted_certificates_contents=obj.get("trusted_certificates_contents", ""),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class TrustedCertificateData:
|
|
245
|
+
"""Typed wrapper for trusted certificate objects from CLI JSON."""
|
|
246
|
+
|
|
247
|
+
raw: dict[str, Any]
|
|
248
|
+
display_name: str = field()
|
|
249
|
+
trusted_certificate_location: CertificateLocation = field()
|
|
250
|
+
trusted_certificate_contents: str = field()
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def from_json_obj(cls, obj: dict[str, Any]) -> "TrustedCertificateData":
|
|
254
|
+
"Parse a trusted certificate object from CLI JSON into TrustedCertificateData."
|
|
255
|
+
return cls(
|
|
256
|
+
raw=obj,
|
|
257
|
+
display_name=obj.get("display_name_en", ""),
|
|
258
|
+
trusted_certificate_location=CertificateLocation.from_string(
|
|
259
|
+
obj.get("trusted_certificate_location", "")
|
|
260
|
+
),
|
|
261
|
+
trusted_certificate_contents=obj.get("trusted_certificate_contents", ""),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True)
|
|
266
|
+
class ServiceData:
|
|
267
|
+
"""Typed wrapper for service objects from CLI JSON."""
|
|
268
|
+
|
|
269
|
+
raw: dict[str, Any]
|
|
270
|
+
known_servers: list[KnownServerData] = field(default_factory=list)
|
|
271
|
+
trusted_certificates: list[TrustedCertificateData] = field(default_factory=list)
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def from_json_obj(cls, obj: dict[str, Any]) -> "ServiceData":
|
|
275
|
+
"Parse a service object from CLI JSON into a typed ServiceData object."
|
|
276
|
+
known_servers_raw = obj.get("known_servers", [])
|
|
277
|
+
known_servers: list[KnownServerData] = []
|
|
278
|
+
if isinstance(known_servers_raw, list):
|
|
279
|
+
for item in known_servers_raw:
|
|
280
|
+
if isinstance(item, dict):
|
|
281
|
+
known_servers.append(KnownServerData.from_json_obj(item))
|
|
282
|
+
|
|
283
|
+
trusted_certificates_raw = obj.get("trusted_certificates", [])
|
|
284
|
+
trusted_certificates: list[TrustedCertificateData] = []
|
|
285
|
+
if isinstance(trusted_certificates_raw, list):
|
|
286
|
+
for item in trusted_certificates_raw:
|
|
287
|
+
if isinstance(item, dict):
|
|
288
|
+
trusted_certificates.append(TrustedCertificateData.from_json_obj(item))
|
|
289
|
+
|
|
290
|
+
return cls(raw=obj, known_servers=known_servers, trusted_certificates=trusted_certificates)
|
|
291
|
+
|
|
292
|
+
def value(self, key: str, default: str = "") -> str:
|
|
293
|
+
"Fetch value from dict with default, ensuring it is a string."
|
|
294
|
+
value = self.raw.get(key, default)
|
|
295
|
+
if isinstance(value, str):
|
|
296
|
+
return value
|
|
297
|
+
return default
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_list_command(role: str) -> Tuple[str, ...]:
|
|
301
|
+
"""Build argv for list mode only.
|
|
302
|
+
|
|
303
|
+
This function does not include executable resolution.
|
|
304
|
+
"""
|
|
305
|
+
return tuple(part.format(role=role) for part in LIST_COMMAND_TEMPLATE)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def build_batch_read_command(role: str) -> Tuple[str, ...]:
|
|
309
|
+
"""Build argv template for batch-read mode only.
|
|
310
|
+
|
|
311
|
+
The template mirrors existing fixture-generation scripts.
|
|
312
|
+
"""
|
|
313
|
+
if role == "client":
|
|
314
|
+
return CLIENT_BATCH_READ_COMMAND_TEMPLATE
|
|
315
|
+
return SERVER_BATCH_READ_COMMAND_TEMPLATE
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def run_nitlsconfig_command(
|
|
319
|
+
command_args: Tuple[str, ...],
|
|
320
|
+
) -> str:
|
|
321
|
+
"""Run nitlsconfig command and return stdout.
|
|
322
|
+
|
|
323
|
+
This helper always invokes subprocess in shell-free argv mode.
|
|
324
|
+
"""
|
|
325
|
+
executable = "nitlsconfig"
|
|
326
|
+
argv = [executable, *command_args]
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
completed = subprocess.run(
|
|
330
|
+
argv,
|
|
331
|
+
capture_output=True,
|
|
332
|
+
text=True,
|
|
333
|
+
check=False,
|
|
334
|
+
timeout=30,
|
|
335
|
+
) # nosec B603 - argv is passed shell-free and executable selection is controlled
|
|
336
|
+
except FileNotFoundError as ex:
|
|
337
|
+
fallback_root = os.environ.get(NITLSCONFIG_CLI_ENV_VAR)
|
|
338
|
+
if fallback_root and fallback_root != executable:
|
|
339
|
+
suffix = ".exe" if platform.system().lower() == "windows" else ""
|
|
340
|
+
fallback_executable = pathlib.Path(fallback_root) / f"nitlsconfig{suffix}"
|
|
341
|
+
fallback_executable_str = str(fallback_executable)
|
|
342
|
+
fallback_argv = [fallback_executable_str, *command_args]
|
|
343
|
+
try:
|
|
344
|
+
completed = subprocess.run(
|
|
345
|
+
fallback_argv,
|
|
346
|
+
capture_output=True,
|
|
347
|
+
text=True,
|
|
348
|
+
check=False,
|
|
349
|
+
timeout=30,
|
|
350
|
+
) # nosec B603 - argv is passed shell-free and fallback executable is explicit
|
|
351
|
+
argv = fallback_argv
|
|
352
|
+
except FileNotFoundError as fallback_ex:
|
|
353
|
+
raise ExecutableNotFoundError(
|
|
354
|
+
"Unable to find nitlsconfig executable. "
|
|
355
|
+
f"Tried {executable!r} and {NITLSCONFIG_CLI_ENV_VAR}={fallback_root!r}."
|
|
356
|
+
) from fallback_ex
|
|
357
|
+
else:
|
|
358
|
+
raise ExecutableNotFoundError(
|
|
359
|
+
"Unable to find nitlsconfig executable. " f"Tried {executable!r}."
|
|
360
|
+
) from ex
|
|
361
|
+
|
|
362
|
+
if completed.returncode != 0:
|
|
363
|
+
command_display = " ".join(argv)
|
|
364
|
+
stderr = completed.stderr.strip()
|
|
365
|
+
stdout = completed.stdout.strip()
|
|
366
|
+
details = stderr if stderr else stdout
|
|
367
|
+
raise CommandFailedError(
|
|
368
|
+
"nitlsconfig command failed "
|
|
369
|
+
f"(exit={completed.returncode}): {command_display}. "
|
|
370
|
+
f"Output: {details}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return completed.stdout
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def run_nitlsconfig_json_command(
|
|
377
|
+
command_args: Tuple[str, ...],
|
|
378
|
+
) -> Any:
|
|
379
|
+
"""Run nitlsconfig command and parse stdout as JSON."""
|
|
380
|
+
stdout = run_nitlsconfig_command(
|
|
381
|
+
command_args=command_args,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
return json.loads(stdout)
|
|
386
|
+
except json.JSONDecodeError as ex:
|
|
387
|
+
snippet = stdout[:240].replace("\n", "\\n")
|
|
388
|
+
raise InvalidOutputError(
|
|
389
|
+
"Failed to parse nitlsconfig JSON output. " f"Command output starts with: {snippet!r}"
|
|
390
|
+
) from ex
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _list_services(scope: str) -> list[str]:
|
|
394
|
+
"""List configured services for the requested scope.
|
|
395
|
+
|
|
396
|
+
Output is parsed line-by-line and normalized by stripping whitespace and
|
|
397
|
+
dropping empty lines.
|
|
398
|
+
"""
|
|
399
|
+
stdout = run_nitlsconfig_command(command_args=build_list_command(scope))
|
|
400
|
+
return [line.strip() for line in stdout.splitlines() if line.strip()]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _read_services(scope: str) -> list[ServiceData]:
|
|
404
|
+
"""Read full service configurations for the requested scope.
|
|
405
|
+
|
|
406
|
+
Parsed output preserves the original key casing and values from the CLI JSON.
|
|
407
|
+
"""
|
|
408
|
+
payload = run_nitlsconfig_json_command(command_args=build_batch_read_command(scope))
|
|
409
|
+
|
|
410
|
+
if not isinstance(payload, dict):
|
|
411
|
+
raise InvalidOutputError("nitlsconfig JSON output root must be an object")
|
|
412
|
+
|
|
413
|
+
if scope not in ROLE_TO_JSON_ROOT_KEY:
|
|
414
|
+
raise InvalidOutputError(f"Unsupported scope for nitlsconfig JSON output: {scope!r}")
|
|
415
|
+
|
|
416
|
+
root_key = ROLE_TO_JSON_ROOT_KEY[scope]
|
|
417
|
+
if root_key not in payload:
|
|
418
|
+
raise InvalidOutputError(f"nitlsconfig JSON output missing expected root key: {root_key!r}")
|
|
419
|
+
|
|
420
|
+
services = payload[root_key]
|
|
421
|
+
if not isinstance(services, list):
|
|
422
|
+
raise InvalidOutputError(f"nitlsconfig JSON root key {root_key!r} must contain a list")
|
|
423
|
+
|
|
424
|
+
normalized: list[ServiceData] = []
|
|
425
|
+
for item in services:
|
|
426
|
+
if isinstance(item, dict):
|
|
427
|
+
normalized.append(ServiceData.from_json_obj(item))
|
|
428
|
+
else:
|
|
429
|
+
raise InvalidOutputError("nitlsconfig service entries must be objects")
|
|
430
|
+
return normalized
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class _BaseConfig:
|
|
434
|
+
"""Shared read-only behavior for service configuration wrappers."""
|
|
435
|
+
|
|
436
|
+
_scope = ""
|
|
437
|
+
|
|
438
|
+
def __init__(self, service_name: str) -> None:
|
|
439
|
+
self.service_name = service_name
|
|
440
|
+
self._data = self._find_service_data(service_name)
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
def list_services(cls) -> list[str]:
|
|
444
|
+
return _list_services(cls._scope)
|
|
445
|
+
|
|
446
|
+
@classmethod
|
|
447
|
+
def _read_all(cls) -> list[ServiceData]:
|
|
448
|
+
return _read_services(cls._scope)
|
|
449
|
+
|
|
450
|
+
@classmethod
|
|
451
|
+
def _find_service_data(cls, service_name: str) -> ServiceData:
|
|
452
|
+
for item in cls._read_all():
|
|
453
|
+
if item.value("service_name") == service_name:
|
|
454
|
+
return item
|
|
455
|
+
|
|
456
|
+
# Keep behavior compatible with minimal list-only service entries.
|
|
457
|
+
if service_name in cls.list_services():
|
|
458
|
+
return ServiceData(raw={"service_name": service_name})
|
|
459
|
+
|
|
460
|
+
raise InvalidOutputError(f"Service not found: {service_name!r}")
|
|
461
|
+
|
|
462
|
+
def _value(self, key: str, default: str = "") -> str:
|
|
463
|
+
return self._data.value(key, default)
|
|
464
|
+
|
|
465
|
+
def _location(self, key: str) -> CertificateLocation:
|
|
466
|
+
return CertificateLocation.from_string(self._value(key))
|
|
467
|
+
|
|
468
|
+
@property
|
|
469
|
+
def certificate_mode_raw(self) -> str:
|
|
470
|
+
return self._value("certificate_mode")
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def certificate_chain_location_raw(self) -> str:
|
|
474
|
+
return self._value("certificate_chain_location")
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def certificate_chain_contents_raw(self) -> str:
|
|
478
|
+
return self._value("certificate_chain_contents")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class ServerConfig(_BaseConfig):
|
|
482
|
+
"""Read-only view for server-side TLS configuration."""
|
|
483
|
+
|
|
484
|
+
_scope = "server"
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def certificate_mode(self) -> ServerCertMode:
|
|
488
|
+
"Parse certificate_mode string into ServerCertMode enum, defaulting to Unknown."
|
|
489
|
+
return _parse_enum(
|
|
490
|
+
self.certificate_mode_raw,
|
|
491
|
+
ServerCertMode,
|
|
492
|
+
ServerCertMode.Unknown,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def certificate_chain_location(self) -> CertificateLocation:
|
|
497
|
+
"Return the parsed certificate_chain_location from the service configuration."
|
|
498
|
+
return self._location("certificate_chain_location")
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def certificate_chain_contents(self) -> str:
|
|
502
|
+
"Return the raw certificate_chain_contents string from the service configuration."
|
|
503
|
+
return self._value("certificate_chain_contents")
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def certificate_key_location(self) -> CertificateLocation:
|
|
507
|
+
"Return the parsed certificate_key_location from the service configuration."
|
|
508
|
+
return self._location("certificate_key_location")
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def certificate_key_contents(self) -> str:
|
|
512
|
+
"Return the raw certificate_key_contents string from the service configuration."
|
|
513
|
+
return self._value("certificate_key_contents")
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def client_mode(self) -> ServerClientMode:
|
|
517
|
+
"Parse client_mode string into ServerClientMode enum, defaulting to Unknown."
|
|
518
|
+
return _parse_enum(
|
|
519
|
+
self._value("client_mode"),
|
|
520
|
+
ServerClientMode,
|
|
521
|
+
ServerClientMode.Unknown,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def trusted_certificates_location(self) -> CertificateLocation:
|
|
526
|
+
"Return the parsed trusted_certificates_location from the service configuration."
|
|
527
|
+
return self._location("trusted_certificates_location")
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def trusted_certificates_contents(self) -> str:
|
|
531
|
+
"Return the raw trusted_certificates_contents string from the service configuration."
|
|
532
|
+
return self._value("trusted_certificates_contents")
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def trusted_certificates(self) -> list[TrustedCertificateData]:
|
|
536
|
+
"Return the list of TrustedCertificateData from the service configuration."
|
|
537
|
+
return self._data.trusted_certificates
|
|
538
|
+
|
|
539
|
+
def __str__(self) -> str:
|
|
540
|
+
"Auto string conversion for debugging and display."
|
|
541
|
+
return f"ServerConfig(name={self.service_name}, certificate_mode={self.certificate_mode}, client_mode={self.client_mode})"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class ClientConfig(_BaseConfig):
|
|
545
|
+
"""Read-only view for client-side TLS configuration."""
|
|
546
|
+
|
|
547
|
+
_scope = "client"
|
|
548
|
+
|
|
549
|
+
def __init__(self, service_name: str, server_address: Optional[str] = None) -> None:
|
|
550
|
+
"""Read client configuration, optionally resolved for a server address.
|
|
551
|
+
|
|
552
|
+
When ``server_address`` matches a known server, its configuration is used.
|
|
553
|
+
Otherwise, the generic service configuration remains in effect.
|
|
554
|
+
"""
|
|
555
|
+
self.service_name = service_name
|
|
556
|
+
self.server_address = server_address
|
|
557
|
+
self._data = self._find_service_data(service_name)
|
|
558
|
+
# _data always contains the default service data. When server_address
|
|
559
|
+
# matches a known_servers entry, _resolved_data uses that configuration;
|
|
560
|
+
# otherwise, it retains the default service data. Client settings are read
|
|
561
|
+
# from _resolved_data.
|
|
562
|
+
self._resolved_data = self._data
|
|
563
|
+
if server_address is not None:
|
|
564
|
+
known_server = next(
|
|
565
|
+
(item for item in self._data.known_servers if item.server_name == server_address),
|
|
566
|
+
None,
|
|
567
|
+
)
|
|
568
|
+
if known_server is not None:
|
|
569
|
+
self._resolved_data = ServiceData(raw=known_server.raw)
|
|
570
|
+
|
|
571
|
+
def _value(self, key: str, default: str = "") -> str:
|
|
572
|
+
return self._resolved_data.value(key, default)
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def certificate_mode(self) -> ClientCertMode:
|
|
576
|
+
"Parse certificate_mode string into ClientCertMode enum, defaulting to Unknown."
|
|
577
|
+
return _parse_enum(
|
|
578
|
+
self.certificate_mode_raw,
|
|
579
|
+
ClientCertMode,
|
|
580
|
+
ClientCertMode.Unknown,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
@property
|
|
584
|
+
def server_mode(self) -> ClientServerMode:
|
|
585
|
+
"Parse server_mode string into ClientServerMode enum, defaulting to Unknown."
|
|
586
|
+
return _parse_enum(
|
|
587
|
+
self._value("server_mode"),
|
|
588
|
+
ClientServerMode,
|
|
589
|
+
ClientServerMode.Unknown,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
@property
|
|
593
|
+
def certificate_key_location(self) -> CertificateLocation:
|
|
594
|
+
"Return the parsed certificate_key_location from the service configuration."
|
|
595
|
+
return self._location("certificate_key_location")
|
|
596
|
+
|
|
597
|
+
@property
|
|
598
|
+
def certificate_key_contents(self) -> str:
|
|
599
|
+
"Return the raw certificate_key_contents string from the service configuration."
|
|
600
|
+
return self._value("certificate_key_contents")
|
|
601
|
+
|
|
602
|
+
@property
|
|
603
|
+
def known_servers(self) -> list[KnownServerData]:
|
|
604
|
+
"Return the typed known-server configurations from the service configuration."
|
|
605
|
+
return self._data.known_servers
|
|
606
|
+
|
|
607
|
+
@property
|
|
608
|
+
def certificate_chain_contents(self) -> str:
|
|
609
|
+
"Return the raw certificate_chain_contents string from the service configuration."
|
|
610
|
+
return self._value("certificate_chain_contents")
|
|
611
|
+
|
|
612
|
+
@property
|
|
613
|
+
def certificate_chain_location(self) -> CertificateLocation:
|
|
614
|
+
"Return the parsed certificate_chain_location from the service configuration."
|
|
615
|
+
return self._location("certificate_chain_location")
|
|
616
|
+
|
|
617
|
+
@property
|
|
618
|
+
def trusted_certificates_contents(self) -> str:
|
|
619
|
+
"Return the raw trusted_certificates_contents string from the service configuration."
|
|
620
|
+
return self._value("trusted_certificates_contents")
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def trusted_certificates_location(self) -> CertificateLocation:
|
|
624
|
+
"Return the parsed trusted_certificates_location from the service configuration."
|
|
625
|
+
return self._location("trusted_certificates_location")
|
|
626
|
+
|
|
627
|
+
def __str__(self) -> str:
|
|
628
|
+
"Auto string conversion for debugging and display."
|
|
629
|
+
return f"ClientConfig(name={self.service_name}, certificate_mode={self.certificate_mode})"
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def nitlsconfig_main(argv: Optional[list[str]] = None) -> int:
|
|
633
|
+
"""Console entry point for read-only service listing."""
|
|
634
|
+
parser = argparse.ArgumentParser(prog="nitlsconfig-read")
|
|
635
|
+
parser.add_argument("scope", choices=ALLOWED_SCOPES)
|
|
636
|
+
parser.add_argument("command", choices=["list"], nargs="?", default="list")
|
|
637
|
+
args = parser.parse_args(argv)
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
if args.command == "list":
|
|
641
|
+
config_cls = ClientConfig if args.scope == "client" else ServerConfig
|
|
642
|
+
for service in config_cls.list_services():
|
|
643
|
+
print(service)
|
|
644
|
+
return 0
|
|
645
|
+
except NitlsconfigCliError as ex:
|
|
646
|
+
print(str(ex), file=sys.stderr)
|
|
647
|
+
return 1
|
|
648
|
+
|
|
649
|
+
return 1
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
if __name__ == "__main__":
|
|
653
|
+
raise SystemExit(nitlsconfig_main())
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Create gRPC client channels from NI-TLS (nitlsconfig) client configuration.
|
|
2
|
+
|
|
3
|
+
Reads the local NI-TLS client configuration for a service and produces a
|
|
4
|
+
:class:`grpc.Channel` that is either secured with TLS/mTLS or, when TLS is not
|
|
5
|
+
configured, a plain insecure channel.
|
|
6
|
+
|
|
7
|
+
The resulting channel is a normal ``grpc.Channel``. It can be handed directly to
|
|
8
|
+
any NI gRPC Python API, for example::
|
|
9
|
+
|
|
10
|
+
from nitlsconfig.grpc_channel import create_grpc_client_channel
|
|
11
|
+
|
|
12
|
+
channel = create_grpc_client_channel("localhost", 31763)
|
|
13
|
+
options = nidcpower.GrpcSessionOptions(channel, "")
|
|
14
|
+
with nidcpower.Session("Dev1", grpc_options=options) as session:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
Channel ownership stays with the caller, matching the NI Python driver APIs,
|
|
18
|
+
which never close the channel themselves. ``grpc.Channel`` is already a context
|
|
19
|
+
manager, so ``with create_grpc_client_channel(...) as channel:`` works as expected.
|
|
20
|
+
|
|
21
|
+
The name carries the ``grpc`` prefix because this package also re-exports the
|
|
22
|
+
factory from its root, alongside any potential future non-gRPC transports.
|
|
23
|
+
|
|
24
|
+
A client ``server_mode`` of ``TrustAlways`` is not currently supported and raises
|
|
25
|
+
:class:`TlsConfigurationError`.
|
|
26
|
+
|
|
27
|
+
A client ``server_mode`` of ``SkipHostnameValidation`` is treated exactly like
|
|
28
|
+
``TrustedCertificates``: the server certificate chain is verified *and* the
|
|
29
|
+
hostname is checked. gRPC's Python API exposes no way to skip only the hostname
|
|
30
|
+
check: doing so requires a custom certificate verifier, which grpcio does not
|
|
31
|
+
bind in Python, where the TLS surface is limited to
|
|
32
|
+
``grpc.ssl_channel_credentials``. Verifying when asked not to fails closed, so
|
|
33
|
+
this is safe, but a caller who sets the mode gets no relaxation of the hostname
|
|
34
|
+
check.
|
|
35
|
+
|
|
36
|
+
When the server certificate's CN/SAN does not match the dialed host, pass
|
|
37
|
+
``grpc.ssl_target_name_override`` via ``options`` instead. That substitutes the
|
|
38
|
+
name gRPC matches against the certificate; it does not disable verification.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from typing import Any, Optional, Sequence, Tuple
|
|
46
|
+
|
|
47
|
+
import grpc
|
|
48
|
+
|
|
49
|
+
from nitlsconfig.cli import (
|
|
50
|
+
CertificateLocation,
|
|
51
|
+
ClientCertMode,
|
|
52
|
+
ClientConfig,
|
|
53
|
+
ClientServerMode,
|
|
54
|
+
LocationScheme,
|
|
55
|
+
NitlsconfigCliError,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"DEFAULT_SERVICE_NAME",
|
|
60
|
+
"RetryPolicy",
|
|
61
|
+
"TlsConfigurationError",
|
|
62
|
+
"create_grpc_client_channel",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# The nitlsconfig service name registered by the NI gRPC Device Server. It is
|
|
66
|
+
# the file stem of ni-grpc-device.client.caps.yml, which grpc-device installs
|
|
67
|
+
# into the nitlsconfig client.d directory.
|
|
68
|
+
DEFAULT_SERVICE_NAME = "ni-grpc-device"
|
|
69
|
+
|
|
70
|
+
# gRPC channel argument that carries a service config JSON document.
|
|
71
|
+
_SERVICE_CONFIG_ARG = "grpc.service_config"
|
|
72
|
+
|
|
73
|
+
ChannelOptions = Sequence[Tuple[str, Any]]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _format_target(server_address: str, server_port: int) -> str:
|
|
77
|
+
"""Join an address and port into a gRPC target, bracketing IPv6 literals.
|
|
78
|
+
|
|
79
|
+
gRPC requires IPv6 literals in brackets: ``::1:31763`` never connects,
|
|
80
|
+
while ``[::1]:31763`` does. An unbracketed address containing a colon can
|
|
81
|
+
only be an IPv6 literal, since neither host names nor IPv4 addresses may
|
|
82
|
+
contain one.
|
|
83
|
+
"""
|
|
84
|
+
if ":" in server_address and not server_address.startswith("["):
|
|
85
|
+
return f"[{server_address}]:{server_port}"
|
|
86
|
+
return f"{server_address}:{server_port}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TlsConfigurationError(NitlsconfigCliError):
|
|
90
|
+
"""Raised when the NI-TLS configuration was read successfully but is invalid."""
|
|
91
|
+
|
|
92
|
+
#: Shared remedy text appended to messages whose fix is to provision
|
|
93
|
+
#: certificates. Kept in one place so the guidance stays consistent with the
|
|
94
|
+
#: wording used elsewhere in the product.
|
|
95
|
+
_REMEDY = (
|
|
96
|
+
"Use NI Hardware Manager to verify that certificates are configured and "
|
|
97
|
+
"matching on both the host and remote target. Check that the remote target "
|
|
98
|
+
"has a compatible TLS enabled configuration with the host."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class RetryPolicy:
|
|
104
|
+
"""Client retry behavior, realized as a gRPC service config.
|
|
105
|
+
|
|
106
|
+
The mechanism and backoff algorithm are defined by gRFC A6 (gRPC Retry
|
|
107
|
+
Design); the values below are this package's defaults. Retries are opt-in:
|
|
108
|
+
gRPC configures no retry policy unless one is supplied.
|
|
109
|
+
|
|
110
|
+
The policy applies to every method, including non-idempotent ones.
|
|
111
|
+
``UNAVAILABLE`` does not guarantee the server never processed the request,
|
|
112
|
+
so a retried operation can be applied twice. It also covers TLS handshake
|
|
113
|
+
failures, which gRPC reports as ``UNAVAILABLE``.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
max_attempts: int = 5
|
|
117
|
+
initial_delay_ms: int = 100
|
|
118
|
+
max_delay_ms: int = 1000
|
|
119
|
+
backoff_multiplier: float = 2.0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class _ClientTlsSettings:
|
|
124
|
+
"""Validated client TLS settings, ready to be turned into channel credentials.
|
|
125
|
+
|
|
126
|
+
The trust anchor *scheme* is deliberately not carried here. gRPC consumes
|
|
127
|
+
only the in-memory PEM, and SystemDefault is already represented by empty
|
|
128
|
+
``trusted_contents``, which :func:`_pem_bytes` maps to None. Add the scheme
|
|
129
|
+
back if a future caller needs to distinguish the two.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
present_client_cert: bool
|
|
133
|
+
certificate_chain_contents: str
|
|
134
|
+
private_key_contents: str
|
|
135
|
+
trusted_contents: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _milliseconds_to_duration(milliseconds: int) -> str:
|
|
139
|
+
"""Format milliseconds as a gRPC duration string, e.g. 100 -> '0.100s'."""
|
|
140
|
+
milliseconds = max(milliseconds, 0)
|
|
141
|
+
return f"{milliseconds // 1000}.{milliseconds % 1000:03d}s"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_retry_service_config(policy: RetryPolicy) -> Optional[str]:
|
|
145
|
+
"""Build a gRPC service config JSON document that enables retries.
|
|
146
|
+
|
|
147
|
+
Returns None when the policy asks for no retries. gRPC requires
|
|
148
|
+
maxAttempts >= 2, strictly positive backoffs, and a strictly positive
|
|
149
|
+
backoff multiplier, so anything less means "do not configure retries"
|
|
150
|
+
rather than an error.
|
|
151
|
+
"""
|
|
152
|
+
if (
|
|
153
|
+
policy.max_attempts < 2
|
|
154
|
+
or policy.initial_delay_ms <= 0
|
|
155
|
+
or policy.max_delay_ms <= 0
|
|
156
|
+
or policy.backoff_multiplier <= 0
|
|
157
|
+
):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
return json.dumps(
|
|
161
|
+
{
|
|
162
|
+
"methodConfig": [
|
|
163
|
+
{
|
|
164
|
+
# An empty method name applies the policy to every method.
|
|
165
|
+
"name": [{}],
|
|
166
|
+
"retryPolicy": {
|
|
167
|
+
"maxAttempts": policy.max_attempts,
|
|
168
|
+
"initialBackoff": _milliseconds_to_duration(policy.initial_delay_ms),
|
|
169
|
+
"maxBackoff": _milliseconds_to_duration(policy.max_delay_ms),
|
|
170
|
+
"backoffMultiplier": policy.backoff_multiplier,
|
|
171
|
+
"retryableStatusCodes": ["UNAVAILABLE"],
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _apply_retry_policy(
|
|
180
|
+
options: ChannelOptions, policy: Optional[RetryPolicy]
|
|
181
|
+
) -> list[Tuple[str, Any]]:
|
|
182
|
+
"""Return channel options with the retry service config appended, if applicable."""
|
|
183
|
+
channel_options = list(options)
|
|
184
|
+
if policy is None:
|
|
185
|
+
return channel_options
|
|
186
|
+
|
|
187
|
+
# gRPC resolves duplicate entries by taking the first, so a caller-supplied
|
|
188
|
+
# service config already takes effect and must not be overridden.
|
|
189
|
+
if any(key == _SERVICE_CONFIG_ARG for key, _ in channel_options):
|
|
190
|
+
return channel_options
|
|
191
|
+
|
|
192
|
+
service_config = _build_retry_service_config(policy)
|
|
193
|
+
if service_config is not None:
|
|
194
|
+
channel_options.append((_SERVICE_CONFIG_ARG, service_config))
|
|
195
|
+
return channel_options
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _require_file_scheme(
|
|
199
|
+
location: CertificateLocation, description: str, service_name: str
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Validate that a certificate or key location is a usable File:// path.
|
|
202
|
+
|
|
203
|
+
The ni-grpc-device client capabilities declare support for the File scheme
|
|
204
|
+
only, so any other scheme is rejected rather than silently ignored.
|
|
205
|
+
"""
|
|
206
|
+
if location.scheme != LocationScheme.File:
|
|
207
|
+
raise TlsConfigurationError(
|
|
208
|
+
f"Client {description} must use the File scheme for service "
|
|
209
|
+
f"{service_name!r}, got {location.scheme.value!r}."
|
|
210
|
+
)
|
|
211
|
+
if not location.path:
|
|
212
|
+
raise TlsConfigurationError(
|
|
213
|
+
f"TLS is enabled but the client {description} path is missing for "
|
|
214
|
+
f"service {service_name!r}."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _require_contents(contents: str, description: str, service_name: str) -> str:
|
|
219
|
+
"""Validate that configured certificate material is actually present.
|
|
220
|
+
|
|
221
|
+
Empty contents mean the material could not be produced (missing, unreadable,
|
|
222
|
+
or not yet provisioned), never that the client opted out. Opting out is
|
|
223
|
+
expressed by the configuration itself: ``certificate_mode`` Disabled for the
|
|
224
|
+
client identity, and the SystemDefault scheme for trust anchors. Neither
|
|
225
|
+
reaches this check.
|
|
226
|
+
"""
|
|
227
|
+
if not contents:
|
|
228
|
+
raise TlsConfigurationError(
|
|
229
|
+
f"TLS is configured for service {service_name!r} but the client "
|
|
230
|
+
f"{description} is missing on this system. {TlsConfigurationError._REMEDY}"
|
|
231
|
+
)
|
|
232
|
+
return contents
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _load_client_tls_settings(config: ClientConfig) -> Optional[_ClientTlsSettings]:
|
|
236
|
+
"""Read and validate client TLS settings, or None when TLS is not in use.
|
|
237
|
+
|
|
238
|
+
``server_mode`` is the master switch: when it is Disabled the client uses a
|
|
239
|
+
plain connection and ``certificate_mode`` is not consulted at all.
|
|
240
|
+
"""
|
|
241
|
+
service_name = config.service_name
|
|
242
|
+
|
|
243
|
+
server_mode = config.server_mode
|
|
244
|
+
if server_mode == ClientServerMode.Disabled:
|
|
245
|
+
return None
|
|
246
|
+
if server_mode == ClientServerMode.TrustAlways:
|
|
247
|
+
# ni-grpc-device.client.caps.yml declares supports_server_mode_trust_always: false,
|
|
248
|
+
# so this mode is not offered for this service.
|
|
249
|
+
raise TlsConfigurationError(
|
|
250
|
+
f"Client server_mode TrustAlways is not supported for service {service_name!r}."
|
|
251
|
+
)
|
|
252
|
+
if server_mode == ClientServerMode.Unknown:
|
|
253
|
+
raise TlsConfigurationError(f"Unsupported client server_mode for service {service_name!r}.")
|
|
254
|
+
|
|
255
|
+
# certificate_mode only decides mTLS versus one-way TLS.
|
|
256
|
+
if config.certificate_mode == ClientCertMode.Unknown:
|
|
257
|
+
raise TlsConfigurationError(
|
|
258
|
+
f"Unsupported client certificate_mode for service {service_name!r}."
|
|
259
|
+
)
|
|
260
|
+
present_client_cert = config.certificate_mode != ClientCertMode.Disabled
|
|
261
|
+
|
|
262
|
+
certificate_chain_contents = ""
|
|
263
|
+
private_key_contents = ""
|
|
264
|
+
if present_client_cert:
|
|
265
|
+
_require_file_scheme(config.certificate_chain_location, "certificate chain", service_name)
|
|
266
|
+
_require_file_scheme(config.certificate_key_location, "certificate key", service_name)
|
|
267
|
+
certificate_chain_contents = _require_contents(
|
|
268
|
+
config.certificate_chain_contents, "certificate chain", service_name
|
|
269
|
+
)
|
|
270
|
+
private_key_contents = _require_contents(
|
|
271
|
+
config.certificate_key_contents, "certificate key", service_name
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Trust anchors are always required: the client must verify the server.
|
|
275
|
+
# SystemDefault means "use the platform certificate store" and carries no
|
|
276
|
+
# contents. Every other usable scheme (File, Directory) is resolved by
|
|
277
|
+
# nitlsconfig into a single PEM bundle, so the scheme itself does not need
|
|
278
|
+
# to be special-cased here; only Unknown is rejected.
|
|
279
|
+
trusted_location = config.trusted_certificates_location
|
|
280
|
+
if trusted_location.scheme == LocationScheme.Unknown:
|
|
281
|
+
raise TlsConfigurationError(
|
|
282
|
+
f"TLS is configured for service {service_name!r} but the client trusted "
|
|
283
|
+
f"certificates location is missing or unrecognized. {TlsConfigurationError._REMEDY}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
trusted_contents = ""
|
|
287
|
+
if trusted_location.scheme != LocationScheme.SystemDefault:
|
|
288
|
+
# Any scheme other than SystemDefault names specific anchors, so they
|
|
289
|
+
# must actually be present.
|
|
290
|
+
trusted_contents = _require_contents(
|
|
291
|
+
config.trusted_certificates_contents, "trusted certificate bundle", service_name
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return _ClientTlsSettings(
|
|
295
|
+
present_client_cert=present_client_cert,
|
|
296
|
+
certificate_chain_contents=certificate_chain_contents,
|
|
297
|
+
private_key_contents=private_key_contents,
|
|
298
|
+
trusted_contents=trusted_contents,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _pem_bytes(contents: str) -> Optional[bytes]:
|
|
303
|
+
"""Encode PEM text for gRPC, mapping empty contents to None.
|
|
304
|
+
|
|
305
|
+
nitlsconfig represents the SystemDefault trust scheme as empty contents,
|
|
306
|
+
meaning "use the platform default certificate store". Python requires None
|
|
307
|
+
for that behavior: passing empty bytes would instead configure an empty
|
|
308
|
+
trust store and fail every connection.
|
|
309
|
+
"""
|
|
310
|
+
return contents.encode("utf-8") if contents else None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _make_client_credentials(settings: _ClientTlsSettings) -> grpc.ChannelCredentials:
|
|
314
|
+
"""Build channel credentials from validated client TLS settings."""
|
|
315
|
+
certificate_chain = None
|
|
316
|
+
private_key = None
|
|
317
|
+
if settings.present_client_cert:
|
|
318
|
+
certificate_chain = _pem_bytes(settings.certificate_chain_contents)
|
|
319
|
+
private_key = _pem_bytes(settings.private_key_contents)
|
|
320
|
+
|
|
321
|
+
return grpc.ssl_channel_credentials(
|
|
322
|
+
root_certificates=_pem_bytes(settings.trusted_contents),
|
|
323
|
+
private_key=private_key,
|
|
324
|
+
certificate_chain=certificate_chain,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def create_grpc_client_channel(
|
|
329
|
+
server_address: str,
|
|
330
|
+
server_port: int,
|
|
331
|
+
service_name: str = DEFAULT_SERVICE_NAME,
|
|
332
|
+
options: ChannelOptions = (),
|
|
333
|
+
retry_policy: Optional[RetryPolicy] = None,
|
|
334
|
+
) -> grpc.Channel:
|
|
335
|
+
"""Create a gRPC channel to ``server_address:server_port`` using NI-TLS configuration.
|
|
336
|
+
|
|
337
|
+
Reads the NI-TLS client configuration for ``service_name`` and builds a
|
|
338
|
+
channel that verifies the server certificate and, when the configuration
|
|
339
|
+
calls for mutual TLS, also presents the client certificate. Falls back to an
|
|
340
|
+
insecure channel when the client's ``server_mode`` is Disabled, which is the
|
|
341
|
+
default until the machine is configured.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
server_address: Host name or address of the NI gRPC Device Server. Also used
|
|
345
|
+
to resolve NI-TLS settings specific to this target. IPv6 literals may be
|
|
346
|
+
passed with or without brackets.
|
|
347
|
+
server_port: Port of the NI gRPC Device Server.
|
|
348
|
+
service_name: nitlsconfig service name to read configuration from.
|
|
349
|
+
options: gRPC channel arguments, as ``(key, value)`` pairs. Use this to
|
|
350
|
+
tune the channel, for example to raise message size limits or to set
|
|
351
|
+
``grpc.ssl_target_name_override`` when the server certificate's
|
|
352
|
+
CN/SAN differs from the dialed host. Channel arguments cannot be
|
|
353
|
+
changed after the channel is built, so they must be supplied here.
|
|
354
|
+
retry_policy: Optional client retry configuration. When None (the
|
|
355
|
+
default) no retry service config is added and gRPC's built-in
|
|
356
|
+
behavior applies. Pass ``RetryPolicy()`` for the defaults described
|
|
357
|
+
on that class.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
A ``grpc.Channel`` owned by the caller. Close it when the last session
|
|
361
|
+
using it is done.
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
TlsConfigurationError: TLS is enabled but the configuration is invalid.
|
|
365
|
+
NitlsconfigCliError: The nitlsconfig CLI could not be run or parsed.
|
|
366
|
+
"""
|
|
367
|
+
target = _format_target(server_address, server_port)
|
|
368
|
+
channel_options = _apply_retry_policy(options, retry_policy)
|
|
369
|
+
|
|
370
|
+
settings = _load_client_tls_settings(ClientConfig(service_name, server_address))
|
|
371
|
+
if settings is None:
|
|
372
|
+
return grpc.insecure_channel(target, options=channel_options)
|
|
373
|
+
|
|
374
|
+
return grpc.secure_channel(target, _make_client_credentials(settings), options=channel_options)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2022, National Instruments Corp.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included
|
|
12
|
+
in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: nitlsconfig
|
|
3
|
+
Version: 1.0.0a1
|
|
4
|
+
Summary: Python API for reading nitlsconfig configurations and creating gRPC client channels from them
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: nitlsconfig,tls,mtls,grpc,configuration
|
|
7
|
+
Author: NI
|
|
8
|
+
Author-email: opensource@ni.com
|
|
9
|
+
Maintainer: Philip Thong
|
|
10
|
+
Maintainer-email: philip.thong@emerson.com
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Provides-Extra: grpc
|
|
25
|
+
Requires-Dist: grpcio (>=1.49.0,<2.0) ; extra == "grpc"
|
|
26
|
+
Project-URL: Repository, https://github.com/ni/nitlsconfig-python
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# nitlsconfig
|
|
30
|
+
|
|
31
|
+
Python API that reads nitlsconfig configurations through the `nitlsconfig` command line,
|
|
32
|
+
and builds gRPC client channels from them.
|
|
33
|
+
|
|
34
|
+
Installed and imported as `nitlsconfig`; developed at
|
|
35
|
+
[ni/nitlsconfig-python](https://github.com/ni/nitlsconfig-python).
|
|
36
|
+
|
|
37
|
+
## Runtime dependencies
|
|
38
|
+
|
|
39
|
+
- nitlsconfig executable, discoverable, or explicit path to NITLSCONFIG_CLI
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
Reading NI-TLS configuration is pure Python and has no third-party dependencies:
|
|
44
|
+
|
|
45
|
+
- `pip install nitlsconfig`
|
|
46
|
+
|
|
47
|
+
The gRPC channel factory additionally needs grpcio, which is an optional extra:
|
|
48
|
+
|
|
49
|
+
- `pip install nitlsconfig[grpc]`
|
|
50
|
+
|
|
51
|
+
## Creating a gRPC channel
|
|
52
|
+
|
|
53
|
+
`create_grpc_client_channel` reads the local NI-TLS client configuration and returns a
|
|
54
|
+
`grpc.Channel` secured accordingly. The `server_address` hostname or address is used to
|
|
55
|
+
select matching target-specific NI-TLS settings. Pass the channel straight to any NI
|
|
56
|
+
gRPC Python API:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import nidcpower
|
|
60
|
+
import nitlsconfig
|
|
61
|
+
|
|
62
|
+
with nitlsconfig.create_grpc_client_channel("localhost", 31763) as channel:
|
|
63
|
+
options = nidcpower.GrpcSessionOptions(channel, "")
|
|
64
|
+
with nidcpower.Session("Dev1", grpc_options=options) as session:
|
|
65
|
+
...
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The channel is mutually authenticated, one-way TLS, or insecure depending on how
|
|
69
|
+
the machine is configured; no code change is needed to move between them. The
|
|
70
|
+
channel is owned by the caller - NI driver APIs never close it.
|
|
71
|
+
|
|
72
|
+
Retries are opt-in:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
channel = nitlsconfig.create_grpc_client_channel(
|
|
76
|
+
"localhost", 31763, retry_policy=nitlsconfig.RetryPolicy()
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`TlsConfigurationError` is raised when TLS is enabled but the configuration is
|
|
81
|
+
unusable. Accessing any of these names without the `grpc` extra installed raises
|
|
82
|
+
`ImportError` telling you which extra to install.
|
|
83
|
+
|
|
84
|
+
## Reading configurations
|
|
85
|
+
```python
|
|
86
|
+
import nitlsconfig
|
|
87
|
+
|
|
88
|
+
# List configured services
|
|
89
|
+
clients = nitlsconfig.ClientConfig.list_services()
|
|
90
|
+
servers = nitlsconfig.ServerConfig.list_services()
|
|
91
|
+
|
|
92
|
+
if clients:
|
|
93
|
+
# Read one client configuration
|
|
94
|
+
client_info = nitlsconfig.ClientConfig(clients[0])
|
|
95
|
+
print(client_info.service_name)
|
|
96
|
+
print(client_info.certificate_mode)
|
|
97
|
+
print(client_info.certificate_chain_location.scheme)
|
|
98
|
+
print(client_info.certificate_chain_location.path)
|
|
99
|
+
print(client_info.certificate_chain_contents)
|
|
100
|
+
|
|
101
|
+
# Inspect target-specific configurations
|
|
102
|
+
for known_server in client_info.known_servers:
|
|
103
|
+
print(known_server.server_name)
|
|
104
|
+
print(known_server.server_mode)
|
|
105
|
+
print(known_server.trusted_certificates_location)
|
|
106
|
+
|
|
107
|
+
if servers:
|
|
108
|
+
# Read one server configuration
|
|
109
|
+
server_info = nitlsconfig.ServerConfig(servers[0])
|
|
110
|
+
print(server_info.service_name)
|
|
111
|
+
print(server_info.certificate_mode)
|
|
112
|
+
print(server_info.client_mode)
|
|
113
|
+
print(server_info.certificate_chain_location.scheme)
|
|
114
|
+
print(server_info.certificate_key_location.scheme)
|
|
115
|
+
print(server_info.trusted_certificates_location.scheme)
|
|
116
|
+
print(server_info.trusted_certificates_contents)
|
|
117
|
+
print(server_info.certificate_key_contents)
|
|
118
|
+
|
|
119
|
+
# Enumerate trusted certificates
|
|
120
|
+
for cert in server_info.trusted_certificates:
|
|
121
|
+
print(cert.display_name)
|
|
122
|
+
print(cert.trusted_certificate_location.path)
|
|
123
|
+
print(cert.trusted_certificate_contents)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
nitlsconfig/__init__.py,sha256=FqybeZ-cjCILSAD8kDmUgO0XPsysodQD3YzuxB4vPko,3421
|
|
2
|
+
nitlsconfig/cli.py,sha256=BEgK6cSuV_G8uKvA_4po0uGCj7X9wvbA3TIX9_hVFfY,22382
|
|
3
|
+
nitlsconfig/grpc_channel.py,sha256=Lz2LEz2URM0qBd8zDiJ6_G-lsR7e_LXwhi1p51qWn20,15598
|
|
4
|
+
nitlsconfig-1.0.0a1.dist-info/LICENSE,sha256=uX8bias4aiQ4jqwYI4rxj3C5l_Bqj4ZSoucMrYksQUc,1071
|
|
5
|
+
nitlsconfig-1.0.0a1.dist-info/METADATA,sha256=t_oKog_zWKdqC9WFvYIqYwsaAT9cm79fupBUxji0Xr8,4373
|
|
6
|
+
nitlsconfig-1.0.0a1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
7
|
+
nitlsconfig-1.0.0a1.dist-info/entry_points.txt,sha256=ECS9TCbetbL-PDOVI_M6nErEV634U5HDhGPHilc3S_I,69
|
|
8
|
+
nitlsconfig-1.0.0a1.dist-info/RECORD,,
|