Skip to content
File

Blob: scripts/credentials.py

python101 lines
1"""Canonical private credentials and the generated Wrangler development export."""
2import os
3import secrets
4import tempfile
5 
6from config import ENV_ASSIGNMENT, env_value, read_env
7 
8WORKER_KEYS = ("REALTIME_APP_ID", "REALTIME_APP_TOKEN", "DEVICE_TOKEN", "VIEWER_PASSWORD")
9GENERATED_KEYS = ("DEVICE_TOKEN", "VIEWER_PASSWORD")
10EXPORT_HEADER = "# Generated from ../.credential.env. Edit that file; this export is overwritten.\n"
11 
12 
13def private_write(path, contents):
14 """Replace a private file atomically, keeping unchanged exports stable."""
15 if path.exists() and path.read_text() == contents:
16 path.chmod(0o600)
17 return
18 descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
19 try:
20 with os.fdopen(descriptor, "w") as output:
21 output.write(contents)
22 os.replace(name, path)
23 finally:
24 if os.path.exists(name):
25 os.unlink(name)
26 
27 
28def validate_worker(values):
29 missing = [key for key in WORKER_KEYS if not values.get(key)]
30 if missing:
31 raise SystemExit(
32 f"Set {', '.join(missing)} in .credential.env; run make secrets for initial setup."
33 )
34 if not 32 <= len(values["DEVICE_TOKEN"]) <= 128:
35 raise SystemExit("DEVICE_TOKEN in .credential.env must be 32–128 characters")
36 if len(values["VIEWER_PASSWORD"].encode("utf-16-le")) // 2 > 256:
37 raise SystemExit("VIEWER_PASSWORD in .credential.env must be at most 256 characters")
38 return {key: values[key] for key in WORKER_KEYS}
39 
40 
41def worker_secrets(root):
42 path = root / ".credential.env"
43 if not path.exists():
44 raise SystemExit("Create .credential.env from .credential.env.example and run make secrets")
45 return validate_worker(read_env(path))
46 
47 
48def export_contents(values):
49 return EXPORT_HEADER + "".join(f"{key}={env_value(key, values[key])}\n" for key in WORKER_KEYS)
50 
51 
52def sync_worker_secrets(root, *, optional=False):
53 """Refresh only the export; ordinary commands never create credentials."""
54 source = root / ".credential.env"
55 export = root / "worker/.dev.vars"
56 if optional and not source.exists() and not export.exists():
57 return
58 values = worker_secrets(root)
59 private_write(export, export_contents(values))
60 
61 
62def initialize_credentials(root):
63 """Import credentials from an older export, or generate them during first setup."""
64 source = root / ".credential.env"
65 export = root / "worker/.dev.vars"
66 if not source.exists():
67 raise SystemExit("Create .credential.env from .credential.env.example first")
68 contents = source.read_text()
69 values = read_env(source)
70 missing = [key for key in GENERATED_KEYS if not values.get(key)]
71 updates = {}
72 if missing and export.exists():
73 if export.read_text().startswith(EXPORT_HEADER):
74 raise SystemExit(
75 f"Restore {', '.join(missing)} in .credential.env; credentials are already initialized."
76 )
77 legacy = read_env(export)
78 if any(not legacy.get(key) for key in missing):
79 raise SystemExit("Legacy worker/.dev.vars is incomplete; restore its device/viewer credentials first")
80 updates = {key: legacy[key] for key in missing}
81 elif missing:
82 updates = {key: secrets.token_urlsafe(32) for key in missing}
83 values.update(updates)
84 rendered = export_contents(validate_worker(values))
85 remaining = updates.copy()
86 lines = []
87 for line in contents.splitlines(keepends=True):
88 match = ENV_ASSIGNMENT.fullmatch(line.rstrip("\r\n"))
89 if match and match[1] in remaining:
90 key = match[1]
91 lines.append(f"{key}={env_value(key, remaining.pop(key))}\n")
92 else:
93 lines.append(line)
94 contents = "".join(lines)
95 if remaining:
96 if contents and not contents.endswith("\n"):
97 contents += "\n"
98 contents += "".join(f"{key}={env_value(key, value)}\n" for key, value in remaining.items())
99 private_write(source, contents)
100 private_write(export, rendered)