edge_gateway_admin.py 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314
  1. #!/usr/bin/env python3
  2. """Fail-closed local administration for a DataOps enterprise edge gateway."""
  3. from __future__ import annotations
  4. import argparse
  5. import fcntl
  6. import hashlib
  7. import json
  8. import os
  9. import re
  10. import sqlite3
  11. import stat
  12. import sys
  13. import tempfile
  14. from contextlib import contextmanager, suppress
  15. from datetime import UTC, datetime
  16. from decimal import Decimal
  17. from pathlib import Path, PurePosixPath
  18. from cryptography import x509
  19. from cryptography.exceptions import InvalidSignature
  20. from cryptography.hazmat.primitives import hashes, serialization
  21. from cryptography.hazmat.primitives.asymmetric import (
  22. ec,
  23. ed448,
  24. ed25519,
  25. padding,
  26. rsa,
  27. )
  28. from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
  29. MAX_JSON_BYTES = 262_144
  30. MAX_BACKUPS = 3
  31. SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
  32. IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$")
  33. VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})$")
  34. ORIGIN_RE = re.compile(r"^https://[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$")
  35. MANIFEST_FIELDS = frozenset(
  36. {
  37. "release_id", "version", "rollback_version", "artifact_digest",
  38. "artifact_name", "deadline_at", "signature_algorithm", "key_id",
  39. "manifest_digest", "signature", "status",
  40. }
  41. )
  42. class AdminError(RuntimeError):
  43. def __init__(self, code: str, exit_code: int = 2):
  44. super().__init__(code)
  45. self.code = code
  46. self.exit_code = exit_code
  47. def _json_bytes(value: object) -> bytes:
  48. return json.dumps(
  49. value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
  50. ).encode("utf-8")
  51. def _digest(value: object) -> str:
  52. return hashlib.sha256(_json_bytes(value)).hexdigest()
  53. def _protocol_bytes(value: object) -> bytes:
  54. def frame(tag: bytes, content: bytes) -> bytes:
  55. return tag + str(len(content)).encode("ascii") + b":" + content
  56. if value is None:
  57. return b"n"
  58. if isinstance(value, bool):
  59. return b"b1" if value else b"b0"
  60. if isinstance(value, (int, float)):
  61. number = Decimal(value if isinstance(value, int) else str(value))
  62. if not number.is_finite():
  63. raise AdminError("INVALID_INPUT")
  64. text = "0" if number == 0 else format(number.normalize(), "f")
  65. if "." in text:
  66. text = text.rstrip("0").rstrip(".")
  67. return frame(b"d", text.encode("ascii"))
  68. if isinstance(value, str):
  69. return frame(b"s", value.encode("utf-8"))
  70. if isinstance(value, dict):
  71. if any(not isinstance(key, str) for key in value):
  72. raise AdminError("INVALID_INPUT")
  73. encoded = bytearray(b"m" + str(len(value)).encode("ascii") + b":")
  74. for key in sorted(value, key=lambda item: item.encode("utf-8")):
  75. encoded.extend(frame(b"k", key.encode("utf-8")))
  76. encoded.extend(_protocol_bytes(value[key]))
  77. return bytes(encoded)
  78. if isinstance(value, (list, tuple)):
  79. encoded = bytearray(b"l" + str(len(value)).encode("ascii") + b":")
  80. for item in value:
  81. encoded.extend(_protocol_bytes(item))
  82. return bytes(encoded)
  83. raise AdminError("INVALID_INPUT")
  84. def _protocol_digest(value: object) -> str:
  85. return hashlib.sha256(_protocol_bytes(value)).hexdigest()
  86. def _output(value: dict[str, object]) -> None:
  87. rendered = json.dumps(value, ensure_ascii=False, sort_keys=True)
  88. if len(rendered.encode("utf-8")) > 32_768:
  89. raise AdminError("OUTPUT_LIMIT")
  90. print(rendered)
  91. def _root(raw: str, *, create: bool = False) -> Path:
  92. candidate = Path(raw)
  93. if not candidate.is_absolute() or "\x00" in raw:
  94. raise AdminError("INVALID_INPUT")
  95. protected = {
  96. Path("/"), Path.home(), Path("/opt"), Path("/var"), Path("/usr"),
  97. Path("/etc"), Path("/tmp"), Path("/private"), Path("/private/tmp"),
  98. Path("/Users"), Path("/home"), Path("/root"), Path("/Volumes"),
  99. }
  100. normalized = Path(os.path.abspath(candidate))
  101. if normalized in protected:
  102. raise AdminError("PATH_POLICY")
  103. if candidate.exists() and candidate.is_symlink():
  104. raise AdminError("PATH_POLICY")
  105. if create:
  106. with suppress(FileExistsError):
  107. candidate.mkdir(mode=0o700, parents=False, exist_ok=False)
  108. if not candidate.is_dir():
  109. raise AdminError("PATH_POLICY")
  110. resolved = candidate.resolve(strict=True)
  111. if resolved != candidate:
  112. raise AdminError("PATH_POLICY")
  113. return resolved
  114. def _runtime_identity(root: Path) -> tuple[int, int]:
  115. record = _read_json(root / "state" / "runtime-identity.json")
  116. if set(record) != {"runtime_gid", "runtime_uid", "schema_version"}:
  117. raise AdminError("OWNER_POLICY")
  118. uid, gid = record["runtime_uid"], record["runtime_gid"]
  119. if (
  120. isinstance(uid, bool)
  121. or not isinstance(uid, int)
  122. or isinstance(gid, bool)
  123. or not isinstance(gid, int)
  124. or uid < 1
  125. or gid < 1
  126. ):
  127. raise AdminError("OWNER_POLICY")
  128. metadata = root.stat()
  129. if metadata.st_uid != uid or metadata.st_gid != gid:
  130. raise AdminError("OWNER_POLICY")
  131. return uid, gid
  132. def _validated_root(raw: str) -> Path:
  133. root = _root(raw)
  134. _runtime_identity(root)
  135. return root
  136. def _chown_exact(path: Path, uid: int, gid: int) -> None:
  137. metadata = path.lstat()
  138. if stat.S_ISLNK(metadata.st_mode):
  139. raise AdminError("PATH_POLICY")
  140. if (metadata.st_uid, metadata.st_gid) == (uid, gid):
  141. return
  142. if os.geteuid() != 0:
  143. raise AdminError("OWNER_POLICY")
  144. os.chown(path, uid, gid, follow_symlinks=False)
  145. def _inside(root: Path, raw: str | Path, *, must_exist: bool = False) -> Path:
  146. candidate = Path(raw)
  147. if not candidate.is_absolute() or "\x00" in str(candidate):
  148. raise AdminError("INVALID_INPUT")
  149. if candidate.is_symlink():
  150. raise AdminError("PATH_POLICY")
  151. try:
  152. parent = candidate.parent.resolve(strict=True)
  153. parent.relative_to(root)
  154. except (OSError, ValueError) as exc:
  155. raise AdminError("PATH_POLICY") from exc
  156. current = root
  157. for part in parent.relative_to(root).parts:
  158. current /= part
  159. if current.is_symlink():
  160. raise AdminError("PATH_POLICY")
  161. if must_exist:
  162. try:
  163. resolved = candidate.resolve(strict=True)
  164. resolved.relative_to(root)
  165. except (OSError, ValueError) as exc:
  166. raise AdminError("PATH_POLICY") from exc
  167. metadata = candidate.stat()
  168. if (
  169. candidate.is_symlink()
  170. or not stat.S_ISREG(metadata.st_mode)
  171. or metadata.st_nlink != 1
  172. ):
  173. raise AdminError("PATH_POLICY")
  174. return candidate
  175. def _mkdir(root: Path, name: str, mode: int = 0o700) -> Path:
  176. path = root / name
  177. if path.exists() and (path.is_symlink() or not path.is_dir()):
  178. raise AdminError("PATH_POLICY")
  179. created = not path.exists()
  180. path.mkdir(mode=mode, exist_ok=True)
  181. os.chmod(path, mode)
  182. parent = path.parent.stat()
  183. metadata = path.stat()
  184. if created and os.geteuid() == 0:
  185. os.chown(path, parent.st_uid, parent.st_gid, follow_symlinks=False)
  186. elif (metadata.st_uid, metadata.st_gid) != (parent.st_uid, parent.st_gid):
  187. raise AdminError("OWNER_POLICY")
  188. return path
  189. def _inherit_parent_owner(descriptor: int, parent: Path) -> None:
  190. parent_metadata = parent.stat()
  191. metadata = os.fstat(descriptor)
  192. expected = (parent_metadata.st_uid, parent_metadata.st_gid)
  193. if (metadata.st_uid, metadata.st_gid) == expected:
  194. return
  195. if os.geteuid() != 0:
  196. raise AdminError("OWNER_POLICY")
  197. os.fchown(descriptor, *expected)
  198. def _atomic_write(path: Path, data: bytes, mode: int) -> None:
  199. if path.exists() and path.is_symlink():
  200. raise AdminError("PATH_POLICY")
  201. descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
  202. try:
  203. os.fchmod(descriptor, mode)
  204. _inherit_parent_owner(descriptor, path.parent)
  205. with os.fdopen(descriptor, "wb", closefd=True) as stream:
  206. stream.write(data)
  207. stream.flush()
  208. os.fsync(stream.fileno())
  209. os.replace(temporary, path)
  210. os.chmod(path, mode)
  211. _fsync_directory(path.parent)
  212. except Exception:
  213. with suppress(FileNotFoundError):
  214. os.unlink(temporary)
  215. raise
  216. def _fsync_directory(path: Path) -> None:
  217. directory = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
  218. try:
  219. os.fsync(directory)
  220. finally:
  221. os.close(directory)
  222. def _atomic_copy(source: Path, target: Path, mode: int) -> None:
  223. if target.exists() or target.is_symlink():
  224. raise AdminError("PATH_POLICY")
  225. descriptor, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
  226. try:
  227. os.fchmod(descriptor, mode)
  228. _inherit_parent_owner(descriptor, target.parent)
  229. with source.open("rb") as reader, os.fdopen(descriptor, "wb") as writer:
  230. for chunk in iter(lambda: reader.read(1_048_576), b""):
  231. writer.write(chunk)
  232. writer.flush()
  233. os.fsync(writer.fileno())
  234. os.replace(temporary, target)
  235. os.chmod(target, mode)
  236. _fsync_directory(target.parent)
  237. except Exception:
  238. with suppress(FileNotFoundError):
  239. os.unlink(temporary)
  240. raise
  241. def _read_json(path: Path) -> dict[str, object]:
  242. if not path.is_file() or path.is_symlink() or path.stat().st_size > MAX_JSON_BYTES:
  243. raise AdminError("INVALID_INPUT")
  244. try:
  245. value = json.loads(path.read_text(encoding="utf-8"))
  246. except (OSError, UnicodeError, json.JSONDecodeError) as exc:
  247. raise AdminError("INVALID_INPUT") from exc
  248. if not isinstance(value, dict):
  249. raise AdminError("INVALID_INPUT")
  250. return value
  251. def _sha256_file(path: Path) -> str:
  252. digest = hashlib.sha256()
  253. try:
  254. with path.open("rb") as stream:
  255. for chunk in iter(lambda: stream.read(1_048_576), b""):
  256. digest.update(chunk)
  257. except OSError as exc:
  258. raise AdminError("INVALID_INPUT") from exc
  259. return digest.hexdigest()
  260. def _open_root_file(root: Path, raw: str | Path) -> tuple[int, os.stat_result]:
  261. path = _inside(root, raw)
  262. flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
  263. if hasattr(os, "O_NOFOLLOW"):
  264. flags |= os.O_NOFOLLOW
  265. try:
  266. descriptor = os.open(path, flags)
  267. except OSError as exc:
  268. raise AdminError("PATH_POLICY") from exc
  269. try:
  270. metadata = os.fstat(descriptor)
  271. uid, gid = _runtime_identity(root)
  272. if (
  273. not stat.S_ISREG(metadata.st_mode)
  274. or metadata.st_nlink != 1
  275. or (metadata.st_uid, metadata.st_gid) != (uid, gid)
  276. ):
  277. raise AdminError("PATH_POLICY")
  278. return descriptor, metadata
  279. except Exception:
  280. os.close(descriptor)
  281. raise
  282. def _read_json_descriptor(descriptor: int) -> dict[str, object]:
  283. metadata = os.fstat(descriptor)
  284. if metadata.st_size > MAX_JSON_BYTES:
  285. raise AdminError("INVALID_INPUT")
  286. os.lseek(descriptor, 0, os.SEEK_SET)
  287. try:
  288. raw = b""
  289. while len(raw) <= MAX_JSON_BYTES:
  290. chunk = os.read(descriptor, min(65_536, MAX_JSON_BYTES + 1 - len(raw)))
  291. if not chunk:
  292. break
  293. raw += chunk
  294. value = json.loads(raw.decode("utf-8"))
  295. except (OSError, UnicodeError, json.JSONDecodeError) as exc:
  296. raise AdminError("INVALID_INPUT") from exc
  297. if not isinstance(value, dict):
  298. raise AdminError("INVALID_INPUT")
  299. return value
  300. def _copy_descriptor(
  301. source_descriptor: int, source_before: os.stat_result, target: Path
  302. ) -> str:
  303. flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
  304. if hasattr(os, "O_NOFOLLOW"):
  305. flags |= os.O_NOFOLLOW
  306. target_descriptor = os.open(target, flags, 0o600)
  307. digest = hashlib.sha256()
  308. try:
  309. os.fchmod(target_descriptor, 0o600)
  310. _inherit_parent_owner(target_descriptor, target.parent)
  311. os.lseek(source_descriptor, 0, os.SEEK_SET)
  312. while True:
  313. chunk = os.read(source_descriptor, 1_048_576)
  314. if not chunk:
  315. break
  316. digest.update(chunk)
  317. view = memoryview(chunk)
  318. while view:
  319. written = os.write(target_descriptor, view)
  320. view = view[written:]
  321. os.fsync(target_descriptor)
  322. finally:
  323. os.close(target_descriptor)
  324. source_after = os.fstat(source_descriptor)
  325. snapshot_fields = (
  326. "st_dev", "st_ino", "st_mode", "st_nlink", "st_uid", "st_gid",
  327. "st_size", "st_mtime_ns", "st_ctime_ns",
  328. )
  329. if any(
  330. getattr(source_before, field) != getattr(source_after, field)
  331. for field in snapshot_fields
  332. ):
  333. raise AdminError("RELEASE_REJECTED", 3)
  334. copied_digest = digest.hexdigest()
  335. if _sha256_file(target) != copied_digest:
  336. raise AdminError("RELEASE_REJECTED", 3)
  337. return copied_digest
  338. def _layout(root: Path) -> None:
  339. for name in (
  340. "queue", "artifacts", "releases", "secrets", "certificates",
  341. "registration", "state",
  342. ):
  343. _mkdir(root, name)
  344. @contextmanager
  345. def _process_lock(root: Path, uid: int | None = None, gid: int | None = None):
  346. if uid is None or gid is None:
  347. uid, gid = _runtime_identity(root)
  348. lock_path = root / "state" / "admin.lock"
  349. flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
  350. if hasattr(os, "O_NOFOLLOW"):
  351. flags |= os.O_NOFOLLOW
  352. try:
  353. descriptor = os.open(lock_path, flags, 0o600)
  354. except OSError as exc:
  355. raise AdminError("LOCK_POLICY", 3) from exc
  356. try:
  357. if os.geteuid() == 0:
  358. os.fchown(descriptor, uid, gid)
  359. os.fchmod(descriptor, 0o600)
  360. metadata = os.fstat(descriptor)
  361. if (
  362. not stat.S_ISREG(metadata.st_mode)
  363. or stat.S_IMODE(metadata.st_mode) != 0o600
  364. or metadata.st_nlink != 1
  365. or (metadata.st_uid, metadata.st_gid) != (uid, gid)
  366. ):
  367. raise AdminError("LOCK_POLICY", 3)
  368. try:
  369. fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
  370. except BlockingIOError as exc:
  371. raise AdminError("CONCURRENT_OPERATION", 3) from exc
  372. yield
  373. finally:
  374. os.close(descriptor)
  375. def _bounded_backups(directory: Path, prefix: str) -> None:
  376. candidates = sorted(
  377. (path for path in directory.glob(f"{prefix}*") if path.is_file() and not path.is_symlink()),
  378. key=lambda path: path.stat().st_mtime_ns,
  379. reverse=True,
  380. )
  381. for path in candidates[MAX_BACKUPS:]:
  382. path.unlink()
  383. def command_init(args: argparse.Namespace) -> dict[str, object]:
  384. if (
  385. isinstance(args.runtime_uid, bool)
  386. or not 1 <= args.runtime_uid <= 2_147_483_647
  387. or isinstance(args.runtime_gid, bool)
  388. or not 1 <= args.runtime_gid <= 2_147_483_647
  389. ):
  390. raise AdminError("INVALID_INPUT")
  391. root = _root(args.root, create=True)
  392. identity_path = root / "state" / "runtime-identity.json"
  393. if identity_path.exists():
  394. uid, gid = _runtime_identity(root)
  395. if (uid, gid) != (args.runtime_uid, args.runtime_gid):
  396. raise AdminError("OWNER_POLICY")
  397. target = root / "edge-config.template.json"
  398. if not target.is_file() or target.is_symlink():
  399. raise AdminError("PATH_POLICY")
  400. with _process_lock(root):
  401. return {
  402. "config_template_path": str(target),
  403. "root": str(root),
  404. "runtime_gid": gid,
  405. "runtime_uid": uid,
  406. "status": "initialized",
  407. }
  408. if any(root.iterdir()):
  409. raise AdminError("PATH_POLICY")
  410. os.chmod(root, 0o700)
  411. current = root.stat()
  412. if (current.st_uid, current.st_gid) != (args.runtime_uid, args.runtime_gid):
  413. if not args.allow_chown:
  414. raise AdminError("OWNER_POLICY")
  415. _chown_exact(root, args.runtime_uid, args.runtime_gid)
  416. _layout(root)
  417. for name in (
  418. "queue", "artifacts", "releases", "secrets", "certificates",
  419. "registration", "state",
  420. ):
  421. _chown_exact(root / name, args.runtime_uid, args.runtime_gid)
  422. identity = {
  423. "runtime_gid": args.runtime_gid,
  424. "runtime_uid": args.runtime_uid,
  425. "schema_version": 1,
  426. }
  427. with _process_lock(root, args.runtime_uid, args.runtime_gid):
  428. _atomic_write(identity_path, _json_bytes(identity) + b"\n", 0o600)
  429. _chown_exact(identity_path, args.runtime_uid, args.runtime_gid)
  430. template = {
  431. "allowed_control_hosts": [],
  432. "allowed_proxy_hosts": [],
  433. "control_origin": None,
  434. "environment": None,
  435. "gateway_id": None,
  436. "network_zone": None,
  437. "policy_digest": None,
  438. "proxy_origin": None,
  439. "runtime_gid": args.runtime_gid,
  440. "runtime_uid": args.runtime_uid,
  441. "schema_version": 1,
  442. }
  443. target = root / "edge-config.template.json"
  444. _atomic_write(target, _json_bytes(template) + b"\n", 0o600)
  445. _chown_exact(target, args.runtime_uid, args.runtime_gid)
  446. return {
  447. "config_template_path": str(target),
  448. "root": str(root),
  449. "runtime_gid": args.runtime_gid,
  450. "runtime_uid": args.runtime_uid,
  451. "status": "initialized",
  452. }
  453. def _new_csr(root: Path, common_name: str, prefix: str) -> dict[str, object]:
  454. if not IDENTIFIER_RE.fullmatch(common_name):
  455. raise AdminError("INVALID_INPUT")
  456. key_path = root / "secrets" / f"{prefix}.key"
  457. csr_path = root / "certificates" / f"{prefix}.csr"
  458. if key_path.exists() or csr_path.exists():
  459. raise AdminError("MATERIAL_EXISTS")
  460. private = rsa.generate_private_key(public_exponent=65537, key_size=3072)
  461. csr = (
  462. x509.CertificateSigningRequestBuilder()
  463. .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
  464. .sign(private, hashes.SHA256())
  465. )
  466. key_bytes = private.private_bytes(
  467. serialization.Encoding.PEM,
  468. serialization.PrivateFormat.PKCS8,
  469. serialization.NoEncryption(),
  470. )
  471. csr_bytes = csr.public_bytes(serialization.Encoding.PEM)
  472. try:
  473. _atomic_write(key_path, key_bytes, 0o600)
  474. _atomic_write(csr_path, csr_bytes, 0o644)
  475. except Exception:
  476. if key_path.exists():
  477. key_path.unlink()
  478. if csr_path.exists():
  479. csr_path.unlink()
  480. raise
  481. return {
  482. "csr_path": str(csr_path),
  483. "private_key_path": str(key_path),
  484. "public_key_sha256": hashlib.sha256(
  485. csr.public_key().public_bytes(
  486. serialization.Encoding.DER,
  487. serialization.PublicFormat.SubjectPublicKeyInfo,
  488. )
  489. ).hexdigest(),
  490. "status": "csr_created",
  491. }
  492. def command_csr(args: argparse.Namespace) -> dict[str, object]:
  493. root = _validated_root(args.root)
  494. _layout(root)
  495. return _new_csr(root, args.common_name, "client")
  496. def _verify_x509_signature(value, issuer_public_key) -> None:
  497. try:
  498. if isinstance(issuer_public_key, rsa.RSAPublicKey):
  499. issuer_public_key.verify(
  500. value.signature, value.tbs_certificate_bytes
  501. if isinstance(value, x509.Certificate)
  502. else value.tbs_certlist_bytes,
  503. padding.PKCS1v15(), value.signature_hash_algorithm,
  504. )
  505. elif isinstance(issuer_public_key, ec.EllipticCurvePublicKey):
  506. issuer_public_key.verify(
  507. value.signature, value.tbs_certificate_bytes
  508. if isinstance(value, x509.Certificate)
  509. else value.tbs_certlist_bytes,
  510. ec.ECDSA(value.signature_hash_algorithm),
  511. )
  512. elif isinstance(issuer_public_key, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)):
  513. issuer_public_key.verify(
  514. value.signature, value.tbs_certificate_bytes
  515. if isinstance(value, x509.Certificate)
  516. else value.tbs_certlist_bytes,
  517. )
  518. else:
  519. raise AdminError("MATERIAL_INVALID")
  520. except (InvalidSignature, ValueError, TypeError) as exc:
  521. raise AdminError("MATERIAL_INVALID") from exc
  522. def _load_material(
  523. root: Path, certificate_raw: str, ca_raw: str, crl_raw: str,
  524. prefix: str = "client",
  525. ):
  526. certificate_path = _inside(root, certificate_raw, must_exist=True)
  527. ca_path = _inside(root, ca_raw, must_exist=True)
  528. crl_path = _inside(root, crl_raw, must_exist=True)
  529. key_path = _inside(root, root / "secrets" / f"{prefix}.key", must_exist=True)
  530. csr_path = _inside(root, root / "certificates" / f"{prefix}.csr", must_exist=True)
  531. if stat.S_IMODE(key_path.stat().st_mode) & 0o077:
  532. raise AdminError("KEY_PERMISSION")
  533. try:
  534. certificate = x509.load_pem_x509_certificate(certificate_path.read_bytes())
  535. authorities = x509.load_pem_x509_certificates(ca_path.read_bytes())
  536. crl = x509.load_pem_x509_crl(crl_path.read_bytes())
  537. private = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
  538. csr = x509.load_pem_x509_csr(csr_path.read_bytes())
  539. except (ValueError, TypeError, OSError) as exc:
  540. raise AdminError("MATERIAL_INVALID") from exc
  541. public = certificate.public_key().public_bytes(
  542. serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
  543. )
  544. if public != private.public_key().public_bytes(
  545. serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
  546. ) or public != csr.public_key().public_bytes(
  547. serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
  548. ) or not csr.is_signature_valid:
  549. raise AdminError("MATERIAL_MISMATCH")
  550. now = datetime.now(UTC)
  551. if certificate.not_valid_before_utc > now or certificate.not_valid_after_utc <= now:
  552. raise AdminError("MATERIAL_INVALID")
  553. try:
  554. usages = certificate.extensions.get_extension_for_class(
  555. x509.ExtendedKeyUsage
  556. ).value
  557. except x509.ExtensionNotFound as exc:
  558. raise AdminError("MATERIAL_INVALID") from exc
  559. if ExtendedKeyUsageOID.CLIENT_AUTH not in usages:
  560. raise AdminError("MATERIAL_INVALID")
  561. issuer = next(
  562. (authority for authority in authorities if authority.subject == certificate.issuer),
  563. None,
  564. )
  565. if issuer is None:
  566. raise AdminError("MATERIAL_INVALID")
  567. try:
  568. basic = issuer.extensions.get_extension_for_class(x509.BasicConstraints).value
  569. except x509.ExtensionNotFound as exc:
  570. raise AdminError("MATERIAL_INVALID") from exc
  571. if not basic.ca or issuer.not_valid_after_utc <= now:
  572. raise AdminError("MATERIAL_INVALID")
  573. _verify_x509_signature(certificate, issuer.public_key())
  574. if crl.issuer != issuer.subject or crl.last_update_utc > now or crl.next_update_utc <= now:
  575. raise AdminError("MATERIAL_INVALID")
  576. _verify_x509_signature(crl, issuer.public_key())
  577. if crl.get_revoked_certificate_by_serial_number(certificate.serial_number) is not None:
  578. raise AdminError("MATERIAL_INVALID")
  579. return certificate, csr, ca_path, crl, crl_path
  580. def command_register_manifest(args: argparse.Namespace) -> dict[str, object]:
  581. root = _validated_root(args.root)
  582. certificate, csr, ca_path, crl, crl_path = _load_material(
  583. root, args.certificate, args.ca_bundle, args.crl
  584. )
  585. for value in (args.gateway_id, args.environment, args.network_zone):
  586. if not IDENTIFIER_RE.fullmatch(value):
  587. raise AdminError("INVALID_INPUT")
  588. if args.environment not in {"development", "staging", "production"}:
  589. raise AdminError("INVALID_INPUT")
  590. if not SHA256_RE.fullmatch(args.policy_digest):
  591. raise AdminError("INVALID_INPUT")
  592. if not ORIGIN_RE.fullmatch(args.control_origin):
  593. raise AdminError("INVALID_INPUT")
  594. if args.proxy_origin and not ORIGIN_RE.fullmatch(args.proxy_origin):
  595. raise AdminError("INVALID_INPUT")
  596. output = root / "registration" / "registration.json"
  597. if output.is_symlink():
  598. raise AdminError("PATH_POLICY")
  599. certificate_digest = certificate.fingerprint(hashes.SHA256()).hex()
  600. public_digest = hashlib.sha256(
  601. certificate.public_key().public_bytes(
  602. serialization.Encoding.DER,
  603. serialization.PublicFormat.SubjectPublicKeyInfo,
  604. )
  605. ).hexdigest()
  606. material = {
  607. "allowed_control_hosts": [args.control_origin.removeprefix("https://").split(":", 1)[0]],
  608. "allowed_proxy_hosts": [] if not args.proxy_origin else [args.proxy_origin.removeprefix("https://").split(":", 1)[0]],
  609. "certificate_sha256": certificate_digest,
  610. "certificate_not_after": certificate.not_valid_after_utc.isoformat().replace("+00:00", "Z"),
  611. "certificate_not_before": certificate.not_valid_before_utc.isoformat().replace("+00:00", "Z"),
  612. "control_origin": args.control_origin,
  613. "csr_sha256": hashlib.sha256(
  614. csr.public_bytes(serialization.Encoding.DER)
  615. ).hexdigest(),
  616. "public_key_sha256": public_digest,
  617. "environment": args.environment,
  618. "gateway_id": args.gateway_id,
  619. "network_zone": args.network_zone,
  620. "policy_digest": args.policy_digest,
  621. "proxy_origin": args.proxy_origin,
  622. "ca_bundle_sha256": _sha256_file(ca_path),
  623. "ca_chain_verified": True,
  624. "client_auth_eku_verified": True,
  625. "crl_sha256": _sha256_file(crl_path),
  626. "crl_verified": True,
  627. "crl_this_update": crl.last_update_utc.isoformat().replace("+00:00", "Z"),
  628. "crl_next_update": crl.next_update_utc.isoformat().replace("+00:00", "Z"),
  629. "schema_version": 1,
  630. }
  631. replayed = False
  632. if output.exists():
  633. if _read_json(output) != material:
  634. raise AdminError("IDEMPOTENCY_CONFLICT", 3)
  635. replayed = True
  636. else:
  637. _atomic_write(output, _json_bytes(material) + b"\n", 0o600)
  638. return {
  639. "certificate_sha256": certificate_digest,
  640. "manifest_digest": _digest(material),
  641. "manifest_path": str(output),
  642. "replayed": replayed,
  643. "status": "registration_material_ready",
  644. }
  645. def command_rotate(args: argparse.Namespace) -> dict[str, object]:
  646. root = _validated_root(args.root)
  647. if not IDENTIFIER_RE.fullmatch(args.request_id) or not SHA256_RE.fullmatch(
  648. args.expected_current_certificate_digest
  649. ):
  650. raise AdminError("INVALID_INPUT")
  651. request = {
  652. "common_name": args.common_name,
  653. "expected_current_certificate_digest": args.expected_current_certificate_digest,
  654. "request_id": args.request_id,
  655. }
  656. request_digest = _digest(request)
  657. record_path = root / "state" / f"rotation-{args.request_id}.json"
  658. if record_path.exists():
  659. record = _read_json(record_path)
  660. if record.get("request_digest") != request_digest:
  661. raise AdminError("IDEMPOTENCY_CONFLICT", 3)
  662. return record
  663. pending_records = [
  664. path
  665. for path in (root / "state").glob("rotation-*.json")
  666. if path.is_file()
  667. and not path.is_symlink()
  668. and _read_json(path).get("status") == "pending_server_approval"
  669. ]
  670. if len(pending_records) >= MAX_BACKUPS:
  671. raise AdminError("ROTATION_LIMIT", 3)
  672. prefix = f"pending-{request_digest[:16]}"
  673. result = _new_csr(root, args.common_name, prefix)
  674. record = {
  675. **result,
  676. "expected_current_certificate_digest": args.expected_current_certificate_digest,
  677. "request_digest": request_digest,
  678. "request_id": args.request_id,
  679. "status": "pending_server_approval",
  680. }
  681. _atomic_write(record_path, _json_bytes(record) + b"\n", 0o600)
  682. return record
  683. def _trusted_keys(bindings: list[str]) -> dict[str, bytes]:
  684. keys: dict[str, bytes] = {}
  685. for binding in bindings:
  686. key_id, separator, key_hex = binding.partition("=")
  687. if (
  688. not separator
  689. or not IDENTIFIER_RE.fullmatch(key_id)
  690. or not re.fullmatch(r"[0-9a-f]{64}", key_hex)
  691. ):
  692. raise AdminError("INVALID_INPUT")
  693. keys[key_id] = bytes.fromhex(key_hex)
  694. return keys
  695. STATUS_FIELDS = frozenset(
  696. {
  697. "certificate_sha256", "expires_at", "gateway_id", "generation",
  698. "issued_at", "key_id", "signature_algorithm", "status",
  699. "envelope_digest", "signature",
  700. }
  701. )
  702. def command_revoke_status(args: argparse.Namespace) -> dict[str, object]:
  703. root = _validated_root(args.root)
  704. local_marker = root / "state" / "REVOKED_STOP"
  705. status = "revoked" if local_marker.exists() else "not_revoked_locally"
  706. evidence_digest = None
  707. if args.server_status:
  708. evidence_path = _inside(root, args.server_status, must_exist=True)
  709. evidence = _read_json(evidence_path)
  710. if set(evidence) != STATUS_FIELDS:
  711. raise AdminError("STATUS_REJECTED", 3)
  712. unsigned = {
  713. key: evidence[key]
  714. for key in sorted(STATUS_FIELDS - {"envelope_digest", "signature"})
  715. }
  716. try:
  717. issued = datetime.fromisoformat(str(evidence["issued_at"]).replace("Z", "+00:00"))
  718. expires = datetime.fromisoformat(str(evidence["expires_at"]).replace("Z", "+00:00"))
  719. generation = int(evidence["generation"])
  720. except (TypeError, ValueError) as exc:
  721. raise AdminError("STATUS_REJECTED", 3) from exc
  722. now = datetime.now(UTC)
  723. if (
  724. not str(evidence["issued_at"]).endswith("Z")
  725. or not str(evidence["expires_at"]).endswith("Z")
  726. or issued.tzinfo is None
  727. or expires.tzinfo is None
  728. or issued.astimezone(UTC) > now
  729. or expires.astimezone(UTC) <= now
  730. or evidence["signature_algorithm"] != "Ed25519"
  731. or evidence["status"] not in {"active", "revoked"}
  732. or evidence["gateway_id"] != args.expected_gateway_id
  733. or generation != args.expected_generation
  734. or evidence["certificate_sha256"] != args.expected_certificate_digest
  735. or _protocol_digest(unsigned) != evidence["envelope_digest"]
  736. ):
  737. raise AdminError("STATUS_REJECTED", 3)
  738. public = _trusted_keys(args.trusted_key).get(str(evidence["key_id"]))
  739. if public is None:
  740. raise AdminError("STATUS_REJECTED", 3)
  741. try:
  742. ed25519.Ed25519PublicKey.from_public_bytes(public).verify(
  743. bytes.fromhex(str(evidence["signature"])), _protocol_bytes(unsigned)
  744. )
  745. except (InvalidSignature, ValueError) as exc:
  746. raise AdminError("STATUS_REJECTED", 3) from exc
  747. if evidence["status"] == "revoked":
  748. _atomic_write(local_marker, _json_bytes(evidence) + b"\n", 0o600)
  749. status = "revoked"
  750. evidence_digest = evidence["envelope_digest"]
  751. return {
  752. "evidence_digest": evidence_digest,
  753. "local_stop_required": status == "revoked",
  754. "status": status,
  755. }
  756. def _version_tuple(value: str) -> tuple[int, int, int, int]:
  757. if not VERSION_RE.fullmatch(value):
  758. raise AdminError("RELEASE_REJECTED", 3)
  759. values = tuple(int(item) for item in value.split("."))
  760. if any(item > 2_147_483_647 for item in values):
  761. raise AdminError("RELEASE_REJECTED", 3)
  762. return (*values, 0) # type: ignore[return-value]
  763. def _verify_release_manifest(
  764. manifest: dict[str, object], artifact_name_actual: str,
  765. artifact_digest_actual: str, trusted_keys: dict[str, bytes],
  766. *, current_version: str | None = None,
  767. ) -> dict[str, object]:
  768. if set(manifest) != MANIFEST_FIELDS:
  769. raise AdminError("RELEASE_REJECTED", 3)
  770. if not IDENTIFIER_RE.fullmatch(str(manifest["release_id"])):
  771. raise AdminError("RELEASE_REJECTED", 3)
  772. artifact_name = manifest["artifact_name"]
  773. if (
  774. not isinstance(artifact_name, str)
  775. or PurePosixPath(artifact_name).name != artifact_name
  776. or artifact_name != artifact_name_actual
  777. or artifact_name in {".", ".."}
  778. ):
  779. raise AdminError("RELEASE_REJECTED", 3)
  780. if (
  781. manifest["signature_algorithm"] != "Ed25519"
  782. or manifest["status"] not in {
  783. "offered", "accepted", "installed", "rolled_back"
  784. }
  785. ):
  786. raise AdminError("RELEASE_REJECTED", 3)
  787. version_tuple = _version_tuple(str(manifest["version"]))
  788. rollback_tuple = _version_tuple(str(manifest["rollback_version"]))
  789. if version_tuple <= rollback_tuple:
  790. raise AdminError("RELEASE_REJECTED", 3)
  791. if current_version is not None and (
  792. manifest["rollback_version"] != current_version
  793. or version_tuple <= _version_tuple(current_version)
  794. ):
  795. raise AdminError("RELEASE_REJECTED", 3)
  796. try:
  797. deadline = datetime.fromisoformat(str(manifest["deadline_at"]).replace("Z", "+00:00"))
  798. except ValueError as exc:
  799. raise AdminError("RELEASE_REJECTED", 3) from exc
  800. if deadline.tzinfo is None or deadline.astimezone(UTC) <= datetime.now(UTC):
  801. raise AdminError("RELEASE_REJECTED", 3)
  802. if not str(manifest["deadline_at"]).endswith("Z"):
  803. raise AdminError("RELEASE_REJECTED", 3)
  804. unsigned = {
  805. key: manifest[key]
  806. for key in sorted(MANIFEST_FIELDS - {"manifest_digest", "signature"})
  807. }
  808. if not SHA256_RE.fullmatch(str(manifest["manifest_digest"])) or _protocol_digest(unsigned) != manifest["manifest_digest"]:
  809. raise AdminError("RELEASE_REJECTED", 3)
  810. if not SHA256_RE.fullmatch(str(manifest["artifact_digest"])):
  811. raise AdminError("RELEASE_REJECTED", 3)
  812. if artifact_digest_actual != manifest["artifact_digest"]:
  813. raise AdminError("RELEASE_REJECTED", 3)
  814. public = trusted_keys.get(str(manifest["key_id"]))
  815. if public is None:
  816. raise AdminError("RELEASE_REJECTED", 3)
  817. try:
  818. ed25519.Ed25519PublicKey.from_public_bytes(public).verify(
  819. bytes.fromhex(str(manifest["signature"])), _protocol_bytes(unsigned)
  820. )
  821. except (ValueError, InvalidSignature) as exc:
  822. raise AdminError("RELEASE_REJECTED", 3) from exc
  823. return {
  824. "artifact_digest": artifact_digest_actual,
  825. "content_digest": manifest["manifest_digest"],
  826. "manifest": manifest,
  827. }
  828. def _bundle_record(manifest: dict[str, object]) -> dict[str, object]:
  829. unsigned = {
  830. "artifact_digest": manifest["artifact_digest"],
  831. "artifact_name": manifest["artifact_name"],
  832. "manifest_digest": manifest["manifest_digest"],
  833. "schema_version": 1,
  834. }
  835. return {**unsigned, "bundle_digest": _protocol_digest(unsigned)}
  836. def _verified_bundle(root: Path, manifest_digest: str) -> Path:
  837. if not SHA256_RE.fullmatch(manifest_digest):
  838. raise AdminError("RELEASE_REJECTED", 3)
  839. verified = root / "releases" / "verified"
  840. if verified.is_symlink() or not verified.is_dir():
  841. raise AdminError("PATH_POLICY")
  842. bundle = verified / manifest_digest
  843. if bundle.is_symlink() or not bundle.is_dir():
  844. raise AdminError("RELEASE_REJECTED", 3)
  845. return bundle
  846. def _verify_bundle(
  847. root: Path, manifest_digest: str, trusted_keys: dict[str, bytes]
  848. ) -> tuple[dict[str, object], dict[str, object], Path]:
  849. bundle = _verified_bundle(root, manifest_digest)
  850. manifest_path = _inside(root, bundle / "manifest.json", must_exist=True)
  851. manifest = _read_json(manifest_path)
  852. if manifest.get("manifest_digest") != manifest_digest:
  853. raise AdminError("RELEASE_REJECTED", 3)
  854. artifact_name = manifest.get("artifact_name")
  855. if not isinstance(artifact_name, str):
  856. raise AdminError("RELEASE_REJECTED", 3)
  857. artifact_path = _inside(root, bundle / artifact_name, must_exist=True)
  858. artifact_descriptor, artifact_metadata = _open_root_file(root, artifact_path)
  859. try:
  860. digest = hashlib.sha256()
  861. for chunk in iter(lambda: os.read(artifact_descriptor, 1_048_576), b""):
  862. digest.update(chunk)
  863. if os.fstat(artifact_descriptor) != artifact_metadata:
  864. raise AdminError("RELEASE_REJECTED", 3)
  865. finally:
  866. os.close(artifact_descriptor)
  867. _verify_release_manifest(manifest, artifact_name, digest.hexdigest(), trusted_keys)
  868. bundle_path = _inside(root, bundle / "bundle.json", must_exist=True)
  869. record = _read_json(bundle_path)
  870. expected = _bundle_record(manifest)
  871. if record != expected or set(bundle.iterdir()) != {
  872. manifest_path, artifact_path, bundle_path
  873. }:
  874. raise AdminError("RELEASE_REJECTED", 3)
  875. return manifest, record, artifact_path
  876. def command_release_check(args: argparse.Namespace) -> dict[str, object]:
  877. root = _validated_root(args.root)
  878. manifest_path = _inside(root, args.manifest)
  879. artifact_path = _inside(root, args.artifact)
  880. manifest_descriptor = -1
  881. artifact_descriptor = -1
  882. temporary: Path | None = None
  883. try:
  884. manifest_descriptor, manifest_before = _open_root_file(root, manifest_path)
  885. artifact_descriptor, artifact_before = _open_root_file(root, artifact_path)
  886. manifest = _read_json_descriptor(manifest_descriptor)
  887. if os.fstat(manifest_descriptor) != manifest_before:
  888. raise AdminError("RELEASE_REJECTED", 3)
  889. artifact_name = manifest.get("artifact_name")
  890. if (
  891. not isinstance(artifact_name, str)
  892. or not artifact_name
  893. or artifact_name in {".", ".."}
  894. or len(artifact_name.encode("utf-8")) > 255
  895. or PurePosixPath(artifact_name).name != artifact_name
  896. or Path(artifact_name).name != artifact_name
  897. or artifact_path.name != artifact_name
  898. ):
  899. raise AdminError("RELEASE_REJECTED", 3)
  900. verified_dir = _mkdir(root / "releases", "verified")
  901. temporary = Path(tempfile.mkdtemp(prefix=".bundle-", dir=verified_dir))
  902. os.chmod(temporary, 0o700)
  903. parent_owner = verified_dir.stat()
  904. if os.geteuid() == 0:
  905. os.chown(
  906. temporary, parent_owner.st_uid, parent_owner.st_gid,
  907. follow_symlinks=False,
  908. )
  909. elif (temporary.stat().st_uid, temporary.stat().st_gid) != (
  910. parent_owner.st_uid, parent_owner.st_gid
  911. ):
  912. raise AdminError("OWNER_POLICY")
  913. copied_artifact = temporary / artifact_name
  914. copied_digest = _copy_descriptor(
  915. artifact_descriptor, artifact_before, copied_artifact
  916. )
  917. keys = _trusted_keys(args.trusted_key)
  918. verified = _verify_release_manifest(
  919. manifest, artifact_path.name, copied_digest, keys,
  920. current_version=args.current_version,
  921. )
  922. replay_path = root / "state" / "release-replay.json"
  923. replay = _read_json(replay_path) if replay_path.exists() else {}
  924. content_digest = str(verified["content_digest"])
  925. prior = replay.get(str(manifest["release_id"]))
  926. if prior is not None and prior != content_digest:
  927. raise AdminError("RELEASE_REJECTED", 3)
  928. final_bundle = verified_dir / str(manifest["manifest_digest"])
  929. replayed = False
  930. if final_bundle.exists() or final_bundle.is_symlink():
  931. _verify_bundle(root, str(manifest["manifest_digest"]), keys)
  932. copied_artifact.unlink()
  933. temporary.rmdir()
  934. replayed = True
  935. else:
  936. _atomic_write(
  937. temporary / "manifest.json", _json_bytes(manifest) + b"\n", 0o600
  938. )
  939. _atomic_write(
  940. temporary / "bundle.json",
  941. _json_bytes(_bundle_record(manifest)) + b"\n", 0o600,
  942. )
  943. _fsync_directory(temporary)
  944. os.replace(temporary, final_bundle)
  945. _fsync_directory(verified_dir)
  946. replay[str(manifest["release_id"])] = content_digest
  947. _atomic_write(replay_path, _json_bytes(replay) + b"\n", 0o600)
  948. return {
  949. "artifact_digest": manifest["artifact_digest"],
  950. "manifest_digest": manifest["manifest_digest"],
  951. "replayed": replayed,
  952. "release_id": manifest["release_id"],
  953. "status": "verified",
  954. "verified_bundle_path": str(final_bundle),
  955. "version": manifest["version"],
  956. }
  957. finally:
  958. if manifest_descriptor >= 0:
  959. os.close(manifest_descriptor)
  960. if artifact_descriptor >= 0:
  961. os.close(artifact_descriptor)
  962. if temporary is not None and temporary.exists():
  963. if temporary.is_symlink() or not temporary.is_dir():
  964. raise AdminError("PATH_POLICY")
  965. for item in temporary.iterdir():
  966. if item.is_symlink() or not item.is_file():
  967. raise AdminError("PATH_POLICY")
  968. item.unlink()
  969. temporary.rmdir()
  970. _fsync_directory(temporary.parent)
  971. def command_rollback(args: argparse.Namespace) -> dict[str, object]:
  972. root = _validated_root(args.root)
  973. if not args.yes and (
  974. not sys.stdin.isatty()
  975. or input("Type ROLLBACK to continue: ").strip() != "ROLLBACK"
  976. ):
  977. raise AdminError("CONFIRMATION_REQUIRED", 4)
  978. if not SHA256_RE.fullmatch(args.expected_current_digest) or not SHA256_RE.fullmatch(args.target_digest):
  979. raise AdminError("INVALID_INPUT")
  980. keys = _trusted_keys(args.trusted_key)
  981. current_path = _inside(root, root / "releases" / "current.json", must_exist=True)
  982. current_pointer = _read_json(current_path)
  983. history_dir = _mkdir(root / "state", "rollback-history")
  984. request_digest = _protocol_digest(
  985. {
  986. "expected_current_digest": args.expected_current_digest,
  987. "target_digest": args.target_digest,
  988. }
  989. )
  990. history_path = history_dir / f"{request_digest}.json"
  991. intent_path = root / "state" / "rollback-intent.json"
  992. current, current_bundle, _ = _verify_bundle(
  993. root, args.expected_current_digest, keys
  994. )
  995. target, target_bundle, _ = _verify_bundle(root, args.target_digest, keys)
  996. expected_pointer = {
  997. "artifact_name": current["artifact_name"],
  998. "bundle_digest": current_bundle["bundle_digest"],
  999. "manifest_digest": current["manifest_digest"],
  1000. "schema_version": 1,
  1001. "version": current["version"],
  1002. }
  1003. pointer = {
  1004. "artifact_name": target["artifact_name"],
  1005. "bundle_digest": target_bundle["bundle_digest"],
  1006. "manifest_digest": target["manifest_digest"],
  1007. "schema_version": 1,
  1008. "version": target["version"],
  1009. }
  1010. if (
  1011. not isinstance(target.get("version"), str)
  1012. or not isinstance(current.get("version"), str)
  1013. or _version_tuple(target["version"]) >= _version_tuple(current["version"])
  1014. or current.get("rollback_version") != target.get("version")
  1015. ):
  1016. raise AdminError("RELEASE_REJECTED", 3)
  1017. if history_path.exists():
  1018. history = _read_json(history_path)
  1019. if current_pointer != pointer:
  1020. raise AdminError("STATE_CONFLICT", 3)
  1021. if intent_path.exists():
  1022. expected_intent = {
  1023. "current_pointer": expected_pointer,
  1024. "request_digest": request_digest,
  1025. "target_pointer": pointer,
  1026. }
  1027. if _read_json(intent_path) != expected_intent:
  1028. raise AdminError("STATE_CONFLICT", 3)
  1029. intent_path.unlink()
  1030. _fsync_directory(intent_path.parent)
  1031. return {**history, "replayed": True}
  1032. if intent_path.exists():
  1033. intent = _read_json(intent_path)
  1034. if (
  1035. intent.get("request_digest") != request_digest
  1036. or intent.get("current_pointer") != expected_pointer
  1037. or intent.get("target_pointer") != pointer
  1038. ):
  1039. raise AdminError("STATE_CONFLICT", 3)
  1040. if current_pointer == pointer:
  1041. recovered = {
  1042. "current_manifest_digest": args.target_digest,
  1043. "previous_manifest_digest": args.expected_current_digest,
  1044. "replayed": True,
  1045. "status": "rolled_back",
  1046. "version": target.get("version"),
  1047. }
  1048. _atomic_write(history_path, _json_bytes(recovered) + b"\n", 0o600)
  1049. intent_path.unlink()
  1050. _fsync_directory(intent_path.parent)
  1051. return recovered
  1052. if current_pointer != expected_pointer:
  1053. raise AdminError("STATE_CONFLICT", 3)
  1054. backup_dir = _mkdir(root / "releases", "backups")
  1055. backup_path = backup_dir / f"current-{args.expected_current_digest}.json"
  1056. if not backup_path.exists():
  1057. _atomic_write(backup_path, _json_bytes(current_pointer) + b"\n", 0o600)
  1058. _bounded_backups(backup_dir, "current-")
  1059. intent = {
  1060. "current_pointer": expected_pointer,
  1061. "request_digest": request_digest,
  1062. "target_pointer": pointer,
  1063. }
  1064. if intent_path.exists() and _read_json(intent_path) != intent:
  1065. raise AdminError("STATE_CONFLICT", 3)
  1066. if not intent_path.exists():
  1067. _atomic_write(intent_path, _json_bytes(intent) + b"\n", 0o600)
  1068. if _read_json(current_path) != current_pointer:
  1069. raise AdminError("STATE_CONFLICT", 3)
  1070. _atomic_write(current_path, _json_bytes(pointer) + b"\n", 0o600)
  1071. result = {
  1072. "current_manifest_digest": args.target_digest,
  1073. "previous_manifest_digest": args.expected_current_digest,
  1074. "replayed": False,
  1075. "status": "rolled_back",
  1076. "version": target.get("version"),
  1077. }
  1078. _atomic_write(history_path, _json_bytes(result) + b"\n", 0o600)
  1079. intent_path.unlink()
  1080. _fsync_directory(intent_path.parent)
  1081. return result
  1082. def command_status(args: argparse.Namespace) -> dict[str, object]:
  1083. root = _validated_root(args.root)
  1084. queue_files = [
  1085. path for path in (root / "queue").glob("*.sqlite3")
  1086. if path.is_file() and not path.is_symlink()
  1087. ]
  1088. if len(queue_files) > 1:
  1089. raise AdminError("STATE_CONFLICT", 3)
  1090. queue_status: dict[str, object] = {
  1091. "artifact_cleanup": {}, "events": {}, "outcomes": {}, "releases": {},
  1092. "schema_version": None, "tasks": {},
  1093. }
  1094. active_leases = 0
  1095. if queue_files:
  1096. queue_path = _inside(root, queue_files[0], must_exist=True)
  1097. connection = sqlite3.connect(f"file:{queue_path}?mode=ro&immutable=1", uri=True)
  1098. try:
  1099. queue_status["schema_version"] = int(
  1100. connection.execute("PRAGMA user_version").fetchone()[0]
  1101. )
  1102. table_specs = {
  1103. "tasks": ("edge_tasks", "status"),
  1104. "events": ("edge_outbound_events", "status"),
  1105. "outcomes": ("edge_task_outcomes", "status"),
  1106. "artifact_cleanup": ("edge_local_artifacts", "cleanup_status"),
  1107. "releases": ("edge_release_state", "status"),
  1108. }
  1109. for label, (table, column) in table_specs.items():
  1110. rows = connection.execute(
  1111. f"SELECT {column},COUNT(*) FROM {table} GROUP BY {column}" # noqa: S608
  1112. ).fetchall()
  1113. queue_status[label] = {str(status): int(count) for status, count in rows}
  1114. active_leases = sum(
  1115. int(connection.execute(query).fetchone()[0])
  1116. for query in (
  1117. "SELECT COUNT(*) FROM edge_tasks WHERE status='leased'",
  1118. "SELECT COUNT(*) FROM edge_outbound_events WHERE status='sending'",
  1119. "SELECT COUNT(*) FROM edge_task_outcomes WHERE status='sending'",
  1120. "SELECT COUNT(*) FROM edge_local_artifacts WHERE cleanup_status='deleting'",
  1121. )
  1122. )
  1123. except sqlite3.DatabaseError as exc:
  1124. raise AdminError("QUEUE_STATUS_FAILED", 3) from exc
  1125. finally:
  1126. connection.close()
  1127. current = None
  1128. current_path = root / "releases" / "current.json"
  1129. if current_path.exists():
  1130. pointer = _read_json(current_path)
  1131. current = {
  1132. "manifest_digest": pointer.get("manifest_digest"),
  1133. "version": pointer.get("version"),
  1134. }
  1135. return {
  1136. "active_leases": active_leases,
  1137. "current_release": current,
  1138. "cursor_state": "reconciled_per_cycle_not_persisted",
  1139. "queue": queue_status,
  1140. "registration_present": (root / "registration" / "registration.json").is_file(),
  1141. "rollback_intent_present": (root / "state" / "rollback-intent.json").is_file(),
  1142. "status": "read_only",
  1143. }
  1144. def parser() -> argparse.ArgumentParser:
  1145. main = argparse.ArgumentParser(description=__doc__)
  1146. subcommands = main.add_subparsers(dest="command", required=True)
  1147. init = subcommands.add_parser("init")
  1148. init.add_argument("--root", required=True)
  1149. init.add_argument("--runtime-uid", required=True, type=int)
  1150. init.add_argument("--runtime-gid", required=True, type=int)
  1151. init.add_argument("--allow-chown", action="store_true")
  1152. init.set_defaults(handler=command_init)
  1153. csr = subcommands.add_parser("csr")
  1154. csr.add_argument("--root", required=True)
  1155. csr.add_argument("--common-name", required=True)
  1156. csr.set_defaults(handler=command_csr)
  1157. registration = subcommands.add_parser("register-manifest")
  1158. registration.add_argument("--root", required=True)
  1159. registration.add_argument("--certificate", required=True)
  1160. registration.add_argument("--ca-bundle", required=True)
  1161. registration.add_argument("--crl", required=True)
  1162. registration.add_argument("--gateway-id", required=True)
  1163. registration.add_argument("--environment", required=True)
  1164. registration.add_argument("--network-zone", required=True)
  1165. registration.add_argument("--policy-digest", required=True)
  1166. registration.add_argument("--control-origin", required=True)
  1167. registration.add_argument("--proxy-origin")
  1168. registration.set_defaults(handler=command_register_manifest)
  1169. rotate = subcommands.add_parser("rotate")
  1170. rotate.add_argument("--root", required=True)
  1171. rotate.add_argument("--common-name", required=True)
  1172. rotate.add_argument("--request-id", required=True)
  1173. rotate.add_argument("--expected-current-certificate-digest", required=True)
  1174. rotate.set_defaults(handler=command_rotate)
  1175. revoke = subcommands.add_parser("revoke-status")
  1176. revoke.add_argument("--root", required=True)
  1177. revoke.add_argument("--server-status")
  1178. revoke.add_argument("--trusted-key", action="append", default=[])
  1179. revoke.add_argument("--expected-gateway-id")
  1180. revoke.add_argument("--expected-generation", type=int)
  1181. revoke.add_argument("--expected-certificate-digest")
  1182. revoke.set_defaults(handler=command_revoke_status)
  1183. check = subcommands.add_parser("release-check")
  1184. check.add_argument("--root", required=True)
  1185. check.add_argument("--manifest", required=True)
  1186. check.add_argument("--artifact", required=True)
  1187. check.add_argument("--trusted-key", action="append", required=True)
  1188. check.add_argument("--current-version", required=True)
  1189. check.set_defaults(handler=command_release_check)
  1190. rollback = subcommands.add_parser("rollback")
  1191. rollback.add_argument("--root", required=True)
  1192. rollback.add_argument("--expected-current-digest", required=True)
  1193. rollback.add_argument("--target-digest", required=True)
  1194. rollback.add_argument("--trusted-key", action="append", required=True)
  1195. rollback.add_argument("--yes", action="store_true")
  1196. rollback.set_defaults(handler=command_rollback)
  1197. status_command = subcommands.add_parser("status")
  1198. status_command.add_argument("--root", required=True)
  1199. status_command.set_defaults(handler=command_status)
  1200. return main
  1201. def main() -> int:
  1202. try:
  1203. args = parser().parse_args()
  1204. if args.command in {"init", "status"}:
  1205. result = args.handler(args)
  1206. else:
  1207. root = _validated_root(args.root)
  1208. with _process_lock(root):
  1209. result = args.handler(args)
  1210. _output(result)
  1211. return 0
  1212. except AdminError as exc:
  1213. _output({"error": {"code": exc.code}, "status": "error"})
  1214. return exc.exit_code
  1215. except (OSError, ValueError, TypeError, MemoryError, RecursionError):
  1216. _output({"error": {"code": "OPERATION_FAILED"}, "status": "error"})
  1217. return 5
  1218. if __name__ == "__main__":
  1219. raise SystemExit(main())