muxplex-deck 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,382 @@
1
+ """`muxplex-deck init` -- turnkey interactive setup wizard.
2
+
3
+ Closes the gap between "just installed" and "actually working" without
4
+ requiring SSH access to the muxplex server. muxplex's own CLI auto-derives
5
+ everything on first run (password, signing secret, device_id); this wizard
6
+ gives the sidecar the same experience for the two things it genuinely can't
7
+ invent (a server URL and a shared secret), while auto-fetching the CA
8
+ certificate wherever possible instead of requiring an `scp`.
9
+
10
+ Flow (see README.md "Quickstart" for the user-facing summary):
11
+
12
+ 1. Server URL -- prompt (or take it as a positional arg / existing config).
13
+ 2. Validate -- `GET /api/instance-info`. A TLS/cert failure is *expected*
14
+ for a server on its own local CA and just means "keep going, we'll fetch
15
+ or ask for the CA next" rather than a fatal error.
16
+ 3. CA certificate -- try `GET /api/ca` (verify disabled for this one
17
+ bootstrap fetch: a CA public cert is a trust anchor, not a secret) first;
18
+ fall back to a validated local-path prompt only if that 404s AND TLS was
19
+ actually failing; skip entirely if TLS already works without one.
20
+ 4. Federation key -- reuse an existing valid key file if present, otherwise
21
+ prompt to paste it (never echoed, never logged) or read it from a local
22
+ path.
23
+ 5. Write config.json via the existing `config.patch_raw_config` (merges,
24
+ never clobbers unrelated keys).
25
+ 6. Re-verify server reachability + deck detection with the new config in
26
+ place, using the same `doctor`-style check functions and formatting.
27
+ 7. Print udev remediation if needed, offer to run `service install`.
28
+
29
+ Every step reuses the pure check helpers already in `cli.py`
30
+ (`check_server_reachable`, `check_ca_file`, `check_federation_key`,
31
+ `check_deck_detected`, `check_hid_openable`) and `service.udev_rule_exists()`
32
+ rather than duplicating their logic -- this module only adds the
33
+ interactive orchestration and the two raw data-fetch primitives
34
+ (`cli.fetch_instance_info`, `cli.fetch_ca_cert`) that check_server_reachable
35
+ alone doesn't expose.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import getpass
41
+ import hashlib
42
+ import sys
43
+ from collections.abc import Callable
44
+ from pathlib import Path
45
+
46
+ from . import cli as cli_mod
47
+ from . import config as config_mod
48
+ from . import service as service_mod
49
+
50
+
51
+ class _InitError(Exception):
52
+ """Raised internally to abort the wizard with a clear, already-final message."""
53
+
54
+
55
+ def _normalize_server_url(url: str) -> str:
56
+ """Add `https://` if no scheme was given; strip a trailing slash."""
57
+ url = url.strip()
58
+ if "://" not in url:
59
+ url = f"https://{url}"
60
+ return url.rstrip("/")
61
+
62
+
63
+ def _sha256_fingerprint(cert_bytes: bytes) -> str:
64
+ digest = hashlib.sha256(cert_bytes).hexdigest().upper()
65
+ return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
66
+
67
+
68
+ def _prompt(
69
+ label: str, *, default: str | None, input_func: Callable[[str], str]
70
+ ) -> str:
71
+ """Prompt with an optional default shown in brackets; Enter keeps it."""
72
+ if default:
73
+ raw = input_func(f"{label} [{default}]: ")
74
+ else:
75
+ raw = input_func(f"{label}: ")
76
+ raw = raw.strip()
77
+ return raw if raw else (default or "")
78
+
79
+
80
+ def _confirm(label: str, *, default: bool, input_func: Callable[[str], str]) -> bool:
81
+ suffix = "[Y/n]" if default else "[y/N]"
82
+ raw = input_func(f"{label} {suffix}: ").strip().lower()
83
+ if not raw:
84
+ return default
85
+ return raw in ("y", "yes")
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Step 2: validate the server (with a re-prompt loop on plain connection errors)
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def _validate_server(
94
+ server_url: str,
95
+ *,
96
+ non_interactive: bool,
97
+ input_func: Callable[[str], str],
98
+ ) -> tuple[str, dict | None, bool]:
99
+ """Returns (possibly-updated server_url, instance_info or None, tls_needed)."""
100
+ current = server_url
101
+ while True:
102
+ print(f"\nChecking {current} ...")
103
+ try:
104
+ data = cli_mod.fetch_instance_info(current, verify=True)
105
+ except Exception as exc:
106
+ if cli_mod._is_tls_error(exc):
107
+ print(f" ! TLS verification failed: {exc}")
108
+ print(
109
+ " This server may be using its own local CA -- continuing "
110
+ "to fetch/configure it next."
111
+ )
112
+ return current, None, True
113
+ print(f" Could not reach {current}: {exc}")
114
+ if non_interactive:
115
+ raise _InitError(f"server unreachable: {exc}") from exc
116
+ current = _prompt("Server URL", default=current, input_func=input_func)
117
+ if not current:
118
+ raise _InitError("a server URL is required")
119
+ current = _normalize_server_url(current)
120
+ continue
121
+
122
+ name = data.get("name", "?")
123
+ version = data.get("version", "?")
124
+ print(f" Found muxplex '{name}' running v{version}")
125
+ if data.get("federation_enabled") is False:
126
+ print(
127
+ " ! Federation is not enabled on this server yet -- run "
128
+ "`muxplex generate-federation-key` on the server first, then "
129
+ "re-run this wizard."
130
+ )
131
+ return current, data, False
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Step 3: CA certificate
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def _resolve_ca(
140
+ server_url: str,
141
+ *,
142
+ existing_ca: str | None,
143
+ tls_needed: bool,
144
+ non_interactive: bool,
145
+ input_func: Callable[[str], str],
146
+ ) -> str | None:
147
+ try:
148
+ ca_bytes = cli_mod.fetch_ca_cert(server_url)
149
+ except Exception as exc: # noqa: BLE001 -- network/HTTP errors from the bootstrap fetch
150
+ print(f" ! Could not query {server_url}/api/ca: {exc}")
151
+ ca_bytes = None
152
+
153
+ if ca_bytes:
154
+ ca_path = config_mod._expand("~/.config/muxplex-deck/muxplex-ca.crt")
155
+ ca_path.parent.mkdir(parents=True, exist_ok=True)
156
+ ca_path.write_bytes(ca_bytes)
157
+ ca_path.chmod(0o644)
158
+ fingerprint = _sha256_fingerprint(ca_bytes)
159
+ print(f" CA certificate fetched and saved: {ca_path}")
160
+ print(f" SHA-256 fingerprint: {fingerprint}")
161
+ print(
162
+ " Verify out-of-band on the server if you'd like:\n"
163
+ " openssl x509 -in ~/.config/muxplex/ca/muxplex-ca.crt "
164
+ "-noout -fingerprint -sha256"
165
+ )
166
+ return str(ca_path)
167
+
168
+ if not tls_needed:
169
+ print(" CA: not needed (server certificate verifies without one)")
170
+ return None
171
+
172
+ print(
173
+ " ! No CA available at /api/ca, but TLS verification failed -- "
174
+ "this server needs a CA file and we can't self-serve it."
175
+ )
176
+ if non_interactive:
177
+ raise _InitError(
178
+ "TLS verification failed and no CA certificate could be "
179
+ "auto-fetched. Provide 'ca_file' in config.json, or re-run "
180
+ "`muxplex-deck init` interactively."
181
+ )
182
+
183
+ default_path = existing_ca
184
+ while True:
185
+ entered = _prompt(
186
+ "Path to the server's CA certificate file",
187
+ default=default_path,
188
+ input_func=input_func,
189
+ )
190
+ if not entered:
191
+ raise _InitError(
192
+ "a CA certificate path is required (TLS verification is failing)"
193
+ )
194
+ candidate = config_mod._expand(entered)
195
+ status, message = cli_mod.check_ca_file(candidate)
196
+ if status == "ok":
197
+ return str(candidate)
198
+ print(f" ! {message}")
199
+ default_path = entered
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Step 4: federation key -- never printed, never logged
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ def _resolve_federation_key(
208
+ key_file_path: Path,
209
+ *,
210
+ non_interactive: bool,
211
+ input_func: Callable[[str], str],
212
+ getpass_func: Callable[[str], str],
213
+ ) -> None:
214
+ if key_file_path.exists() and key_file_path.stat().st_size > 0:
215
+ if non_interactive:
216
+ print(f" Federation key: keeping existing {key_file_path}")
217
+ return
218
+ if _confirm(
219
+ f"Federation key already exists at {key_file_path}. Keep it?",
220
+ default=True,
221
+ input_func=input_func,
222
+ ):
223
+ print(f" Federation key: keeping existing {key_file_path}")
224
+ return
225
+
226
+ if non_interactive:
227
+ raise _InitError(
228
+ f"federation key not found at {key_file_path}. Paste it "
229
+ "interactively once (re-run without --non-interactive), or "
230
+ f"copy it there yourself first, e.g.:\n"
231
+ f" scp <your-server>:.config/muxplex/federation_key {key_file_path}"
232
+ )
233
+
234
+ print(" Paste the federation key, or enter a local file path containing it.")
235
+ print(
236
+ " Get it from the server with `muxplex generate-federation-key`, or "
237
+ "read ~/.config/muxplex/federation_key there. SSH fallback:"
238
+ )
239
+ print(f" scp <your-server>:.config/muxplex/federation_key {key_file_path}")
240
+ pasted = getpass_func("Federation key (or path): ")
241
+ value = pasted.strip()
242
+ candidate_path = Path(value).expanduser() if value else None
243
+
244
+ if candidate_path is not None and candidate_path.is_file():
245
+ key_value = candidate_path.read_text(encoding="utf-8").strip()
246
+ else:
247
+ key_value = value
248
+
249
+ if not key_value:
250
+ raise _InitError("no federation key provided")
251
+
252
+ key_file_path.parent.mkdir(parents=True, exist_ok=True)
253
+ key_file_path.parent.chmod(0o700)
254
+ key_file_path.write_text(key_value + "\n", encoding="utf-8")
255
+ key_file_path.chmod(0o600)
256
+ print(f" Federation key written: {key_file_path} (mode 0600)")
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Orchestration
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def _run_init_impl(
265
+ config_path: str | None,
266
+ server_url_arg: str | None,
267
+ *,
268
+ non_interactive: bool,
269
+ input_func: Callable[[str], str],
270
+ getpass_func: Callable[[str], str],
271
+ ) -> int:
272
+ raw = config_mod.load_raw_config(config_path)
273
+
274
+ print("\nmuxplex-deck init\n")
275
+
276
+ # Step 1: server URL
277
+ existing_url = raw.get("server_url") or None
278
+ if server_url_arg:
279
+ server_url = server_url_arg
280
+ elif non_interactive:
281
+ if not existing_url:
282
+ raise _InitError(
283
+ "SERVER_URL is required in --non-interactive mode, e.g.:\n"
284
+ " muxplex-deck init https://your-server:8088 --non-interactive"
285
+ )
286
+ server_url = existing_url
287
+ else:
288
+ entered = _prompt("Server URL", default=existing_url, input_func=input_func)
289
+ if not entered:
290
+ raise _InitError("a server URL is required")
291
+ server_url = entered
292
+ server_url = _normalize_server_url(server_url)
293
+
294
+ # Step 2: validate
295
+ server_url, _instance_data, tls_needed = _validate_server(
296
+ server_url, non_interactive=non_interactive, input_func=input_func
297
+ )
298
+
299
+ # Step 3: CA certificate
300
+ print()
301
+ ca_file_value = _resolve_ca(
302
+ server_url,
303
+ existing_ca=raw.get("ca_file") or None,
304
+ tls_needed=tls_needed,
305
+ non_interactive=non_interactive,
306
+ input_func=input_func,
307
+ )
308
+
309
+ # Step 4: federation key
310
+ print()
311
+ key_file_raw = raw.get("key_file") or config_mod.DEFAULT_KEY_FILE
312
+ _resolve_federation_key(
313
+ config_mod._expand(key_file_raw),
314
+ non_interactive=non_interactive,
315
+ input_func=input_func,
316
+ getpass_func=getpass_func,
317
+ )
318
+
319
+ # Step 5: write config (merge -- preserves sort/poll_interval/focus_app/etc.)
320
+ patch: dict = {"server_url": server_url, "key_file": key_file_raw}
321
+ if ca_file_value:
322
+ patch["ca_file"] = ca_file_value
323
+ config_mod.patch_raw_config(patch, config_path)
324
+ resolved_path = config_mod._resolve_config_path(config_path)
325
+ print(f"\nConfig saved: {resolved_path}")
326
+
327
+ # Step 6: verify
328
+ print("\nVerifying...")
329
+ final_raw = config_mod.load_raw_config(config_path)
330
+ ca_path = (
331
+ config_mod._expand(final_raw["ca_file"]) if final_raw.get("ca_file") else None
332
+ )
333
+ status, message = cli_mod.check_server_reachable(server_url, ca_path)
334
+ cli_mod.print_check(status, message)
335
+ status, message = cli_mod.check_deck_detected(config_path)
336
+ cli_mod.print_check(status, message)
337
+ status, message = cli_mod.check_hid_openable()
338
+ cli_mod.print_check(status, message)
339
+
340
+ # Step 7: next steps
341
+ if sys.platform not in ("darwin", "win32") and not service_mod.udev_rule_exists():
342
+ print()
343
+ print(service_mod._UDEV_REMEDIATION)
344
+
345
+ if not non_interactive and _confirm(
346
+ "\nRun `muxplex-deck service install` now?",
347
+ default=False,
348
+ input_func=input_func,
349
+ ):
350
+ service_mod.service_install()
351
+
352
+ print("\nYou're set up -- run `muxplex-deck` or `muxplex-deck service install`.\n")
353
+ return 0
354
+
355
+
356
+ def run_init(
357
+ config_path: str | None,
358
+ server_url_arg: str | None = None,
359
+ *,
360
+ non_interactive: bool = False,
361
+ input_func: Callable[[str], str] = input,
362
+ getpass_func: Callable[[str], str] = getpass.getpass,
363
+ ) -> int:
364
+ """Run the interactive setup wizard. Idempotent and safe to re-run.
365
+
366
+ Returns a process exit code (0 success, 1 a required input/validation
367
+ failed, 130 aborted via Ctrl-C/EOF before any config was written).
368
+ """
369
+ try:
370
+ return _run_init_impl(
371
+ config_path,
372
+ server_url_arg,
373
+ non_interactive=non_interactive,
374
+ input_func=input_func,
375
+ getpass_func=getpass_func,
376
+ )
377
+ except _InitError as exc:
378
+ print(f"\nERROR: {exc}", file=sys.stderr)
379
+ return 1
380
+ except (KeyboardInterrupt, EOFError):
381
+ print("\n\nAborted -- no changes made.", file=sys.stderr)
382
+ return 130