@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/LICENSE +21 -0
- package/README.md +92 -0
- package/bin/zocket-setup.cjs +12 -0
- package/bin/zocket.cjs +174 -0
- package/docs/AI_AUTODEPLOY.md +52 -0
- package/docs/CLIENTS_MCP.md +59 -0
- package/docs/INSTALL.md +288 -0
- package/docs/LOCAL_MODELS.md +95 -0
- package/package.json +52 -0
- package/pyproject.toml +29 -0
- package/scripts/ai-autodeploy.py +127 -0
- package/scripts/install-zocket.ps1 +116 -0
- package/scripts/install-zocket.sh +228 -0
- package/zocket/__init__.py +2 -0
- package/zocket/__main__.py +5 -0
- package/zocket/audit.py +76 -0
- package/zocket/auth.py +34 -0
- package/zocket/autostart.py +281 -0
- package/zocket/backup.py +33 -0
- package/zocket/cli.py +655 -0
- package/zocket/config_store.py +68 -0
- package/zocket/crypto.py +158 -0
- package/zocket/harden.py +136 -0
- package/zocket/i18n.py +216 -0
- package/zocket/mcp_server.py +249 -0
- package/zocket/paths.py +50 -0
- package/zocket/runner.py +108 -0
- package/zocket/templates/index.html +1062 -0
- package/zocket/templates/login.html +244 -0
- package/zocket/vault.py +331 -0
- package/zocket/web.py +490 -0
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())
|