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.
- faden_cli/__init__.py +16 -0
- faden_cli/__main__.py +9 -0
- faden_cli/bundle/shared/.gitignore +25 -0
- faden_cli/bundle/shared/LICENSE +187 -0
- faden_cli/bundle/shared/NOTICE +15 -0
- faden_cli/bundle/shared/OFFICIAL.md +9 -0
- faden_cli/bundle/shared/README.md +16 -0
- faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
- faden_cli/bundle/shared/alloy/config.alloy +40 -0
- faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
- faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
- faden_cli/bundle/shared/docker-compose.yaml +998 -0
- faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
- faden_cli/bundle/shared/initdb/01-init.sql +32 -0
- faden_cli/bundle/shared/loki/local-config.yaml +32 -0
- faden_cli/bundle/shared/nginx/locations.conf +245 -0
- faden_cli/bundle/shared/nginx/maintenance.html +239 -0
- faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
- faden_cli/bundle/shared/nginx/nginx.conf +44 -0
- faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
- faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
- faden_cli/bundle/shared/nginx/select-config.sh +37 -0
- faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
- faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
- faden_cli/bundle/shared/tls/README.md +30 -0
- faden_cli/cli.py +158 -0
- faden_cli/commands/__init__.py +1 -0
- faden_cli/commands/admin.py +161 -0
- faden_cli/commands/backup.py +159 -0
- faden_cli/commands/config.py +146 -0
- faden_cli/commands/deploy.py +949 -0
- faden_cli/commands/dev/__init__.py +1 -0
- faden_cli/commands/dev/dev_doctor.py +46 -0
- faden_cli/commands/dev/dev_down.py +241 -0
- faden_cli/commands/dev/dev_group.py +24 -0
- faden_cli/commands/dev/dev_init.py +710 -0
- faden_cli/commands/dev/dev_pull.py +297 -0
- faden_cli/commands/dev/dev_status.py +151 -0
- faden_cli/commands/dev/dev_up.py +1392 -0
- faden_cli/commands/doctor.py +225 -0
- faden_cli/commands/down.py +75 -0
- faden_cli/commands/logs_cmd.py +41 -0
- faden_cli/commands/maintenance.py +74 -0
- faden_cli/commands/module.py +113 -0
- faden_cli/commands/observe.py +95 -0
- faden_cli/commands/ports.py +91 -0
- faden_cli/commands/rebuild.py +158 -0
- faden_cli/commands/restore.py +139 -0
- faden_cli/commands/runtime_images.py +174 -0
- faden_cli/commands/status.py +71 -0
- faden_cli/commands/tls.py +722 -0
- faden_cli/commands/tune.py +37 -0
- faden_cli/commands/up.py +64 -0
- faden_cli/commands/upgrade.py +470 -0
- faden_cli/commands/version.py +78 -0
- faden_cli/core/__init__.py +1 -0
- faden_cli/core/api_client.py +77 -0
- faden_cli/core/backup.py +717 -0
- faden_cli/core/bootstrap.py +291 -0
- faden_cli/core/bundle.py +110 -0
- faden_cli/core/clickhouse.py +61 -0
- faden_cli/core/compose.py +484 -0
- faden_cli/core/console.py +106 -0
- faden_cli/core/detect.py +580 -0
- faden_cli/core/dev_modules.py +149 -0
- faden_cli/core/docker_env.py +147 -0
- faden_cli/core/edge.py +94 -0
- faden_cli/core/env_gen.py +598 -0
- faden_cli/core/git.py +219 -0
- faden_cli/core/install.py +288 -0
- faden_cli/core/installation.py +186 -0
- faden_cli/core/local_node.py +719 -0
- faden_cli/core/maintenance.py +82 -0
- faden_cli/core/private_files.py +133 -0
- faden_cli/core/registry.py +574 -0
- faden_cli/core/release_feed.py +342 -0
- faden_cli/core/rmq.py +135 -0
- faden_cli/core/runtime_images.py +309 -0
- faden_cli/core/schedule.py +278 -0
- faden_cli/core/settings.py +159 -0
- faden_cli/core/sysinfo.py +303 -0
- faden_cli/core/tls.py +790 -0
- faden_cli/core/tune.py +154 -0
- faden_cli/core/update_check.py +254 -0
- faden_cli/core/workspace.py +155 -0
- faden_cli/templates/env.j2 +33 -0
- faden_cli/tui/__init__.py +1 -0
- faden_cli/tui/widgets/__init__.py +1 -0
- faden_cli/tui/wizard/__init__.py +1 -0
- fadenstack-0.4.2.dist-info/METADATA +304 -0
- fadenstack-0.4.2.dist-info/RECORD +99 -0
- fadenstack-0.4.2.dist-info/WHEEL +4 -0
- fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
- fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
- 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)
|