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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ nitlsconfig-read=nitlsconfig.cli:nitlsconfig_main
3
+