@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/audit.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections import deque
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def utc_now_iso() -> str:
|
|
12
|
+
return datetime.now(timezone.utc).isoformat()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuditLogger:
|
|
16
|
+
def __init__(self, path: Path, enabled: bool = True):
|
|
17
|
+
self.path = path
|
|
18
|
+
self.enabled = enabled
|
|
19
|
+
|
|
20
|
+
def log(
|
|
21
|
+
self,
|
|
22
|
+
action: str,
|
|
23
|
+
status: str,
|
|
24
|
+
actor: str,
|
|
25
|
+
details: dict[str, Any] | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
if not self.enabled:
|
|
28
|
+
return
|
|
29
|
+
entry = {
|
|
30
|
+
"ts": utc_now_iso(),
|
|
31
|
+
"action": action,
|
|
32
|
+
"status": status,
|
|
33
|
+
"actor": actor,
|
|
34
|
+
"details": details or {},
|
|
35
|
+
}
|
|
36
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
if not self.path.exists():
|
|
38
|
+
self.path.touch(mode=0o600)
|
|
39
|
+
line = json.dumps(entry, ensure_ascii=True, separators=(",", ":")) + "\n"
|
|
40
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
41
|
+
f.write(line)
|
|
42
|
+
os.chmod(self.path, 0o600)
|
|
43
|
+
|
|
44
|
+
def tail(self, n: int = 50) -> list[dict[str, Any]]:
|
|
45
|
+
if not self.path.exists():
|
|
46
|
+
return []
|
|
47
|
+
out: deque[dict[str, Any]] = deque(maxlen=max(1, n))
|
|
48
|
+
with self.path.open("r", encoding="utf-8") as f:
|
|
49
|
+
for line in f:
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if not line:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
out.append(json.loads(line))
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
continue
|
|
57
|
+
return list(out)
|
|
58
|
+
|
|
59
|
+
def failed_logins(self, minutes: int = 60) -> int:
|
|
60
|
+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
|
61
|
+
total = 0
|
|
62
|
+
for item in self.tail(2000):
|
|
63
|
+
if item.get("action") != "web.login":
|
|
64
|
+
continue
|
|
65
|
+
if item.get("status") != "failed":
|
|
66
|
+
continue
|
|
67
|
+
ts = item.get("ts")
|
|
68
|
+
if not isinstance(ts, str):
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
t = datetime.fromisoformat(ts)
|
|
72
|
+
except ValueError:
|
|
73
|
+
continue
|
|
74
|
+
if t >= cutoff:
|
|
75
|
+
total += 1
|
|
76
|
+
return total
|
package/zocket/auth.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hash_password(
|
|
9
|
+
password: str, salt_hex: str | None = None, iterations: int = 390000
|
|
10
|
+
) -> tuple[str, str]:
|
|
11
|
+
salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
|
|
12
|
+
digest = hashlib.pbkdf2_hmac(
|
|
13
|
+
"sha256",
|
|
14
|
+
password.encode("utf-8"),
|
|
15
|
+
salt,
|
|
16
|
+
iterations,
|
|
17
|
+
)
|
|
18
|
+
return salt.hex(), digest.hex()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verify_password(
|
|
22
|
+
password: str,
|
|
23
|
+
salt_hex: str,
|
|
24
|
+
expected_hash_hex: str,
|
|
25
|
+
iterations: int,
|
|
26
|
+
) -> bool:
|
|
27
|
+
salt = bytes.fromhex(salt_hex)
|
|
28
|
+
digest = hashlib.pbkdf2_hmac(
|
|
29
|
+
"sha256",
|
|
30
|
+
password.encode("utf-8"),
|
|
31
|
+
salt,
|
|
32
|
+
iterations,
|
|
33
|
+
)
|
|
34
|
+
return hmac.compare_digest(digest.hex(), expected_hash_hex)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
AutostartTarget = Literal["web", "mcp", "both"]
|
|
11
|
+
MCPMode = Literal["metadata", "admin"]
|
|
12
|
+
|
|
13
|
+
WEB_SERVICE = "zocket-web.service"
|
|
14
|
+
MCP_HTTP_SERVICE = "zocket-mcp-http.service"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def current_platform() -> str:
|
|
18
|
+
if sys.platform.startswith("linux"):
|
|
19
|
+
return "linux"
|
|
20
|
+
if sys.platform == "darwin":
|
|
21
|
+
return "darwin"
|
|
22
|
+
if sys.platform in {"win32", "cygwin"}:
|
|
23
|
+
return "windows"
|
|
24
|
+
return "other"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _service_targets(target: AutostartTarget) -> list[str]:
|
|
28
|
+
if target == "web":
|
|
29
|
+
return [WEB_SERVICE]
|
|
30
|
+
if target == "mcp":
|
|
31
|
+
return [MCP_HTTP_SERVICE]
|
|
32
|
+
return [WEB_SERVICE, MCP_HTTP_SERVICE]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
36
|
+
cp = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
37
|
+
if check and cp.returncode != 0:
|
|
38
|
+
msg = cp.stderr.strip() or cp.stdout.strip() or f"command failed: {' '.join(cmd)}"
|
|
39
|
+
raise RuntimeError(msg)
|
|
40
|
+
return cp
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _safe_exec_start(cmd: list[str]) -> str:
|
|
44
|
+
# systemd supports shell-like quoting in unit files.
|
|
45
|
+
escaped = []
|
|
46
|
+
for part in cmd:
|
|
47
|
+
if " " in part or '"' in part or "'" in part:
|
|
48
|
+
escaped.append('"' + part.replace('"', '\\"') + '"')
|
|
49
|
+
else:
|
|
50
|
+
escaped.append(part)
|
|
51
|
+
return " ".join(escaped)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _linux_unit_web(zocket_home: Path, exec_cmd: list[str], host: str, port: int) -> str:
|
|
55
|
+
exec_start = _safe_exec_start(exec_cmd + ["web", "--host", host, "--port", str(port)])
|
|
56
|
+
return (
|
|
57
|
+
"[Unit]\n"
|
|
58
|
+
"Description=Zocket Web Panel\n"
|
|
59
|
+
"After=network-online.target\n"
|
|
60
|
+
"Wants=network-online.target\n\n"
|
|
61
|
+
"[Service]\n"
|
|
62
|
+
"Type=simple\n"
|
|
63
|
+
f"Environment=ZOCKET_HOME={zocket_home}\n"
|
|
64
|
+
f"ExecStart={exec_start}\n"
|
|
65
|
+
"Restart=on-failure\n"
|
|
66
|
+
"RestartSec=2\n"
|
|
67
|
+
"NoNewPrivileges=true\n"
|
|
68
|
+
"PrivateTmp=true\n"
|
|
69
|
+
"ProtectSystem=full\n"
|
|
70
|
+
"ProtectHome=read-only\n"
|
|
71
|
+
f"ReadWritePaths={zocket_home}\n"
|
|
72
|
+
"LockPersonality=true\n"
|
|
73
|
+
"MemoryDenyWriteExecute=true\n\n"
|
|
74
|
+
"[Install]\n"
|
|
75
|
+
"WantedBy=default.target\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _linux_unit_mcp(
|
|
80
|
+
zocket_home: Path,
|
|
81
|
+
exec_cmd: list[str],
|
|
82
|
+
mcp_mode: MCPMode,
|
|
83
|
+
mcp_host: str,
|
|
84
|
+
mcp_port: int,
|
|
85
|
+
) -> str:
|
|
86
|
+
exec_start = _safe_exec_start(
|
|
87
|
+
exec_cmd
|
|
88
|
+
+ [
|
|
89
|
+
"mcp",
|
|
90
|
+
"--transport",
|
|
91
|
+
"streamable-http",
|
|
92
|
+
"--mode",
|
|
93
|
+
mcp_mode,
|
|
94
|
+
"--host",
|
|
95
|
+
mcp_host,
|
|
96
|
+
"--port",
|
|
97
|
+
str(mcp_port),
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
return (
|
|
101
|
+
"[Unit]\n"
|
|
102
|
+
"Description=Zocket MCP (Streamable HTTP)\n"
|
|
103
|
+
"After=network-online.target\n"
|
|
104
|
+
"Wants=network-online.target\n\n"
|
|
105
|
+
"[Service]\n"
|
|
106
|
+
"Type=simple\n"
|
|
107
|
+
f"Environment=ZOCKET_HOME={zocket_home}\n"
|
|
108
|
+
f"ExecStart={exec_start}\n"
|
|
109
|
+
"Restart=on-failure\n"
|
|
110
|
+
"RestartSec=2\n"
|
|
111
|
+
"NoNewPrivileges=true\n"
|
|
112
|
+
"PrivateTmp=true\n"
|
|
113
|
+
"ProtectSystem=full\n"
|
|
114
|
+
"ProtectHome=read-only\n"
|
|
115
|
+
f"ReadWritePaths={zocket_home}\n"
|
|
116
|
+
"LockPersonality=true\n"
|
|
117
|
+
"MemoryDenyWriteExecute=true\n\n"
|
|
118
|
+
"[Install]\n"
|
|
119
|
+
"WantedBy=default.target\n"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _linux_user_units_dir() -> Path:
|
|
124
|
+
return Path.home() / ".config" / "systemd" / "user"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _status_for_service(name: str) -> dict[str, str]:
|
|
128
|
+
enabled = _run(["systemctl", "--user", "is-enabled", name], check=False)
|
|
129
|
+
active = _run(["systemctl", "--user", "is-active", name], check=False)
|
|
130
|
+
return {
|
|
131
|
+
"service": name,
|
|
132
|
+
"enabled": (enabled.stdout or enabled.stderr).strip(),
|
|
133
|
+
"active": (active.stdout or active.stderr).strip(),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _linger_note() -> str:
|
|
138
|
+
user = os.environ.get("USER", "")
|
|
139
|
+
if not user:
|
|
140
|
+
return "Could not determine USER for loginctl linger check."
|
|
141
|
+
cp = _run(
|
|
142
|
+
["loginctl", "show-user", user, "-p", "Linger"],
|
|
143
|
+
check=False,
|
|
144
|
+
)
|
|
145
|
+
output = (cp.stdout or cp.stderr).strip()
|
|
146
|
+
if "Linger=yes" in output:
|
|
147
|
+
return "Linger is enabled (services can run without active GUI/login session)."
|
|
148
|
+
if "Linger=no" in output:
|
|
149
|
+
return (
|
|
150
|
+
"Linger is disabled. To keep user services alive after logout run: "
|
|
151
|
+
f"sudo loginctl enable-linger {user}"
|
|
152
|
+
)
|
|
153
|
+
return "Could not verify linger status."
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def install_autostart(
|
|
157
|
+
target: AutostartTarget,
|
|
158
|
+
web_host: str,
|
|
159
|
+
web_port: int,
|
|
160
|
+
mcp_host: str,
|
|
161
|
+
mcp_port: int,
|
|
162
|
+
mcp_mode: MCPMode,
|
|
163
|
+
zocket_home: Path,
|
|
164
|
+
dry_run: bool = False,
|
|
165
|
+
) -> dict[str, object]:
|
|
166
|
+
platform = current_platform()
|
|
167
|
+
if platform != "linux":
|
|
168
|
+
return {
|
|
169
|
+
"ok": False,
|
|
170
|
+
"platform": platform,
|
|
171
|
+
"message": (
|
|
172
|
+
"Automatic install is implemented for Linux/systemd first. "
|
|
173
|
+
"Use generated guidance in README for macOS/Windows."
|
|
174
|
+
),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
exec_bin = shutil.which("zocket")
|
|
178
|
+
exec_cmd = [exec_bin] if exec_bin else [sys.executable, "-m", "zocket"]
|
|
179
|
+
|
|
180
|
+
units_dir = _linux_user_units_dir()
|
|
181
|
+
service_files: list[Path] = []
|
|
182
|
+
unit_preview: dict[str, str] = {}
|
|
183
|
+
services = _service_targets(target)
|
|
184
|
+
for service_name in services:
|
|
185
|
+
if service_name == WEB_SERVICE:
|
|
186
|
+
content = _linux_unit_web(
|
|
187
|
+
zocket_home=zocket_home,
|
|
188
|
+
exec_cmd=exec_cmd,
|
|
189
|
+
host=web_host,
|
|
190
|
+
port=web_port,
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
content = _linux_unit_mcp(
|
|
194
|
+
zocket_home=zocket_home,
|
|
195
|
+
exec_cmd=exec_cmd,
|
|
196
|
+
mcp_mode=mcp_mode,
|
|
197
|
+
mcp_host=mcp_host,
|
|
198
|
+
mcp_port=mcp_port,
|
|
199
|
+
)
|
|
200
|
+
service_path = units_dir / service_name
|
|
201
|
+
service_files.append(service_path)
|
|
202
|
+
unit_preview[service_name] = content
|
|
203
|
+
|
|
204
|
+
result: dict[str, object] = {
|
|
205
|
+
"ok": True,
|
|
206
|
+
"platform": platform,
|
|
207
|
+
"units_dir": str(units_dir),
|
|
208
|
+
"service_files": [str(p) for p in service_files],
|
|
209
|
+
"unit_preview": unit_preview,
|
|
210
|
+
"dry_run": dry_run,
|
|
211
|
+
}
|
|
212
|
+
if dry_run:
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
units_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
zocket_home.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
os.chmod(zocket_home, 0o700)
|
|
218
|
+
for service_path in service_files:
|
|
219
|
+
content = unit_preview[service_path.name]
|
|
220
|
+
service_path.write_text(content, encoding="utf-8")
|
|
221
|
+
os.chmod(service_path, 0o644)
|
|
222
|
+
|
|
223
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
224
|
+
for service_name in services:
|
|
225
|
+
_run(["systemctl", "--user", "enable", "--now", service_name])
|
|
226
|
+
|
|
227
|
+
result["services"] = [_status_for_service(s) for s in services]
|
|
228
|
+
result["linger_note"] = _linger_note()
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def remove_autostart(target: AutostartTarget) -> dict[str, object]:
|
|
233
|
+
platform = current_platform()
|
|
234
|
+
if platform != "linux":
|
|
235
|
+
return {
|
|
236
|
+
"ok": False,
|
|
237
|
+
"platform": platform,
|
|
238
|
+
"message": "Automatic removal is implemented for Linux/systemd first.",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
units_dir = _linux_user_units_dir()
|
|
242
|
+
services = _service_targets(target)
|
|
243
|
+
removed_files: list[str] = []
|
|
244
|
+
|
|
245
|
+
for service_name in services:
|
|
246
|
+
_run(["systemctl", "--user", "disable", "--now", service_name], check=False)
|
|
247
|
+
path = units_dir / service_name
|
|
248
|
+
if path.exists():
|
|
249
|
+
path.unlink()
|
|
250
|
+
removed_files.append(str(path))
|
|
251
|
+
|
|
252
|
+
_run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
253
|
+
return {
|
|
254
|
+
"ok": True,
|
|
255
|
+
"platform": platform,
|
|
256
|
+
"removed_files": removed_files,
|
|
257
|
+
"services": [_status_for_service(s) for s in services],
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def status_autostart(target: AutostartTarget) -> dict[str, object]:
|
|
262
|
+
platform = current_platform()
|
|
263
|
+
services = _service_targets(target)
|
|
264
|
+
if platform != "linux":
|
|
265
|
+
return {
|
|
266
|
+
"ok": False,
|
|
267
|
+
"platform": platform,
|
|
268
|
+
"services": [{"service": s, "enabled": "n/a", "active": "n/a"} for s in services],
|
|
269
|
+
"message": "Status command is implemented for Linux/systemd first.",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
units_dir = _linux_user_units_dir()
|
|
273
|
+
files = [str(units_dir / s) for s in services]
|
|
274
|
+
return {
|
|
275
|
+
"ok": True,
|
|
276
|
+
"platform": platform,
|
|
277
|
+
"units_dir": str(units_dir),
|
|
278
|
+
"service_files": files,
|
|
279
|
+
"services": [_status_for_service(s) for s in services],
|
|
280
|
+
"linger_note": _linger_note(),
|
|
281
|
+
}
|
package/zocket/backup.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def backup_name(prefix: str = "vault") -> str:
|
|
9
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
10
|
+
return f"{prefix}-{ts}.enc"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_backup(vault_file: Path, backup_dir: Path) -> Path:
|
|
14
|
+
if not vault_file.exists():
|
|
15
|
+
raise FileNotFoundError(f"Vault file not found: {vault_file}")
|
|
16
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
target = backup_dir / backup_name()
|
|
18
|
+
shutil.copy2(vault_file, target)
|
|
19
|
+
return target
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def restore_backup(vault_file: Path, backup_file: Path) -> Path:
|
|
23
|
+
if not backup_file.exists():
|
|
24
|
+
raise FileNotFoundError(f"Backup file not found: {backup_file}")
|
|
25
|
+
vault_file.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
shutil.copy2(backup_file, vault_file)
|
|
27
|
+
return vault_file
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_backups(backup_dir: Path) -> list[Path]:
|
|
31
|
+
if not backup_dir.exists():
|
|
32
|
+
return []
|
|
33
|
+
return sorted(backup_dir.glob("*.enc"), reverse=True)
|