@ao_zorin/zocket 1.0.0

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.
package/zocket/cli.py ADDED
@@ -0,0 +1,655 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from getpass import getpass
9
+ from pathlib import Path
10
+
11
+ from cryptography.fernet import Fernet
12
+ from waitress import serve
13
+
14
+ from .audit import AuditLogger
15
+ from .auth import hash_password
16
+ from .autostart import install_autostart, remove_autostart, status_autostart
17
+ from .backup import create_backup, list_backups, restore_backup
18
+ from .config_store import ConfigStore
19
+ from .crypto import (
20
+ decrypt_payload,
21
+ delete_key,
22
+ encrypt_payload,
23
+ generate_master_key,
24
+ load_key,
25
+ store_key,
26
+ )
27
+ from .harden import install_linux_system_services
28
+ from .i18n import normalize_lang, tr
29
+ from .mcp_server import run_server
30
+ from .paths import (
31
+ audit_log_path,
32
+ backups_dir,
33
+ config_path,
34
+ ensure_dirs,
35
+ key_path,
36
+ lock_path,
37
+ vault_path,
38
+ )
39
+ from .runner import ExecPolicyError, run_with_env, run_with_env_limited
40
+ from .vault import ProjectNotFoundError, VaultError, empty_vault, VaultService
41
+ from .web import create_web_app
42
+
43
+
44
+ @dataclass
45
+ class AppContext:
46
+ vault: VaultService
47
+ cfg_store: ConfigStore
48
+ cfg: dict
49
+ audit: AuditLogger
50
+ lang: str
51
+
52
+
53
+ def t(ctx: AppContext, message_id: str, **kwargs: object) -> str:
54
+ return tr(ctx.lang, message_id, **kwargs)
55
+
56
+
57
+ def parser() -> argparse.ArgumentParser:
58
+ p = argparse.ArgumentParser(
59
+ prog="zocket",
60
+ description="Local MCP + web secret vault for AI-assisted workflows",
61
+ )
62
+ p.add_argument("--vault-path", help="Path to encrypted vault file")
63
+ p.add_argument("--key-path", help="Path to master key file")
64
+ p.add_argument("--lock-path", help="Path to lock file")
65
+ p.add_argument("--lang", choices=["en", "ru"], help="CLI language")
66
+
67
+ sub = p.add_subparsers(dest="action", required=True)
68
+
69
+ init_p = sub.add_parser("init", help="Create master key and initialize vault")
70
+ init_p.add_argument("--force", action="store_true", help="Overwrite existing key")
71
+ init_p.add_argument("--autostart", action="store_true")
72
+ init_p.add_argument("--key-storage", choices=["file", "keyring"], default=None)
73
+
74
+ mcp_p = sub.add_parser("mcp", help="Run MCP server")
75
+ mcp_p.add_argument(
76
+ "--transport",
77
+ default="stdio",
78
+ choices=["stdio", "sse", "streamable-http"],
79
+ )
80
+ mcp_p.add_argument(
81
+ "--mode",
82
+ default="metadata",
83
+ choices=["metadata", "admin"],
84
+ help="metadata: no secret use/mutation tools; admin: full toolset",
85
+ )
86
+ mcp_p.add_argument("--host", default="127.0.0.1")
87
+ mcp_p.add_argument("--port", type=int, default=18002)
88
+
89
+ web_p = sub.add_parser("web", help="Run local web UI")
90
+ web_p.add_argument("--host", default="127.0.0.1")
91
+ web_p.add_argument("--port", type=int, default=18001)
92
+ web_p.add_argument("--threads", type=int, default=8)
93
+
94
+ project_p = sub.add_parser("projects", help="Project operations")
95
+ project_sub = project_p.add_subparsers(dest="project_cmd", required=True)
96
+ project_sub.add_parser("list", help="List projects")
97
+ project_create = project_sub.add_parser("create", help="Create project")
98
+ project_create.add_argument("name")
99
+ project_create.add_argument("--description", default="")
100
+ project_create.add_argument("--folder", default="")
101
+ project_folder = project_sub.add_parser(
102
+ "set-folder", help="Set or clear project folder path"
103
+ )
104
+ project_folder.add_argument("name")
105
+ project_folder_group = project_folder.add_mutually_exclusive_group(required=True)
106
+ project_folder_group.add_argument("--folder")
107
+ project_folder_group.add_argument("--clear", action="store_true")
108
+ project_match = project_sub.add_parser(
109
+ "match-path", help="Find project mapped to filesystem path"
110
+ )
111
+ project_match.add_argument("path", nargs="?", default=".")
112
+ project_delete = project_sub.add_parser("delete", help="Delete project")
113
+ project_delete.add_argument("name")
114
+
115
+ secret_p = sub.add_parser("secrets", help="Secret operations")
116
+ secret_sub = secret_p.add_subparsers(dest="secret_cmd", required=True)
117
+ secret_list = secret_sub.add_parser("list", help="List secret keys in project")
118
+ secret_list.add_argument("project")
119
+ secret_list.add_argument("--show-values", action="store_true")
120
+ secret_set = secret_sub.add_parser("set", help="Set secret")
121
+ secret_set.add_argument("project")
122
+ secret_set.add_argument("key")
123
+ secret_set.add_argument("value")
124
+ secret_set.add_argument("--description", default="")
125
+ secret_del = secret_sub.add_parser("delete", help="Delete secret")
126
+ secret_del.add_argument("project")
127
+ secret_del.add_argument("key")
128
+
129
+ use_p = sub.add_parser("use", help="Run command with project env")
130
+ use_p.add_argument("project")
131
+ use_p.add_argument("--full-output", action="store_true")
132
+ use_p.add_argument("--no-subst", action="store_true")
133
+ use_p.add_argument("exec_command", nargs=argparse.REMAINDER)
134
+
135
+ auto_p = sub.add_parser("autostart", help="Manage OS autostart services")
136
+ auto_sub = auto_p.add_subparsers(dest="autostart_cmd", required=True)
137
+ auto_install = auto_sub.add_parser("install", help="Install and enable autostart")
138
+ auto_install.add_argument("--target", choices=["web", "mcp", "both"], default="both")
139
+ auto_install.add_argument("--web-host", default="127.0.0.1")
140
+ auto_install.add_argument("--web-port", type=int, default=18001)
141
+ auto_install.add_argument("--mcp-host", default="127.0.0.1")
142
+ auto_install.add_argument("--mcp-port", type=int, default=18002)
143
+ auto_install.add_argument("--mcp-mode", choices=["metadata", "admin"], default="metadata")
144
+ auto_install.add_argument("--zocket-home", help="ZOCKET_HOME for generated units")
145
+ auto_install.add_argument("--dry-run", action="store_true")
146
+ auto_remove = auto_sub.add_parser("remove", help="Disable and remove autostart")
147
+ auto_remove.add_argument("--target", choices=["web", "mcp", "both"], default="both")
148
+ auto_status = auto_sub.add_parser("status", help="Show autostart status")
149
+ auto_status.add_argument("--target", choices=["web", "mcp", "both"], default="both")
150
+
151
+ cfg_p = sub.add_parser("config", help="Config operations")
152
+ cfg_sub = cfg_p.add_subparsers(dest="cfg_cmd", required=True)
153
+ cfg_sub.add_parser("show")
154
+ cfg_lang = cfg_sub.add_parser("set-language")
155
+ cfg_lang.add_argument("language", choices=["en", "ru"])
156
+ cfg_key_storage = cfg_sub.add_parser("set-key-storage")
157
+ cfg_key_storage.add_argument("storage", choices=["file", "keyring"])
158
+
159
+ auth_p = sub.add_parser("auth", help="Web auth operations")
160
+ auth_sub = auth_p.add_subparsers(dest="auth_cmd", required=True)
161
+ auth_set = auth_sub.add_parser("set-password")
162
+ auth_set.add_argument("--password")
163
+ auth_sub.add_parser("enable")
164
+ auth_sub.add_parser("disable")
165
+
166
+ key_p = sub.add_parser("key", help="Master key operations")
167
+ key_sub = key_p.add_subparsers(dest="key_cmd", required=True)
168
+ key_rotate = key_sub.add_parser("rotate")
169
+ key_rotate.add_argument("--to-storage", choices=["file", "keyring"])
170
+
171
+ backup_p = sub.add_parser("backup", help="Backup operations")
172
+ backup_sub = backup_p.add_subparsers(dest="backup_cmd", required=True)
173
+ backup_create = backup_sub.add_parser("create")
174
+ backup_create.add_argument("--output")
175
+ backup_sub.add_parser("list")
176
+ backup_restore = backup_sub.add_parser("restore")
177
+ backup_restore.add_argument("backup_file")
178
+
179
+ audit_p = sub.add_parser("audit", help="Audit log operations")
180
+ audit_sub = audit_p.add_subparsers(dest="audit_cmd", required=True)
181
+ audit_tail = audit_sub.add_parser("tail")
182
+ audit_tail.add_argument("--lines", type=int, default=50)
183
+ audit_check = audit_sub.add_parser("check")
184
+ audit_check.add_argument("--minutes", type=int, default=60)
185
+ audit_check.add_argument("--failed-login-threshold", type=int, default=5)
186
+
187
+ harden_p = sub.add_parser("harden", help="OS hardening helpers")
188
+ harden_sub = harden_p.add_subparsers(dest="harden_cmd", required=True)
189
+ harden_install = harden_sub.add_parser("install-linux-system")
190
+ harden_install.add_argument("--service-user", default="zocketd")
191
+ harden_install.add_argument("--zocket-home", default="/var/lib/zocket")
192
+ harden_install.add_argument("--web-port", type=int, default=18001)
193
+ harden_install.add_argument("--mcp-host", default="127.0.0.1")
194
+ harden_install.add_argument("--mcp-port", type=int, default=18002)
195
+ harden_install.add_argument("--mcp-mode", choices=["metadata", "admin"], default="metadata")
196
+ harden_install.add_argument("--dry-run", action="store_true")
197
+
198
+ return p
199
+
200
+
201
+ def _json(data: object) -> None:
202
+ print(json.dumps(data, ensure_ascii=False, indent=2))
203
+
204
+
205
+ def _harden_permissions(path: Path, mode: int) -> None:
206
+ if path.exists():
207
+ os.chmod(path, mode)
208
+
209
+
210
+ def build_context(args: argparse.Namespace) -> AppContext:
211
+ ensure_dirs()
212
+ cfg_store = ConfigStore(config_path())
213
+ cfg = cfg_store.ensure_exists()
214
+ lang = normalize_lang(args.lang or str(cfg.get("language", "en")))
215
+
216
+ v_path = Path(args.vault_path).expanduser() if args.vault_path else vault_path()
217
+ k_path = Path(args.key_path).expanduser() if args.key_path else key_path()
218
+ l_path = Path(args.lock_path).expanduser() if args.lock_path else lock_path()
219
+
220
+ vault = VaultService(
221
+ vault_file=v_path,
222
+ key_file=k_path,
223
+ lock_file=l_path,
224
+ key_storage=str(cfg.get("key_storage", "file")),
225
+ keyring_service=str(cfg.get("keyring_service", "zocket")),
226
+ keyring_account=str(cfg.get("keyring_account", "master-key")),
227
+ )
228
+ audit = AuditLogger(
229
+ path=audit_log_path(),
230
+ enabled=bool(cfg.get("audit_enabled", True)),
231
+ )
232
+ return AppContext(vault=vault, cfg_store=cfg_store, cfg=cfg, audit=audit, lang=lang)
233
+
234
+
235
+ def cmd_init(args: argparse.Namespace, ctx: AppContext) -> int:
236
+ storage = args.key_storage or str(ctx.cfg.get("key_storage", "file"))
237
+ if args.key_storage:
238
+ ctx.cfg["key_storage"] = args.key_storage
239
+ ctx.cfg_store.save(ctx.cfg)
240
+ ctx.vault.key_storage = args.key_storage
241
+ generate_master_key(
242
+ path=ctx.vault.key_file,
243
+ storage=storage,
244
+ keyring_service=str(ctx.cfg.get("keyring_service", "zocket")),
245
+ keyring_account=str(ctx.cfg.get("keyring_account", "master-key")),
246
+ force=args.force,
247
+ )
248
+ ctx.vault._cached_key = None
249
+ ctx.vault.ensure_initialized()
250
+ _harden_permissions(ctx.vault.vault_file, 0o600)
251
+ _harden_permissions(ctx.vault.key_file, 0o600)
252
+ _harden_permissions(ctx.cfg_store.path, 0o600)
253
+ _harden_permissions(ctx.vault.vault_file.parent, 0o700)
254
+ print(t(ctx, "msg.key_file", path=ctx.vault.key_file))
255
+ print(t(ctx, "msg.vault_file", path=ctx.vault.vault_file))
256
+ print(t(ctx, "msg.init_complete"))
257
+ ctx.audit.log("cli.init", "ok", "cli", {"storage": storage})
258
+
259
+ if args.autostart:
260
+ result = install_autostart(
261
+ target="both",
262
+ web_host="127.0.0.1",
263
+ web_port=18001,
264
+ mcp_host="127.0.0.1",
265
+ mcp_port=18002,
266
+ mcp_mode="metadata",
267
+ zocket_home=ctx.vault.vault_file.parent,
268
+ dry_run=False,
269
+ )
270
+ _json(result)
271
+ return 0
272
+
273
+
274
+ def cmd_projects(args: argparse.Namespace, ctx: AppContext) -> int:
275
+ ctx.vault.ensure_initialized()
276
+ if args.project_cmd == "list":
277
+ _json(ctx.vault.list_projects())
278
+ ctx.audit.log("cli.projects.list", "ok", "cli")
279
+ return 0
280
+ if args.project_cmd == "create":
281
+ folder_path = args.folder.strip() if args.folder else None
282
+ ctx.vault.create_project(
283
+ args.name,
284
+ description=args.description,
285
+ folder_path=folder_path,
286
+ )
287
+ print(t(ctx, "msg.project_created", name=args.name))
288
+ ctx.audit.log(
289
+ "cli.projects.create",
290
+ "ok",
291
+ "cli",
292
+ {"project": args.name, "folder_path": folder_path},
293
+ )
294
+ return 0
295
+ if args.project_cmd == "set-folder":
296
+ folder_path = None if args.clear else args.folder
297
+ ctx.vault.set_project_folder(args.name, folder_path)
298
+ if args.clear:
299
+ print(t(ctx, "msg.project_folder_cleared", name=args.name))
300
+ else:
301
+ print(t(ctx, "msg.project_folder_set", name=args.name))
302
+ ctx.audit.log(
303
+ "cli.projects.set_folder",
304
+ "ok",
305
+ "cli",
306
+ {"project": args.name, "folder_path": folder_path or ""},
307
+ )
308
+ return 0
309
+ if args.project_cmd == "match-path":
310
+ match = ctx.vault.find_project_by_path(args.path)
311
+ _json(match if match else {})
312
+ ctx.audit.log(
313
+ "cli.projects.match_path",
314
+ "ok",
315
+ "cli",
316
+ {"path": args.path, "matched": bool(match)},
317
+ )
318
+ return 0
319
+ if args.project_cmd == "delete":
320
+ ctx.vault.delete_project(args.name)
321
+ print(t(ctx, "msg.project_deleted", name=args.name))
322
+ ctx.audit.log("cli.projects.delete", "ok", "cli", {"project": args.name})
323
+ return 0
324
+ raise ValueError(f"Unsupported project command: {args.project_cmd}")
325
+
326
+
327
+ def cmd_secrets(args: argparse.Namespace, ctx: AppContext) -> int:
328
+ ctx.vault.ensure_initialized()
329
+ if args.secret_cmd == "list":
330
+ payload = ctx.vault.list_project_secrets(args.project, include_values=bool(args.show_values))
331
+ _json(payload)
332
+ ctx.audit.log("cli.secrets.list", "ok", "cli", {"project": args.project})
333
+ return 0
334
+ if args.secret_cmd == "set":
335
+ ctx.vault.upsert_secret(
336
+ project=args.project,
337
+ key=args.key,
338
+ value=args.value,
339
+ description=args.description,
340
+ )
341
+ print(t(ctx, "msg.secret_saved", key=args.key, project=args.project))
342
+ ctx.audit.log(
343
+ "cli.secrets.set",
344
+ "ok",
345
+ "cli",
346
+ {"project": args.project, "key": args.key},
347
+ )
348
+ return 0
349
+ if args.secret_cmd == "delete":
350
+ ctx.vault.delete_secret(project=args.project, key=args.key)
351
+ print(t(ctx, "msg.secret_deleted", key=args.key, project=args.project))
352
+ ctx.audit.log(
353
+ "cli.secrets.delete",
354
+ "ok",
355
+ "cli",
356
+ {"project": args.project, "key": args.key},
357
+ )
358
+ return 0
359
+ raise ValueError(f"Unsupported secrets command: {args.secret_cmd}")
360
+
361
+
362
+ def cmd_use(args: argparse.Namespace, ctx: AppContext) -> int:
363
+ ctx.vault.ensure_initialized()
364
+ command = list(args.exec_command)
365
+ if command and command[0] == "--":
366
+ command = command[1:]
367
+ if not command:
368
+ print(t(ctx, "err.usage_use"), file=sys.stderr)
369
+ return 2
370
+ env = ctx.vault.get_project_env(args.project)
371
+ cfg = ctx.cfg_store.load()
372
+ allowlist = cfg.get("exec_allowlist") or []
373
+ return_output = bool(cfg.get("exec_return_output", True))
374
+ allow_full_output = bool(cfg.get("exec_allow_full_output", False))
375
+ substitute_env = bool(cfg.get("exec_substitute_env", True))
376
+ if args.no_subst:
377
+ substitute_env = False
378
+ try:
379
+ max_output = int(cfg.get("exec_max_output_chars", 0))
380
+ except (TypeError, ValueError):
381
+ max_output = 0
382
+ if not return_output:
383
+ max_output = 0
384
+ if args.full_output and not allow_full_output:
385
+ print("Full output is not allowed by policy.", file=sys.stderr)
386
+ return 3
387
+ output_limit = None if args.full_output else max_output
388
+ try:
389
+ result = run_with_env_limited(
390
+ command=command,
391
+ project_env=env,
392
+ allowlist=allowlist,
393
+ max_output_chars=output_limit,
394
+ substitute_env=substitute_env,
395
+ )
396
+ except ExecPolicyError as exc:
397
+ print(str(exc), file=sys.stderr)
398
+ return 3
399
+ if result.stdout:
400
+ print(result.stdout, end="")
401
+ if result.stderr:
402
+ print(result.stderr, end="", file=sys.stderr)
403
+ ctx.audit.log(
404
+ "cli.use",
405
+ "ok" if result.exit_code == 0 else "failed",
406
+ "cli",
407
+ {"project": args.project, "exit_code": result.exit_code},
408
+ )
409
+ return result.exit_code
410
+
411
+
412
+ def cmd_web(args: argparse.Namespace, ctx: AppContext) -> int:
413
+ ctx.vault.ensure_initialized()
414
+ app = create_web_app(ctx.vault, ctx.cfg_store, ctx.audit)
415
+ serve(app, host=args.host, port=args.port, threads=args.threads)
416
+ return 0
417
+
418
+
419
+ def cmd_mcp(args: argparse.Namespace, ctx: AppContext) -> int:
420
+ ctx.vault.ensure_initialized()
421
+ run_server(
422
+ vault=ctx.vault,
423
+ transport=args.transport,
424
+ mode=args.mode,
425
+ audit=ctx.audit,
426
+ host=args.host,
427
+ port=args.port,
428
+ )
429
+ return 0
430
+
431
+
432
+ def cmd_autostart(args: argparse.Namespace, ctx: AppContext) -> int:
433
+ if args.autostart_cmd == "install":
434
+ z_home = Path(args.zocket_home).expanduser() if args.zocket_home else ctx.vault.vault_file.parent
435
+ result = install_autostart(
436
+ target=args.target,
437
+ web_host=args.web_host,
438
+ web_port=args.web_port,
439
+ mcp_host=args.mcp_host,
440
+ mcp_port=args.mcp_port,
441
+ mcp_mode=args.mcp_mode,
442
+ zocket_home=z_home,
443
+ dry_run=bool(args.dry_run),
444
+ )
445
+ elif args.autostart_cmd == "remove":
446
+ result = remove_autostart(target=args.target)
447
+ elif args.autostart_cmd == "status":
448
+ result = status_autostart(target=args.target)
449
+ else:
450
+ raise ValueError(f"Unsupported autostart command: {args.autostart_cmd}")
451
+ _json(result)
452
+ return 0 if result.get("ok", False) else 1
453
+
454
+
455
+ def cmd_config(args: argparse.Namespace, ctx: AppContext) -> int:
456
+ if args.cfg_cmd == "show":
457
+ _json(ctx.cfg_store.load())
458
+ return 0
459
+ if args.cfg_cmd == "set-language":
460
+ cfg = ctx.cfg_store.load()
461
+ cfg["language"] = args.language
462
+ ctx.cfg_store.save(cfg)
463
+ print(t(ctx, "msg.language_set", lang=args.language))
464
+ ctx.audit.log("cli.config.language", "ok", "cli", {"language": args.language})
465
+ return 0
466
+ if args.cfg_cmd == "set-key-storage":
467
+ cfg = ctx.cfg_store.load()
468
+ cfg["key_storage"] = args.storage
469
+ ctx.cfg_store.save(cfg)
470
+ _json(cfg)
471
+ ctx.audit.log("cli.config.key_storage", "ok", "cli", {"storage": args.storage})
472
+ return 0
473
+ raise ValueError(f"Unsupported config command: {args.cfg_cmd}")
474
+
475
+
476
+ def cmd_auth(args: argparse.Namespace, ctx: AppContext) -> int:
477
+ cfg = ctx.cfg_store.load()
478
+ if args.auth_cmd == "set-password":
479
+ password = args.password
480
+ if not password:
481
+ first = getpass(f"{tr(ctx.lang, 'ui.password')}: ")
482
+ second = getpass(f"{tr(ctx.lang, 'ui.password')} (repeat): ")
483
+ if first != second:
484
+ raise RuntimeError("Passwords do not match")
485
+ password = first
486
+ salt_hex, hash_hex = hash_password(password)
487
+ cfg["web_password_salt"] = salt_hex
488
+ cfg["web_password_hash"] = hash_hex
489
+ cfg["web_auth_enabled"] = True
490
+ ctx.cfg_store.save(cfg)
491
+ print(t(ctx, "msg.password_set"))
492
+ ctx.audit.log("cli.auth.set_password", "ok", "cli")
493
+ return 0
494
+ if args.auth_cmd == "enable":
495
+ cfg["web_auth_enabled"] = True
496
+ ctx.cfg_store.save(cfg)
497
+ _json(cfg)
498
+ ctx.audit.log("cli.auth.enable", "ok", "cli")
499
+ return 0
500
+ if args.auth_cmd == "disable":
501
+ cfg["web_auth_enabled"] = False
502
+ ctx.cfg_store.save(cfg)
503
+ _json(cfg)
504
+ ctx.audit.log("cli.auth.disable", "ok", "cli")
505
+ return 0
506
+ raise ValueError(f"Unsupported auth command: {args.auth_cmd}")
507
+
508
+
509
+ def cmd_key(args: argparse.Namespace, ctx: AppContext) -> int:
510
+ if args.key_cmd != "rotate":
511
+ raise ValueError(f"Unsupported key command: {args.key_cmd}")
512
+
513
+ current_cfg = ctx.cfg_store.load()
514
+ current_storage = str(current_cfg.get("key_storage", "file"))
515
+ target_storage = args.to_storage or current_storage
516
+ old_key = load_key(
517
+ ctx.vault.key_file,
518
+ storage=current_storage,
519
+ keyring_service=str(current_cfg.get("keyring_service", "zocket")),
520
+ keyring_account=str(current_cfg.get("keyring_account", "master-key")),
521
+ )
522
+ if ctx.vault.vault_file.exists() and ctx.vault.vault_file.read_bytes():
523
+ payload = decrypt_payload(ctx.vault.vault_file.read_bytes(), old_key)
524
+ else:
525
+ payload = empty_vault()
526
+ new_key = Fernet.generate_key()
527
+ ciphertext = encrypt_payload(payload, new_key)
528
+ ctx.vault.vault_file.write_bytes(ciphertext)
529
+ store_key(
530
+ new_key,
531
+ path=ctx.vault.key_file,
532
+ storage=target_storage,
533
+ keyring_service=str(current_cfg.get("keyring_service", "zocket")),
534
+ keyring_account=str(current_cfg.get("keyring_account", "master-key")),
535
+ )
536
+ if target_storage != current_storage:
537
+ delete_key(
538
+ ctx.vault.key_file,
539
+ storage=current_storage,
540
+ keyring_service=str(current_cfg.get("keyring_service", "zocket")),
541
+ keyring_account=str(current_cfg.get("keyring_account", "master-key")),
542
+ )
543
+ current_cfg["key_storage"] = target_storage
544
+ ctx.cfg_store.save(current_cfg)
545
+ _harden_permissions(ctx.vault.vault_file, 0o600)
546
+ _harden_permissions(ctx.vault.key_file, 0o600)
547
+ ctx.audit.log("cli.key.rotate", "ok", "cli", {"target_storage": target_storage})
548
+ _json({"ok": True, "target_storage": target_storage})
549
+ return 0
550
+
551
+
552
+ def cmd_backup(args: argparse.Namespace, ctx: AppContext) -> int:
553
+ if args.backup_cmd == "create":
554
+ if args.output:
555
+ out = Path(args.output).expanduser()
556
+ out.parent.mkdir(parents=True, exist_ok=True)
557
+ out.write_bytes(ctx.vault.vault_file.read_bytes())
558
+ target = out
559
+ else:
560
+ target = create_backup(ctx.vault.vault_file, backups_dir())
561
+ _json({"ok": True, "backup_file": str(target)})
562
+ ctx.audit.log("cli.backup.create", "ok", "cli", {"backup_file": str(target)})
563
+ return 0
564
+ if args.backup_cmd == "list":
565
+ rows = [{"path": str(p), "size": p.stat().st_size} for p in list_backups(backups_dir())]
566
+ _json(rows)
567
+ return 0
568
+ if args.backup_cmd == "restore":
569
+ restored = restore_backup(ctx.vault.vault_file, Path(args.backup_file).expanduser())
570
+ _json({"ok": True, "restored_to": str(restored)})
571
+ ctx.audit.log("cli.backup.restore", "ok", "cli", {"backup_file": args.backup_file})
572
+ return 0
573
+ raise ValueError(f"Unsupported backup command: {args.backup_cmd}")
574
+
575
+
576
+ def cmd_audit(args: argparse.Namespace, ctx: AppContext) -> int:
577
+ if args.audit_cmd == "tail":
578
+ _json(ctx.audit.tail(args.lines))
579
+ return 0
580
+ if args.audit_cmd == "check":
581
+ failed = ctx.audit.failed_logins(minutes=args.minutes)
582
+ status = "ok" if failed < args.failed_login_threshold else "alert"
583
+ payload = {
584
+ "status": status,
585
+ "failed_logins_last_window": failed,
586
+ "minutes": args.minutes,
587
+ "threshold": args.failed_login_threshold,
588
+ }
589
+ _json(payload)
590
+ return 0 if status == "ok" else 2
591
+ raise ValueError(f"Unsupported audit command: {args.audit_cmd}")
592
+
593
+
594
+ def cmd_harden(args: argparse.Namespace, ctx: AppContext) -> int:
595
+ if args.harden_cmd == "install-linux-system":
596
+ result = install_linux_system_services(
597
+ service_user=args.service_user,
598
+ zocket_home=Path(args.zocket_home),
599
+ web_port=args.web_port,
600
+ mcp_host=args.mcp_host,
601
+ mcp_port=args.mcp_port,
602
+ mcp_mode=args.mcp_mode,
603
+ dry_run=bool(args.dry_run),
604
+ )
605
+ _json(result)
606
+ return 0 if result.get("ok") else 1
607
+ raise ValueError(f"Unsupported harden command: {args.harden_cmd}")
608
+
609
+
610
+ def main(argv: list[str] | None = None) -> int:
611
+ args = parser().parse_args(argv)
612
+ ctx = build_context(args)
613
+ try:
614
+ if args.action == "init":
615
+ return cmd_init(args, ctx)
616
+ if args.action == "projects":
617
+ return cmd_projects(args, ctx)
618
+ if args.action == "secrets":
619
+ return cmd_secrets(args, ctx)
620
+ if args.action == "use":
621
+ return cmd_use(args, ctx)
622
+ if args.action == "web":
623
+ return cmd_web(args, ctx)
624
+ if args.action == "mcp":
625
+ return cmd_mcp(args, ctx)
626
+ if args.action == "autostart":
627
+ return cmd_autostart(args, ctx)
628
+ if args.action == "config":
629
+ return cmd_config(args, ctx)
630
+ if args.action == "auth":
631
+ return cmd_auth(args, ctx)
632
+ if args.action == "key":
633
+ return cmd_key(args, ctx)
634
+ if args.action == "backup":
635
+ return cmd_backup(args, ctx)
636
+ if args.action == "audit":
637
+ return cmd_audit(args, ctx)
638
+ if args.action == "harden":
639
+ return cmd_harden(args, ctx)
640
+ except (
641
+ VaultError,
642
+ ProjectNotFoundError,
643
+ FileNotFoundError,
644
+ PermissionError,
645
+ RuntimeError,
646
+ ValueError,
647
+ ) as exc:
648
+ ctx.audit.log("cli.error", "failed", "cli", {"error": str(exc), "action": args.action})
649
+ print(f"Error: {exc}", file=sys.stderr)
650
+ return 1
651
+ return 0
652
+
653
+
654
+ if __name__ == "__main__":
655
+ raise SystemExit(main())