fadenstack 0.4.2__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.
Files changed (99) hide show
  1. faden_cli/__init__.py +16 -0
  2. faden_cli/__main__.py +9 -0
  3. faden_cli/bundle/shared/.gitignore +25 -0
  4. faden_cli/bundle/shared/LICENSE +187 -0
  5. faden_cli/bundle/shared/NOTICE +15 -0
  6. faden_cli/bundle/shared/OFFICIAL.md +9 -0
  7. faden_cli/bundle/shared/README.md +16 -0
  8. faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
  9. faden_cli/bundle/shared/alloy/config.alloy +40 -0
  10. faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
  11. faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
  12. faden_cli/bundle/shared/docker-compose.yaml +998 -0
  13. faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
  14. faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
  15. faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
  16. faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
  17. faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
  18. faden_cli/bundle/shared/initdb/01-init.sql +32 -0
  19. faden_cli/bundle/shared/loki/local-config.yaml +32 -0
  20. faden_cli/bundle/shared/nginx/locations.conf +245 -0
  21. faden_cli/bundle/shared/nginx/maintenance.html +239 -0
  22. faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
  23. faden_cli/bundle/shared/nginx/nginx.conf +44 -0
  24. faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
  25. faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
  26. faden_cli/bundle/shared/nginx/select-config.sh +37 -0
  27. faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
  28. faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
  29. faden_cli/bundle/shared/tls/README.md +30 -0
  30. faden_cli/cli.py +158 -0
  31. faden_cli/commands/__init__.py +1 -0
  32. faden_cli/commands/admin.py +161 -0
  33. faden_cli/commands/backup.py +159 -0
  34. faden_cli/commands/config.py +146 -0
  35. faden_cli/commands/deploy.py +949 -0
  36. faden_cli/commands/dev/__init__.py +1 -0
  37. faden_cli/commands/dev/dev_doctor.py +46 -0
  38. faden_cli/commands/dev/dev_down.py +241 -0
  39. faden_cli/commands/dev/dev_group.py +24 -0
  40. faden_cli/commands/dev/dev_init.py +710 -0
  41. faden_cli/commands/dev/dev_pull.py +297 -0
  42. faden_cli/commands/dev/dev_status.py +151 -0
  43. faden_cli/commands/dev/dev_up.py +1392 -0
  44. faden_cli/commands/doctor.py +225 -0
  45. faden_cli/commands/down.py +75 -0
  46. faden_cli/commands/logs_cmd.py +41 -0
  47. faden_cli/commands/maintenance.py +74 -0
  48. faden_cli/commands/module.py +113 -0
  49. faden_cli/commands/observe.py +95 -0
  50. faden_cli/commands/ports.py +91 -0
  51. faden_cli/commands/rebuild.py +158 -0
  52. faden_cli/commands/restore.py +139 -0
  53. faden_cli/commands/runtime_images.py +174 -0
  54. faden_cli/commands/status.py +71 -0
  55. faden_cli/commands/tls.py +722 -0
  56. faden_cli/commands/tune.py +37 -0
  57. faden_cli/commands/up.py +64 -0
  58. faden_cli/commands/upgrade.py +470 -0
  59. faden_cli/commands/version.py +78 -0
  60. faden_cli/core/__init__.py +1 -0
  61. faden_cli/core/api_client.py +77 -0
  62. faden_cli/core/backup.py +717 -0
  63. faden_cli/core/bootstrap.py +291 -0
  64. faden_cli/core/bundle.py +110 -0
  65. faden_cli/core/clickhouse.py +61 -0
  66. faden_cli/core/compose.py +484 -0
  67. faden_cli/core/console.py +106 -0
  68. faden_cli/core/detect.py +580 -0
  69. faden_cli/core/dev_modules.py +149 -0
  70. faden_cli/core/docker_env.py +147 -0
  71. faden_cli/core/edge.py +94 -0
  72. faden_cli/core/env_gen.py +598 -0
  73. faden_cli/core/git.py +219 -0
  74. faden_cli/core/install.py +288 -0
  75. faden_cli/core/installation.py +186 -0
  76. faden_cli/core/local_node.py +719 -0
  77. faden_cli/core/maintenance.py +82 -0
  78. faden_cli/core/private_files.py +133 -0
  79. faden_cli/core/registry.py +574 -0
  80. faden_cli/core/release_feed.py +342 -0
  81. faden_cli/core/rmq.py +135 -0
  82. faden_cli/core/runtime_images.py +309 -0
  83. faden_cli/core/schedule.py +278 -0
  84. faden_cli/core/settings.py +159 -0
  85. faden_cli/core/sysinfo.py +303 -0
  86. faden_cli/core/tls.py +790 -0
  87. faden_cli/core/tune.py +154 -0
  88. faden_cli/core/update_check.py +254 -0
  89. faden_cli/core/workspace.py +155 -0
  90. faden_cli/templates/env.j2 +33 -0
  91. faden_cli/tui/__init__.py +1 -0
  92. faden_cli/tui/widgets/__init__.py +1 -0
  93. faden_cli/tui/wizard/__init__.py +1 -0
  94. fadenstack-0.4.2.dist-info/METADATA +304 -0
  95. fadenstack-0.4.2.dist-info/RECORD +99 -0
  96. fadenstack-0.4.2.dist-info/WHEEL +4 -0
  97. fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
  98. fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
  99. fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
faden_cli/core/tls.py ADDED
@@ -0,0 +1,790 @@
1
+ """The certificate nginx serves HTTPS with: made on this machine, or brought by the operator.
2
+
3
+ nginx serves HTTPS whenever ``<install>/tls`` holds ``edge.crt`` and
4
+ ``edge.key`` (``nginx/select-config.sh``), with ``edge-chain.pem`` sent after
5
+ the certificate when present. The backend's TLS admin API writes the same
6
+ three files, so whichever wrote last is what nginx serves.
7
+
8
+ A certificate made here is signed by a small certificate authority of this
9
+ install rather than by itself. Browsers, API clients and the agents on other
10
+ machines are told to trust the authority once (``tls/ca.crt``); a new
11
+ certificate -- another address, a renewal -- then needs nothing new on their
12
+ side. The authority's key stays in ``<install>/tls-ca``, which no container
13
+ mounts.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import ipaddress
20
+ import os
21
+ import socket
22
+ from collections.abc import Iterable
23
+ from dataclasses import dataclass, field
24
+ from datetime import UTC, datetime, timedelta
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING:
29
+ from cryptography import x509
30
+ from cryptography.hazmat.primitives.asymmetric.types import CertificateIssuerPrivateKeyTypes
31
+
32
+ TLS_DIR = "tls"
33
+ CERT_FILE = "edge.crt"
34
+ KEY_FILE = "edge.key"
35
+ CHAIN_FILE = "edge-chain.pem"
36
+ #: The authority's certificate, next to the material: what everyone is told to trust.
37
+ CA_PUBLIC_FILE = "ca.crt"
38
+ #: The authority's own files, outside every container mount.
39
+ CA_DIR = "tls-ca"
40
+ CA_CERT_FILE = "ca.crt"
41
+ CA_KEY_FILE = "ca.key"
42
+ #: The authority before the last rotation, published beside the new one while
43
+ #: certificates it signed may still be served.
44
+ CA_PREVIOUS_FILE = "ca-previous.crt"
45
+ #: The operator's own issuers (``tls use --ca``), published to the machines too.
46
+ ROOTS_FILE = "roots.pem"
47
+ #: Where the operator's certificate came from, so ``tls renew`` can take the renewed one.
48
+ SOURCE_FILE = "source.json"
49
+ #: The operator chose plain HTTP (``faden tls off``, ``deploy --tls off``): deploy keeps it.
50
+ OFF_CHOICE_FILE = "https-off"
51
+ #: A certificate made here is re-issued this many days before it runs out.
52
+ RENEW_BEFORE_DAYS = 30
53
+
54
+ #: Apple's limit for certificates under an authority the user added; the others accept it.
55
+ LEAF_DAYS = 825
56
+ CA_DAYS = 3650
57
+ #: A certificate brought along that runs out sooner than this gets a warning.
58
+ EXPIRY_WARNING_DAYS = 30
59
+ _MAX_PEM = 64 * 1024
60
+
61
+ # Interfaces whose addresses no other machine reaches this one by (loopback
62
+ # goes by its address: "lo" as a prefix would also match "Local Area Connection").
63
+ _VIRTUAL_INTERFACES = ("docker", "br-", "veth", "virbr", "cni", "flannel", "vethernet")
64
+
65
+
66
+ class TlsError(Exception):
67
+ """The material cannot be used; the message says why and what to do."""
68
+
69
+
70
+ @dataclass
71
+ class CertSummary:
72
+ """What an operator needs to know about a certificate."""
73
+
74
+ subject: str
75
+ issuer: str
76
+ names: list[str]
77
+ not_before: datetime
78
+ not_after: datetime
79
+ fingerprint_sha256: str
80
+ self_signed: bool
81
+ made_here: bool = False
82
+ warnings: list[str] = field(default_factory=list)
83
+ #: The machines have a new issuer to fetch before this certificate is served.
84
+ trust_changed: bool = False
85
+
86
+
87
+ # ── names ─────────────────────────────────────────────────────────
88
+
89
+
90
+ def machine_names() -> list[str]:
91
+ """The names and addresses this machine is reached by, loopback first.
92
+
93
+ Docker's bridges and other virtual interfaces are left out: no other
94
+ machine reaches the console through them.
95
+ """
96
+ import psutil # noqa: PLC0415
97
+
98
+ names = ["localhost", "127.0.0.1", "::1"]
99
+ host = socket.gethostname()
100
+ for candidate in (host, socket.getfqdn()):
101
+ if candidate and candidate not in names and candidate != "localhost":
102
+ names.append(candidate)
103
+ try:
104
+ interfaces = psutil.net_if_addrs()
105
+ except Exception: # never block on inventory
106
+ interfaces = {}
107
+ for name, addresses in interfaces.items():
108
+ if name.lower().startswith(_VIRTUAL_INTERFACES):
109
+ continue
110
+ for address in addresses:
111
+ if address.family != socket.AF_INET:
112
+ continue
113
+ ip = ipaddress.ip_address(address.address)
114
+ if ip.is_loopback or ip.is_link_local:
115
+ continue
116
+ if address.address not in names:
117
+ names.append(address.address)
118
+ return names
119
+
120
+
121
+ def _general_name(name: str) -> x509.GeneralName:
122
+ from cryptography import x509 # noqa: PLC0415
123
+
124
+ try:
125
+ return x509.IPAddress(ipaddress.ip_address(name))
126
+ except ValueError:
127
+ return x509.DNSName(name)
128
+
129
+
130
+ def _cert_names(cert: x509.Certificate) -> list[str]:
131
+ from cryptography import x509 # noqa: PLC0415
132
+ from cryptography.x509.oid import ExtensionOID # noqa: PLC0415
133
+
134
+ try:
135
+ ext = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
136
+ except x509.ExtensionNotFound:
137
+ return []
138
+ return [str(value.value) for value in ext.value if hasattr(value, "value")]
139
+
140
+
141
+ def covers(cert_names: Iterable[str], name: str) -> bool:
142
+ """Whether a certificate listing *cert_names* is valid for *name* (one-label wildcards)."""
143
+ wanted = name.lower().rstrip(".")
144
+ for entry in cert_names:
145
+ listed = entry.lower().rstrip(".")
146
+ if listed == wanted:
147
+ return True
148
+ wildcard = listed.startswith("*.") and "." in wanted and not _is_ip(wanted)
149
+ if wildcard and wanted.split(".", 1)[1] == listed[2:]:
150
+ return True
151
+ return False
152
+
153
+
154
+ def _is_ip(name: str) -> bool:
155
+ try:
156
+ ipaddress.ip_address(name)
157
+ except ValueError:
158
+ return False
159
+ return True
160
+
161
+
162
+ # ── reading and summarising ───────────────────────────────────────
163
+
164
+
165
+ def _pem_blocks(text: str, kind: str = "CERTIFICATE") -> list[str]:
166
+ begin, end = f"-----BEGIN {kind}-----", f"-----END {kind}-----"
167
+ blocks: list[str] = []
168
+ rest = text
169
+ while begin in rest:
170
+ start = rest.index(begin)
171
+ stop = rest.find(end, start)
172
+ if stop < 0:
173
+ break
174
+ blocks.append(rest[start : stop + len(end)] + "\n")
175
+ rest = rest[stop + len(end) :]
176
+ return blocks
177
+
178
+
179
+ def summarise(cert: x509.Certificate, *, made_here: bool = False) -> CertSummary:
180
+ """What :class:`CertSummary` shows of *cert*."""
181
+ from cryptography.hazmat.primitives import hashes # noqa: PLC0415
182
+
183
+ return CertSummary(
184
+ subject=cert.subject.rfc4514_string(),
185
+ issuer=cert.issuer.rfc4514_string(),
186
+ names=_cert_names(cert),
187
+ not_before=cert.not_valid_before_utc,
188
+ not_after=cert.not_valid_after_utc,
189
+ fingerprint_sha256=cert.fingerprint(hashes.SHA256()).hex(),
190
+ self_signed=cert.subject == cert.issuer,
191
+ made_here=made_here,
192
+ )
193
+
194
+
195
+ def material_present(install_dir: Path) -> bool:
196
+ """Whether the install holds what nginx serves HTTPS with."""
197
+ tls = Path(install_dir) / TLS_DIR
198
+ return (tls / CERT_FILE).is_file() and (tls / KEY_FILE).is_file()
199
+
200
+
201
+ def current(install_dir: Path) -> CertSummary | None:
202
+ """The certificate nginx serves, or ``None`` while the install is plain HTTP."""
203
+ from cryptography import x509 # noqa: PLC0415
204
+
205
+ install_dir = Path(install_dir)
206
+ if not material_present(install_dir):
207
+ return None
208
+ try:
209
+ cert = x509.load_pem_x509_certificate((install_dir / TLS_DIR / CERT_FILE).read_bytes())
210
+ except (ValueError, OSError) as exc:
211
+ raise TlsError(f"{install_dir / TLS_DIR / CERT_FILE} cannot be read: {exc}") from exc
212
+ return summarise(cert, made_here=_issued_by_our_ca(install_dir, cert))
213
+
214
+
215
+ def _issued_by_our_ca(install_dir: Path, cert: x509.Certificate) -> bool:
216
+ """Signed by the install's authority, the current one or the one before a rotation."""
217
+ for name in (CA_CERT_FILE, CA_PREVIOUS_FILE):
218
+ for ca in _valid_certs(install_dir / CA_DIR / name, datetime.now(UTC)):
219
+ try:
220
+ cert.verify_directly_issued_by(ca)
221
+ except Exception: # noqa: S112 - InvalidSignature, another key type: not this issuer
222
+ continue
223
+ return True
224
+ return False
225
+
226
+
227
+ def _issued_by_current_ca(install_dir: Path, cert: x509.Certificate) -> bool:
228
+ for ca in _valid_certs(install_dir / CA_DIR / CA_CERT_FILE, datetime.now(UTC)):
229
+ try:
230
+ cert.verify_directly_issued_by(ca)
231
+ except Exception: # noqa: S112 - InvalidSignature, another key type: not this issuer
232
+ continue
233
+ return True
234
+ return False
235
+
236
+
237
+ # ── writing ───────────────────────────────────────────────────────
238
+
239
+
240
+ def _write(path: Path, data: bytes, *, mode: int) -> None:
241
+ """Write *data* with *mode* from the start, so a key is never readable by others."""
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ tmp = path.with_name(path.name + ".tmp")
244
+ # O_BINARY: Windows' C runtime would otherwise turn every newline into CRLF.
245
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
246
+ fd = os.open(str(tmp), flags, mode)
247
+ try:
248
+ os.write(fd, data)
249
+ finally:
250
+ os.close(fd)
251
+ tmp.chmod(mode)
252
+ tmp.replace(path)
253
+
254
+
255
+ def chose_off(install_dir: Path) -> bool:
256
+ """Whether the operator switched HTTPS off on purpose (so ``deploy --yes`` keeps it off)."""
257
+ return (Path(install_dir) / TLS_DIR / OFF_CHOICE_FILE).is_file()
258
+
259
+
260
+ def remember_off(install_dir: Path) -> None:
261
+ """Record that plain HTTP is the operator's choice, not a default nobody changed."""
262
+ tls = Path(install_dir) / TLS_DIR
263
+ tls.mkdir(parents=True, exist_ok=True)
264
+ note = b"HTTPS was switched off on purpose; faden deploy keeps it off.\n"
265
+ _write(tls / OFF_CHOICE_FILE, note, mode=0o644)
266
+
267
+
268
+ def _install(install_dir: Path, cert_pem: bytes, key_pem: bytes, chain_pem: bytes | None) -> None:
269
+ tls = Path(install_dir) / TLS_DIR
270
+ (tls / OFF_CHOICE_FILE).unlink(missing_ok=True)
271
+ # The key last: nginx switches on the pair, so it must never find a key
272
+ # next to the previous certificate.
273
+ _write(tls / CERT_FILE, cert_pem, mode=0o644)
274
+ if chain_pem:
275
+ _write(tls / CHAIN_FILE, chain_pem, mode=0o644)
276
+ else:
277
+ (tls / CHAIN_FILE).unlink(missing_ok=True)
278
+ _write(tls / KEY_FILE, key_pem, mode=0o600)
279
+
280
+
281
+ def remove(install_dir: Path) -> bool:
282
+ """Take the material away, so nginx goes back to plain HTTP; ``True`` if there was any.
283
+
284
+ The authority stays: switched on again, a new certificate is trusted
285
+ wherever the old one was.
286
+ """
287
+ tls = Path(install_dir) / TLS_DIR
288
+ had = material_present(install_dir)
289
+ for name in (KEY_FILE, CERT_FILE, CHAIN_FILE):
290
+ (tls / name).unlink(missing_ok=True)
291
+ return had
292
+
293
+
294
+ # ── made here ─────────────────────────────────────────────────────
295
+
296
+
297
+ def _load_or_make_ca(
298
+ install_dir: Path, now: datetime
299
+ ) -> tuple[x509.Certificate, CertificateIssuerPrivateKeyTypes, bool]:
300
+ """This install's authority, made on first use; ``(cert, key, created)``."""
301
+ from cryptography import x509 # noqa: PLC0415
302
+ from cryptography.hazmat.primitives import hashes, serialization # noqa: PLC0415
303
+ from cryptography.hazmat.primitives.asymmetric import ec # noqa: PLC0415
304
+ from cryptography.x509.oid import NameOID # noqa: PLC0415
305
+
306
+ ca_dir = install_dir / CA_DIR
307
+ cert_path, key_path = ca_dir / CA_CERT_FILE, ca_dir / CA_KEY_FILE
308
+ if cert_path.is_file() and key_path.is_file():
309
+ try:
310
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
311
+ key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
312
+ if not isinstance(key, ec.EllipticCurvePrivateKey):
313
+ raise TypeError("not the EC key this CLI makes")
314
+ except (ValueError, TypeError, OSError) as exc:
315
+ raise TlsError(
316
+ f"The certificate authority in {ca_dir} cannot be read ({exc}). Move the directory "
317
+ "away to make a new one; everything that trusted the old one must then trust the new."
318
+ ) from exc
319
+ # Reused while it outlives the certificate it is about to sign.
320
+ if cert.not_valid_after_utc > now + timedelta(days=LEAF_DAYS + 1):
321
+ return cert, key, False
322
+ # Rotated: the old one stays published while what it signed may be served.
323
+ _write(ca_dir / CA_PREVIOUS_FILE, cert_path.read_bytes(), mode=0o644)
324
+
325
+ key = ec.generate_private_key(ec.SECP256R1())
326
+ name = x509.Name([
327
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Fadenstack"),
328
+ x509.NameAttribute(NameOID.COMMON_NAME, f"Fadenstack CA on {socket.gethostname()}"),
329
+ ])
330
+ public = key.public_key()
331
+ cert = (
332
+ x509.CertificateBuilder()
333
+ .subject_name(name)
334
+ .issuer_name(name)
335
+ .public_key(public)
336
+ .serial_number(x509.random_serial_number())
337
+ .not_valid_before(now - timedelta(minutes=5))
338
+ .not_valid_after(now + timedelta(days=CA_DAYS))
339
+ .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
340
+ .add_extension(
341
+ x509.KeyUsage(
342
+ digital_signature=True, content_commitment=False, key_encipherment=False,
343
+ data_encipherment=False, key_agreement=False, key_cert_sign=True, crl_sign=True,
344
+ encipher_only=False, decipher_only=False,
345
+ ),
346
+ critical=True,
347
+ )
348
+ .add_extension(x509.SubjectKeyIdentifier.from_public_key(public), critical=False)
349
+ .sign(key, hashes.SHA256())
350
+ )
351
+ ca_dir.mkdir(parents=True, exist_ok=True)
352
+ with contextlib.suppress(OSError): # Windows has no such bits
353
+ ca_dir.chmod(0o700)
354
+ _write(
355
+ key_path,
356
+ key.private_bytes(
357
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
358
+ ),
359
+ mode=0o600,
360
+ )
361
+ _write(cert_path, cert.public_bytes(serialization.Encoding.PEM), mode=0o644)
362
+ return cert, key, True
363
+
364
+
365
+ def _valid_certs(path: Path, now: datetime) -> list[x509.Certificate]:
366
+ from cryptography import x509 # noqa: PLC0415
367
+
368
+ if not path.is_file():
369
+ return []
370
+ try:
371
+ certs = x509.load_pem_x509_certificates(path.read_bytes())
372
+ except ValueError:
373
+ return []
374
+ return [c for c in certs if c.not_valid_after_utc > now]
375
+
376
+
377
+ def publish_trust(install_dir: Path, *, now: datetime | None = None) -> tuple[Path, bool]:
378
+ """Write what the machines are to trust into ``tls/ca.crt``: ``(path, changed)``.
379
+
380
+ The install's authority, the one before it while still valid (a rotation
381
+ never leaves a served certificate without its issuer), and the operator's
382
+ own issuers. The backend hands this file to every machine as it is.
383
+ """
384
+ from cryptography.hazmat.primitives import serialization # noqa: PLC0415
385
+
386
+ install_dir = Path(install_dir)
387
+ now = now or datetime.now(UTC)
388
+ ca_dir = install_dir / CA_DIR
389
+ seen: set[bytes] = set()
390
+ parts: list[bytes] = []
391
+ for name in (CA_CERT_FILE, CA_PREVIOUS_FILE, ROOTS_FILE):
392
+ for cert in _valid_certs(ca_dir / name, now):
393
+ der = cert.public_bytes(serialization.Encoding.DER)
394
+ if der not in seen:
395
+ seen.add(der)
396
+ parts.append(cert.public_bytes(serialization.Encoding.PEM))
397
+ public = install_dir / TLS_DIR / CA_PUBLIC_FILE
398
+ data = b"".join(parts)
399
+ if not data:
400
+ public.unlink(missing_ok=True)
401
+ return public, False
402
+ if public.is_file() and public.read_bytes() == data:
403
+ return public, False
404
+ _write(public, data, mode=0o644)
405
+ return public, True
406
+
407
+
408
+ def ensure_ca(install_dir: Path, *, now: datetime | None = None) -> tuple[Path, bool]:
409
+ """The install's authority, made when missing, published in ``tls/``: ``(path, changed)``.
410
+
411
+ Deploy and upgrade call this with HTTPS still off. The backend offers the
412
+ authority to every machine (``/api/node-files/server-trust``), so they
413
+ hold it before a certificate it signed is served: once HTTPS is on, the
414
+ plain-HTTP address only redirects to what they could not check otherwise.
415
+ ``changed`` says the machines have something new to fetch.
416
+ """
417
+ install_dir = Path(install_dir)
418
+ now = now or datetime.now(UTC)
419
+ _load_or_make_ca(install_dir, now)
420
+ return publish_trust(install_dir, now=now)
421
+
422
+
423
+ @dataclass
424
+ class Generated:
425
+ """A certificate made here: what it is, and the authority to trust for it."""
426
+
427
+ summary: CertSummary
428
+ ca_public: Path
429
+ #: The machines have a new authority to fetch before this certificate is served.
430
+ trust_changed: bool
431
+
432
+
433
+ def generate(install_dir: Path, extra_names: Iterable[str] = (), *, now: datetime | None = None) -> Generated:
434
+ """Make a certificate for this machine, signed by the install's authority, and install it.
435
+
436
+ It names ``localhost``, the machine's host names and every address it
437
+ has outside Docker's bridges, plus *extra_names* -- a DNS name or an
438
+ address the console is also reached by.
439
+ """
440
+ from cryptography import x509 # noqa: PLC0415
441
+ from cryptography.hazmat.primitives import hashes, serialization # noqa: PLC0415
442
+ from cryptography.hazmat.primitives.asymmetric import ec # noqa: PLC0415
443
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID # noqa: PLC0415
444
+
445
+ install_dir = Path(install_dir)
446
+ now = now or datetime.now(UTC)
447
+ names = machine_names()
448
+ for raw in extra_names:
449
+ extra = raw.strip()
450
+ if not extra:
451
+ continue
452
+ with_port = ":" in extra and not _is_ip(extra) # IPv6 addresses have colons of their own
453
+ if extra.startswith(("http://", "https://")) or "/" in extra or " " in extra or with_port:
454
+ raise TlsError(f"{extra!r} is not a host name or an address (no scheme, port or path).")
455
+ if extra not in names:
456
+ names.append(extra)
457
+
458
+ ca_cert, ca_key, _created = _load_or_make_ca(install_dir, now)
459
+ key = ec.generate_private_key(ec.SECP256R1())
460
+ public = key.public_key()
461
+ loopback = ("localhost", "127.0.0.1", "::1")
462
+ primary = next((n for n in names if n not in loopback and not _is_ip(n)), "localhost")
463
+ cert = (
464
+ x509.CertificateBuilder()
465
+ .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, primary)]))
466
+ .issuer_name(ca_cert.subject)
467
+ .public_key(public)
468
+ .serial_number(x509.random_serial_number())
469
+ .not_valid_before(now - timedelta(minutes=5))
470
+ .not_valid_after(now + timedelta(days=LEAF_DAYS))
471
+ .add_extension(x509.SubjectAlternativeName([_general_name(n) for n in names]), critical=False)
472
+ .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
473
+ .add_extension(
474
+ x509.KeyUsage(
475
+ digital_signature=True, content_commitment=False, key_encipherment=False,
476
+ data_encipherment=False, key_agreement=False, key_cert_sign=False, crl_sign=False,
477
+ encipher_only=False, decipher_only=False,
478
+ ),
479
+ critical=True,
480
+ )
481
+ .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
482
+ .add_extension(x509.SubjectKeyIdentifier.from_public_key(public), critical=False)
483
+ .add_extension(
484
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False
485
+ )
486
+ .sign(ca_key, hashes.SHA256())
487
+ )
488
+ _install(
489
+ install_dir,
490
+ cert.public_bytes(serialization.Encoding.PEM),
491
+ key.private_bytes(
492
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
493
+ ),
494
+ None,
495
+ )
496
+ # The served certificate is made here now: nothing to take from the operator's source.
497
+ (install_dir / CA_DIR / SOURCE_FILE).unlink(missing_ok=True)
498
+ ca_public, changed = publish_trust(install_dir, now=now)
499
+ return Generated(summary=summarise(cert, made_here=True), ca_public=ca_public, trust_changed=changed)
500
+
501
+
502
+ # ── brought by the operator ───────────────────────────────────────
503
+
504
+
505
+ def _read_pem(path: Path, label: str) -> str:
506
+ path = Path(path).expanduser()
507
+ if not path.is_file():
508
+ raise TlsError(f"{label} {path} does not exist.")
509
+ raw = path.read_bytes()
510
+ if len(raw) > _MAX_PEM:
511
+ raise TlsError(f"{label} {path} is larger than {_MAX_PEM // 1024} KB; that is not a PEM file.")
512
+ try:
513
+ text = raw.decode("utf-8")
514
+ except UnicodeDecodeError as exc:
515
+ raise TlsError(f"{label} {path} is not PEM text (DER? convert it with openssl first).") from exc
516
+ if "-----BEGIN " not in text:
517
+ raise TlsError(f"{label} {path} is not PEM-encoded.")
518
+ return text
519
+
520
+
521
+ def _chains_to(leaf: x509.Certificate, chain: list[x509.Certificate], roots: list[x509.Certificate]) -> bool:
522
+ """Whether *leaf*, through *chain*, was issued by one of *roots*."""
523
+ current = leaf
524
+ for _ in range(len(chain) + 1):
525
+ for root in roots:
526
+ try:
527
+ current.verify_directly_issued_by(root)
528
+ except Exception: # noqa: S112 - InvalidSignature, another key type: not this issuer
529
+ continue
530
+ return True
531
+ issuer = next((c for c in chain if c.subject == current.issuer and c is not current), None)
532
+ if issuer is None:
533
+ return False
534
+ current = issuer
535
+ return False
536
+
537
+
538
+ def _sha256_file(path: Path) -> str | None:
539
+ import hashlib # noqa: PLC0415
540
+
541
+ try:
542
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
543
+ except OSError:
544
+ return None
545
+
546
+
547
+ def source(install_dir: Path) -> dict[str, str | None] | None:
548
+ """Where the served certificate was imported from, when it was the operator's."""
549
+ import json # noqa: PLC0415
550
+
551
+ try:
552
+ data = json.loads((Path(install_dir) / CA_DIR / SOURCE_FILE).read_text(encoding="utf-8"))
553
+ except (OSError, ValueError):
554
+ return None
555
+ return data if isinstance(data, dict) and data.get("cert") and data.get("key") else None
556
+
557
+
558
+ def use_own(
559
+ install_dir: Path,
560
+ cert_path: Path,
561
+ key_path: Path,
562
+ chain_path: Path | None = None,
563
+ *,
564
+ ca_path: Path | None = None,
565
+ now: datetime | None = None,
566
+ ) -> CertSummary:
567
+ """Check the operator's certificate and key, then install them.
568
+
569
+ The certificate file may carry its chain after it (a "fullchain" file);
570
+ a separate *chain_path* is added after that. Problems that make nginx or
571
+ every client refuse the pair raise :class:`TlsError`; a certificate that
572
+ does not name the addresses of this machine only warns, since the console
573
+ may well be reached by a name only DNS knows.
574
+
575
+ A private issuer -- *ca_path*, or a self-signed root the chain ends in --
576
+ is published to the machines with the install's own authority, so they
577
+ trust the certificate without anyone copying files to them. Where the
578
+ files came from is kept, so ``tls renew`` takes the renewed ones from
579
+ there (certbot, acme.sh and corporate tooling renew in place).
580
+ """
581
+ import json # noqa: PLC0415
582
+
583
+ from cryptography import x509 # noqa: PLC0415
584
+ from cryptography.hazmat.primitives import serialization # noqa: PLC0415
585
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa # noqa: PLC0415
586
+
587
+ now = now or datetime.now(UTC)
588
+ cert_text = _read_pem(cert_path, "Certificate")
589
+ key_text = _read_pem(key_path, "Key")
590
+ blocks = _pem_blocks(cert_text)
591
+ if not blocks:
592
+ raise TlsError(f"Certificate {cert_path} holds no CERTIFICATE block.")
593
+ chain_blocks = blocks[1:]
594
+ if chain_path is not None:
595
+ chain_blocks += _pem_blocks(_read_pem(chain_path, "Chain"))
596
+
597
+ try:
598
+ cert = x509.load_pem_x509_certificate(blocks[0].encode())
599
+ except ValueError as exc:
600
+ raise TlsError(f"Certificate {cert_path} cannot be read: {exc}") from exc
601
+ try:
602
+ key = serialization.load_pem_private_key(key_text.encode(), password=None)
603
+ except TypeError as exc:
604
+ raise TlsError(
605
+ f"Key {key_path} is protected by a passphrase, which nginx cannot ask for. "
606
+ "Write it out without one, for example: openssl pkey -in <key> -out <key>.plain"
607
+ ) from exc
608
+ except ValueError as exc:
609
+ raise TlsError(f"Key {key_path} cannot be read: {exc}") from exc
610
+ if not isinstance(key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
611
+ raise TlsError("Only RSA and EC keys are served; browsers do not accept the others.")
612
+
613
+ der = serialization.Encoding.DER
614
+ spki = serialization.PublicFormat.SubjectPublicKeyInfo
615
+ if cert.public_key().public_bytes(der, spki) != key.public_key().public_bytes(der, spki):
616
+ raise TlsError("The key does not belong to the certificate.")
617
+ if cert.not_valid_before_utc > now:
618
+ raise TlsError(f"The certificate is not valid before {cert.not_valid_before_utc:%Y-%m-%d %H:%M} UTC.")
619
+ if cert.not_valid_after_utc < now:
620
+ raise TlsError(f"The certificate expired on {cert.not_valid_after_utc:%Y-%m-%d}.")
621
+ chain: list[x509.Certificate] = []
622
+ for index, block in enumerate(chain_blocks, start=1):
623
+ try:
624
+ chain.append(x509.load_pem_x509_certificate(block.encode()))
625
+ except ValueError as exc:
626
+ raise TlsError(f"Chain certificate #{index} cannot be read: {exc}") from exc
627
+
628
+ # The issuer the machines are to trust: given, or the root the chain ends in.
629
+ roots: list[x509.Certificate] = []
630
+ if ca_path is not None:
631
+ try:
632
+ roots = x509.load_pem_x509_certificates(_read_pem(ca_path, "Authority").encode())
633
+ except ValueError as exc:
634
+ raise TlsError(f"Authority {ca_path} cannot be read: {exc}") from exc
635
+ if not _chains_to(cert, chain, roots):
636
+ msg = f"The certificate was not issued by {ca_path}, through the chain given or directly."
637
+ raise TlsError(msg)
638
+ elif chain and chain[-1].subject == chain[-1].issuer and chain[-1] is not cert:
639
+ roots = [chain[-1]]
640
+
641
+ summary = summarise(cert)
642
+ reachable = [n for n in machine_names() if n not in ("localhost", "127.0.0.1", "::1")]
643
+ if reachable and not any(covers(summary.names, n) for n in reachable):
644
+ summary.warnings.append(
645
+ "It names none of this machine's own names or addresses "
646
+ f"({', '.join(reachable)}); clients must reach the console by a name it lists: "
647
+ f"{', '.join(summary.names) or 'it lists none'}."
648
+ )
649
+ if summary.self_signed:
650
+ summary.warnings.append("It is self-signed: every browser and client has to be told to trust it.")
651
+ if (summary.not_after - now).days < EXPIRY_WARNING_DAYS:
652
+ summary.warnings.append(f"It expires on {summary.not_after:%Y-%m-%d}.")
653
+
654
+ install_dir = Path(install_dir)
655
+ key_pem = key.private_bytes(
656
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
657
+ )
658
+ _install(
659
+ install_dir,
660
+ blocks[0].encode(),
661
+ key_pem,
662
+ "".join(chain_blocks).encode() if chain_blocks else None,
663
+ )
664
+ roots_file = install_dir / CA_DIR / ROOTS_FILE
665
+ if roots:
666
+ _write(roots_file, b"".join(r.public_bytes(serialization.Encoding.PEM) for r in roots), mode=0o644)
667
+ else:
668
+ roots_file.unlink(missing_ok=True)
669
+ absolute = {
670
+ "cert": str(Path(cert_path).expanduser().resolve()),
671
+ "key": str(Path(key_path).expanduser().resolve()),
672
+ "chain": str(Path(chain_path).expanduser().resolve()) if chain_path else None,
673
+ "ca": str(Path(ca_path).expanduser().resolve()) if ca_path else None,
674
+ }
675
+ _write(
676
+ install_dir / CA_DIR / SOURCE_FILE,
677
+ json.dumps({**absolute, "cert_sha256": _sha256_file(Path(absolute["cert"]))}, indent=1).encode(),
678
+ mode=0o600,
679
+ )
680
+ _, summary.trust_changed = publish_trust(install_dir, now=now)
681
+ return summary
682
+
683
+
684
+ # ── renewing ──────────────────────────────────────────────────────
685
+
686
+
687
+ @dataclass
688
+ class Renewal:
689
+ """What ``tls renew`` did, or why it did nothing."""
690
+
691
+ #: off | current | renewed | reimported | expiring
692
+ action: str
693
+ message: str
694
+ summary: CertSummary | None = None
695
+ trust_changed: bool = False
696
+
697
+
698
+ def renew(
699
+ install_dir: Path, *, days: int = RENEW_BEFORE_DAYS, force: bool = False, now: datetime | None = None
700
+ ) -> Renewal:
701
+ """Keep the served certificate current; safe to run every day.
702
+
703
+ * Made here: issued again, by the same authority, ``days`` before it runs
704
+ out -- or when this machine has an address it does not list, or it was
705
+ signed by the authority before a rotation. It keeps every name it had.
706
+ Nothing changes for the machines or the browsers that trust the authority.
707
+ * The operator's own: taken again from the files it was imported from when
708
+ they changed there (a renewal by certbot and the like), after the same
709
+ checks as the first time. Otherwise a warning once it is close to its end.
710
+ """
711
+ install_dir = Path(install_dir)
712
+ now = now or datetime.now(UTC)
713
+ served = current(install_dir)
714
+ if served is None:
715
+ return Renewal("off", "HTTPS is off; nothing to renew.")
716
+ left = (served.not_after - now).days
717
+
718
+ if served.made_here:
719
+ reachable = [n for n in machine_names() if n not in ("localhost", "127.0.0.1", "::1")]
720
+ missing = [n for n in reachable if not covers(served.names, n)]
721
+ from cryptography import x509 # noqa: PLC0415
722
+
723
+ cert = x509.load_pem_x509_certificate((install_dir / TLS_DIR / CERT_FILE).read_bytes())
724
+ stale_issuer = not _issued_by_current_ca(install_dir, cert)
725
+ if not (force or left < days or missing or stale_issuer):
726
+ return Renewal("current", f"Made here, valid for {left} more days.", served)
727
+ why = (
728
+ "as asked" if force
729
+ else f"it had {left} days left" if left < days
730
+ else f"it did not list {', '.join(missing)}" if missing
731
+ else "the authority was renewed"
732
+ )
733
+ made = generate(install_dir, served.names, now=now)
734
+ return Renewal("renewed", f"Issued again ({why}).", made.summary, made.trust_changed)
735
+
736
+ origin = source(install_dir)
737
+ if origin is not None:
738
+ changed = _sha256_file(Path(str(origin["cert"]))) != origin.get("cert_sha256")
739
+ if force or changed:
740
+ try:
741
+ summary = use_own(
742
+ install_dir,
743
+ Path(str(origin["cert"])),
744
+ Path(str(origin["key"])),
745
+ Path(str(origin["chain"])) if origin.get("chain") else None,
746
+ ca_path=Path(str(origin["ca"])) if origin.get("ca") else None,
747
+ now=now,
748
+ )
749
+ except TlsError as exc:
750
+ why = f"The files at {origin['cert']} changed but cannot be used: {exc}"
751
+ return Renewal("expiring", why, served)
752
+ taken = f"Taken again from {origin['cert']}."
753
+ return Renewal("reimported", taken, summary, summary.trust_changed)
754
+ if left < days:
755
+ return Renewal(
756
+ "expiring",
757
+ f"It runs out in {left} days and {origin['cert']} still holds the same one: "
758
+ "renew it there, and the next run takes it.",
759
+ served,
760
+ )
761
+ return Renewal(
762
+ "current", f"Valid for {left} more days; taken from {origin['cert']} when it changes.", served
763
+ )
764
+
765
+ if left < days:
766
+ return Renewal(
767
+ "expiring",
768
+ f"It runs out in {left} days. Install the renewed one: faden tls use CERT KEY.",
769
+ served,
770
+ )
771
+ return Renewal("current", f"Valid for {left} more days.", served)
772
+
773
+
774
+ # ── what the backend is told about the front door ─────────────────
775
+
776
+
777
+ def write_edge_info(install_dir: Path, *, http_port: int, https_port: int, renews: str) -> None:
778
+ """``tls/edge.json``: this host's addresses and ports, for the backend in its container.
779
+
780
+ The backend sees only the container's own interfaces, so the addresses it
781
+ offered a machine being added were ones no other machine could use.
782
+ """
783
+ import json # noqa: PLC0415
784
+
785
+ hosts = [n for n in machine_names() if n not in ("localhost", "127.0.0.1", "::1")]
786
+ info = {"hosts": hosts, "http_port": http_port, "https_port": https_port, "renews": renews}
787
+ path = Path(install_dir) / TLS_DIR / "edge.json"
788
+ data = (json.dumps(info, indent=1) + "\n").encode()
789
+ if not path.is_file() or path.read_bytes() != data:
790
+ _write(path, data, mode=0o644)