@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.
@@ -0,0 +1,244 @@
1
+ <!doctype html>
2
+ <html lang="{{ lang }}">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>zocket login</title>
7
+ <style>
8
+ :root {
9
+ --primary-color: #0088cc;
10
+ --primary-accent: #00a1ff;
11
+ --dark-bg: #18222d;
12
+ --dark-surface: #212e3c;
13
+ --dark-text: #e9ecef;
14
+ --dark-muted: #adb5bd;
15
+ --light-bg: #ffffff;
16
+ --light-surface: #f8f9fa;
17
+ --light-text: #2c2c2c;
18
+ --light-muted: #6c757d;
19
+ --border-radius: 14px;
20
+ --page-gradient: radial-gradient(circle at 0 0, rgba(242,245,250,1) 0%, rgba(248,249,250,0.8) 55%);
21
+ --transition: all 0.35s cubic-bezier(0.165, 0.84, 0.44, 1);
22
+ }
23
+ body {
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ display: grid;
27
+ place-items: center;
28
+ padding: 24px;
29
+ background: var(--page-gradient);
30
+ color: var(--light-text);
31
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
32
+ }
33
+ body[data-theme="zorin"] {
34
+ background: radial-gradient(circle at 15% -10%, rgba(0, 136, 204, 0.45), rgba(8, 16, 30, 0.95) 45%), linear-gradient(180deg, #18222d 0%, #0f172a 65%, #0b1a2b 100%);
35
+ color: var(--dark-text);
36
+ }
37
+ body[data-theme="zorin"][data-theme-variant="light"] {
38
+ background: radial-gradient(circle at 25% -20%, rgba(0, 136, 204, 0.25), transparent 45%), linear-gradient(180deg, #ffffff 0%, #d7efff 100%);
39
+ color: var(--light-text);
40
+ }
41
+ .card {
42
+ width: min(520px, 94vw);
43
+ border-radius: var(--border-radius);
44
+ padding: 24px;
45
+ background: var(--light-surface);
46
+ border: 1px solid rgba(0, 0, 0, 0.08);
47
+ box-shadow: 0 15px 35px rgba(0, 0, 0, 0.12);
48
+ transition: var(--transition);
49
+ }
50
+ body[data-theme="zorin"] .card {
51
+ background: rgba(8, 11, 24, 0.85);
52
+ border-color: rgba(255, 255, 255, 0.15);
53
+ box-shadow: 0 25px 60px rgba(0, 0, 0, 0.6);
54
+ backdrop-filter: blur(28px);
55
+ }
56
+ body[data-theme="zorin"][data-theme-variant="light"] .card {
57
+ background: rgba(255, 255, 255, 0.96);
58
+ border-color: rgba(0, 136, 204, 0.25);
59
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
60
+ }
61
+ h1 {
62
+ margin: 0 0 8px;
63
+ font-size: 42px;
64
+ letter-spacing: -0.04em;
65
+ line-height: 1.2;
66
+ }
67
+ h2 {
68
+ margin: 14px 0 8px;
69
+ font-size: 20px;
70
+ font-weight: 600;
71
+ }
72
+ p {
73
+ margin: 0 0 12px;
74
+ color: var(--muted, #6c757d);
75
+ }
76
+ input, button {
77
+ width: 100%;
78
+ margin-bottom: 12px;
79
+ padding: 12px;
80
+ border-radius: var(--border-radius);
81
+ border: 1px solid rgba(0, 0, 0, 0.1);
82
+ font: inherit;
83
+ background: var(--light-surface);
84
+ color: inherit;
85
+ transition: var(--transition);
86
+ }
87
+ button {
88
+ border: none;
89
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-accent));
90
+ color: white;
91
+ cursor: pointer;
92
+ box-shadow: 0 10px 25px rgba(0, 136, 204, 0.25);
93
+ }
94
+ button:hover {
95
+ transform: translateY(-4px);
96
+ }
97
+ button.danger {
98
+ background: linear-gradient(135deg, #ff6b6b, #ff8e8e);
99
+ }
100
+ .lang {
101
+ display: flex;
102
+ gap: 8px;
103
+ margin-bottom: 12px;
104
+ }
105
+ .lang a {
106
+ color: var(--primary-color);
107
+ font-weight: 600;
108
+ text-decoration: none;
109
+ }
110
+ .theme-selector {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 6px;
114
+ margin-bottom: 12px;
115
+ font-size: 14px;
116
+ color: var(--muted);
117
+ }
118
+ .styled-select {
119
+ appearance: none;
120
+ -webkit-appearance: none;
121
+ -moz-appearance: none;
122
+ padding: 10px 28px 10px 16px;
123
+ border-radius: 999px;
124
+ border: 1px solid rgba(0, 0, 0, 0.1);
125
+ background: var(--light-surface);
126
+ color: inherit;
127
+ font-weight: 500;
128
+ cursor: pointer;
129
+ background-image: url('data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"%3E%3Cpolyline points=\"6 8 10 12 14 8\"/%3E%3C/svg%3E'), none;
130
+ background-position: right 10px center, center;
131
+ background-repeat: no-repeat;
132
+ }
133
+ .theme-selector select:focus {
134
+ outline: none;
135
+ box-shadow: 0 0 0 2px rgba(0, 136, 204, 0.3);
136
+ }
137
+ .variant-toggle {
138
+ display: none;
139
+ align-items: center;
140
+ gap: 6px;
141
+ margin-left: 6px;
142
+ }
143
+ body[data-theme="zorin"] .variant-toggle {
144
+ display: inline-flex;
145
+ }
146
+ .variant-button {
147
+ width: 40px;
148
+ height: 40px;
149
+ border-radius: 50%;
150
+ border: 1px solid rgba(255, 255, 255, 0.3);
151
+ background: rgba(255, 255, 255, 0.08);
152
+ color: var(--primary-color);
153
+ font-size: 1.1rem;
154
+ cursor: pointer;
155
+ transition: var(--transition);
156
+ }
157
+ .variant-button.active {
158
+ background: var(--primary-color);
159
+ color: #fff;
160
+ box-shadow: 0 0 12px rgba(0, 136, 204, 0.6);
161
+ }
162
+ .notice {
163
+ border-radius: var(--border-radius);
164
+ border: 1px solid rgba(0, 136, 204, 0.3);
165
+ background: rgba(0, 136, 204, 0.08);
166
+ }
167
+ .error {
168
+ border-radius: var(--border-radius);
169
+ border: 1px solid #fecdd3;
170
+ background: #fff1f2;
171
+ }
172
+ </style>
173
+ </head>
174
+ <body data-theme="{{ theme }}" data-theme-variant="{{ theme_variant }}">
175
+ <div class="card">
176
+ <div class="lang">
177
+ <a href="/login?lang=en">EN</a>
178
+ <a href="/login?lang=ru">RU</a>
179
+ </div>
180
+ <form method="post" action="/set-theme" class="theme-selector">
181
+ <label for="theme-select">{{ t("ui.theme") }}:</label>
182
+ <select id="theme-select" class="styled-select" name="theme" onchange="this.form.submit();">
183
+ <option value="standard" {% if theme == 'standard' %}selected{% endif %}>{{ t('ui.theme_standard') }}</option>
184
+ <option value="zorin" {% if theme == 'zorin' %}selected{% endif %}>{{ t('ui.theme_zorin') }}</option>
185
+ </select>
186
+ <input type="hidden" name="next" value="{{ next_path }}" />
187
+ </form>
188
+ {% if theme == "zorin" %}
189
+ <form method="post" action="/set-theme-variant" class="variant-toggle">
190
+ <input type="hidden" name="next" value="{{ next_path }}" />
191
+ <button type="submit" name="variant" value="light" class="variant-button {% if theme_variant == 'light' %}active{% endif %}" aria-label="{{ t('ui.variant_light') }}">☀️</button>
192
+ <button type="submit" name="variant" value="dark" class="variant-button {% if theme_variant == 'dark' %}active{% endif %}" aria-label="{{ t('ui.variant_dark') }}">🌙</button>
193
+ </form>
194
+ {% endif %}
195
+ <h1>{{ t("ui.sign_in") }}</h1>
196
+
197
+ {% if error %}
198
+ <div class="error">{{ error }}</div>
199
+ {% endif %}
200
+
201
+ {% if missing_password %}
202
+ <h1>{{ t("ui.first_setup_title") }}</h1>
203
+ <p>{{ t("ui.first_setup_subtitle") }}</p>
204
+
205
+ <div class="section">
206
+ <h2>{{ t("ui.set_password") }}</h2>
207
+ <form method="post" action="/setup/first-run">
208
+ <input type="hidden" name="mode" value="set_password" />
209
+ <input type="password" name="password" placeholder="{{ t('ui.password') }}" required />
210
+ <input type="password" name="password_repeat" placeholder="{{ t('ui.password_repeat') }}" required />
211
+ <button type="submit">{{ t("ui.save_and_enter") }}</button>
212
+ </form>
213
+ </div>
214
+
215
+ <div class="section">
216
+ <h2>{{ t("ui.generate_password") }}</h2>
217
+ <p>{{ t("ui.generate_password_hint") }}</p>
218
+ <form method="post" action="/setup/first-run">
219
+ <input type="hidden" name="mode" value="generate_password" />
220
+ <button type="submit">{{ t("ui.generate_and_enter") }}</button>
221
+ </form>
222
+ </div>
223
+
224
+ <div class="section warning">
225
+ <h2>{{ t("ui.continue_without_password") }}</h2>
226
+ <p>{{ t("ui.insecure_warning") }}</p>
227
+ <form method="post" action="/setup/first-run" onsubmit="return confirm('{{ t('ui.insecure_confirm_dialog') }}')">
228
+ <input type="hidden" name="mode" value="no_password" />
229
+ <label class="checkline">
230
+ <input type="checkbox" name="confirm_no_password" value="1" required />
231
+ <span>{{ t("ui.i_understand_risk") }}</span>
232
+ </label>
233
+ <button class="danger" type="submit">{{ t("ui.continue_anyway") }}</button>
234
+ </form>
235
+ </div>
236
+ {% else %}
237
+ <form method="post" action="/login">
238
+ <input type="password" name="password" placeholder="{{ t('ui.password') }}" required />
239
+ <button type="submit">{{ t("ui.login") }}</button>
240
+ </form>
241
+ {% endif %}
242
+ </div>
243
+ </body>
244
+ </html>
@@ -0,0 +1,331 @@
1
+ from __future__ import annotations
2
+
3
+ import fcntl
4
+ import os
5
+ import re
6
+ from contextlib import contextmanager
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Iterator
10
+
11
+ from .crypto import decrypt_payload, encrypt_payload, load_key
12
+
13
+ PROJECT_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
14
+ SECRET_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
15
+
16
+
17
+ def utc_now_iso() -> str:
18
+ return datetime.now(timezone.utc).isoformat()
19
+
20
+
21
+ def empty_vault() -> dict[str, Any]:
22
+ return {"version": 1, "projects": {}}
23
+
24
+
25
+ class VaultError(RuntimeError):
26
+ pass
27
+
28
+
29
+ class ProjectNotFoundError(VaultError):
30
+ pass
31
+
32
+
33
+ class SecretNotFoundError(VaultError):
34
+ pass
35
+
36
+
37
+ class ValidationError(VaultError):
38
+ pass
39
+
40
+
41
+ class VaultService:
42
+ def __init__(
43
+ self,
44
+ vault_file: Path,
45
+ key_file: Path,
46
+ lock_file: Path,
47
+ key_storage: str = "file",
48
+ keyring_service: str = "zocket",
49
+ keyring_account: str = "master-key",
50
+ ):
51
+ self.vault_file = vault_file
52
+ self.key_file = key_file
53
+ self.lock_file = lock_file
54
+ self.key_storage = key_storage
55
+ self.keyring_service = keyring_service
56
+ self.keyring_account = keyring_account
57
+ self._cached_key: bytes | None = None
58
+
59
+ def _key(self) -> bytes:
60
+ if self._cached_key is None:
61
+ self._cached_key = load_key(
62
+ self.key_file,
63
+ storage=self.key_storage,
64
+ keyring_service=self.keyring_service,
65
+ keyring_account=self.keyring_account,
66
+ )
67
+ return self._cached_key
68
+
69
+ @contextmanager
70
+ def _locked(self) -> Iterator[None]:
71
+ self.lock_file.parent.mkdir(parents=True, exist_ok=True)
72
+ with self.lock_file.open("a+") as lock_fd:
73
+ fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
74
+ try:
75
+ yield
76
+ finally:
77
+ fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
78
+
79
+ def _read_unlocked(self) -> dict[str, Any]:
80
+ if not self.vault_file.exists():
81
+ return empty_vault()
82
+ ciphertext = self.vault_file.read_bytes()
83
+ if not ciphertext:
84
+ return empty_vault()
85
+ payload = decrypt_payload(ciphertext, self._key())
86
+ if "projects" not in payload or not isinstance(payload["projects"], dict):
87
+ raise VaultError("Vault payload is malformed: missing `projects` map.")
88
+ return payload
89
+
90
+ def _write_unlocked(self, payload: dict[str, Any]) -> None:
91
+ self.vault_file.parent.mkdir(parents=True, exist_ok=True)
92
+ ciphertext = encrypt_payload(payload, self._key())
93
+ tmp = self.vault_file.with_suffix(self.vault_file.suffix + ".tmp")
94
+ tmp.write_bytes(ciphertext)
95
+ os.chmod(tmp, 0o600)
96
+ os.replace(tmp, self.vault_file)
97
+
98
+ def ensure_initialized(self) -> None:
99
+ with self._locked():
100
+ if self.vault_file.exists():
101
+ return
102
+ self._write_unlocked(empty_vault())
103
+
104
+ def _validate_project_name(self, project: str) -> None:
105
+ if not project or not PROJECT_RE.match(project):
106
+ raise ValidationError(
107
+ "Invalid project name. Use only [a-zA-Z0-9._-] characters."
108
+ )
109
+
110
+ def _validate_secret_key(self, key: str) -> None:
111
+ if not key or not SECRET_KEY_RE.match(key):
112
+ raise ValidationError(
113
+ "Invalid secret key. Use UPPERCASE env-like keys, e.g. SSH_PASSWORD."
114
+ )
115
+
116
+ def _normalize_folder_path(
117
+ self,
118
+ folder_path: str | None,
119
+ require_exists: bool,
120
+ ) -> str | None:
121
+ if folder_path is None:
122
+ return None
123
+ raw = folder_path.strip()
124
+ if not raw:
125
+ return None
126
+ path = Path(raw).expanduser()
127
+ try:
128
+ resolved = path.resolve(strict=require_exists)
129
+ except FileNotFoundError as exc:
130
+ raise ValidationError(f"Project folder not found: {path}") from exc
131
+ except OSError as exc:
132
+ raise ValidationError(f"Invalid project folder path: {path}") from exc
133
+ if require_exists and not resolved.is_dir():
134
+ raise ValidationError(f"Project folder is not a directory: {resolved}")
135
+ return str(resolved)
136
+
137
+ def create_project(
138
+ self,
139
+ project: str,
140
+ description: str = "",
141
+ folder_path: str | None = None,
142
+ ) -> None:
143
+ self._validate_project_name(project)
144
+ normalized_folder = self._normalize_folder_path(
145
+ folder_path, require_exists=True
146
+ )
147
+ with self._locked():
148
+ payload = self._read_unlocked()
149
+ projects = payload["projects"]
150
+ if project in projects:
151
+ return
152
+ now = utc_now_iso()
153
+ projects[project] = {
154
+ "description": description,
155
+ "created_at": now,
156
+ "updated_at": now,
157
+ "secrets": {},
158
+ }
159
+ if normalized_folder:
160
+ projects[project]["folder_path"] = normalized_folder
161
+ self._write_unlocked(payload)
162
+
163
+ def list_projects(self) -> list[dict[str, Any]]:
164
+ with self._locked():
165
+ payload = self._read_unlocked()
166
+ items: list[dict[str, Any]] = []
167
+ for project, info in payload["projects"].items():
168
+ secrets = info.get("secrets", {})
169
+ items.append(
170
+ {
171
+ "project": project,
172
+ "description": info.get("description", ""),
173
+ "folder_path": info.get("folder_path"),
174
+ "secret_count": len(secrets),
175
+ "updated_at": info.get("updated_at"),
176
+ }
177
+ )
178
+ items.sort(key=lambda x: x["project"])
179
+ return items
180
+
181
+ def _get_project(self, payload: dict[str, Any], project: str) -> dict[str, Any]:
182
+ self._validate_project_name(project)
183
+ info = payload["projects"].get(project)
184
+ if info is None:
185
+ raise ProjectNotFoundError(f"Project not found: {project}")
186
+ return info
187
+
188
+ def list_project_secrets(
189
+ self, project: str, include_values: bool = False
190
+ ) -> list[dict[str, Any]]:
191
+ with self._locked():
192
+ payload = self._read_unlocked()
193
+ info = self._get_project(payload, project)
194
+ result: list[dict[str, Any]] = []
195
+ for key, item in info.get("secrets", {}).items():
196
+ row: dict[str, Any] = {
197
+ "key": key,
198
+ "description": item.get("description", ""),
199
+ "updated_at": item.get("updated_at"),
200
+ "has_value": bool(item.get("value")),
201
+ }
202
+ if include_values:
203
+ row["value"] = item.get("value", "")
204
+ result.append(row)
205
+ result.sort(key=lambda x: x["key"])
206
+ return result
207
+
208
+ def upsert_secret(
209
+ self, project: str, key: str, value: str, description: str = ""
210
+ ) -> None:
211
+ self._validate_project_name(project)
212
+ self._validate_secret_key(key)
213
+ if value is None:
214
+ raise ValidationError("Secret value cannot be null.")
215
+
216
+ with self._locked():
217
+ payload = self._read_unlocked()
218
+ projects = payload["projects"]
219
+ if project not in projects:
220
+ now = utc_now_iso()
221
+ projects[project] = {
222
+ "description": "",
223
+ "created_at": now,
224
+ "updated_at": now,
225
+ "secrets": {},
226
+ }
227
+ project_info = projects[project]
228
+ now = utc_now_iso()
229
+ project_info["secrets"][key] = {
230
+ "value": value,
231
+ "description": description,
232
+ "updated_at": now,
233
+ }
234
+ project_info["updated_at"] = now
235
+ self._write_unlocked(payload)
236
+
237
+ def delete_secret(self, project: str, key: str) -> None:
238
+ self._validate_project_name(project)
239
+ self._validate_secret_key(key)
240
+ with self._locked():
241
+ payload = self._read_unlocked()
242
+ info = self._get_project(payload, project)
243
+ if key not in info.get("secrets", {}):
244
+ raise SecretNotFoundError(f"Secret {key} not found in project {project}")
245
+ del info["secrets"][key]
246
+ info["updated_at"] = utc_now_iso()
247
+ self._write_unlocked(payload)
248
+
249
+ def get_secret(self, project: str, key: str) -> dict[str, str]:
250
+ self._validate_project_name(project)
251
+ self._validate_secret_key(key)
252
+ with self._locked():
253
+ payload = self._read_unlocked()
254
+ info = self._get_project(payload, project)
255
+ secret = info.get("secrets", {}).get(key)
256
+ if secret is None:
257
+ raise SecretNotFoundError(f"Secret {key} not found in project {project}")
258
+ return dict(secret)
259
+
260
+ def delete_project(self, project: str) -> None:
261
+ self._validate_project_name(project)
262
+ with self._locked():
263
+ payload = self._read_unlocked()
264
+ if project not in payload["projects"]:
265
+ raise ProjectNotFoundError(f"Project not found: {project}")
266
+ del payload["projects"][project]
267
+ self._write_unlocked(payload)
268
+
269
+ def set_project_folder(self, project: str, folder_path: str | None) -> None:
270
+ self._validate_project_name(project)
271
+ normalized_folder = self._normalize_folder_path(
272
+ folder_path, require_exists=True
273
+ )
274
+ with self._locked():
275
+ payload = self._read_unlocked()
276
+ info = self._get_project(payload, project)
277
+ if normalized_folder:
278
+ info["folder_path"] = normalized_folder
279
+ else:
280
+ info.pop("folder_path", None)
281
+ info["updated_at"] = utc_now_iso()
282
+ self._write_unlocked(payload)
283
+
284
+ def find_project_by_path(self, folder_path: str) -> dict[str, Any] | None:
285
+ normalized_folder = self._normalize_folder_path(
286
+ folder_path, require_exists=False
287
+ )
288
+ if not normalized_folder:
289
+ return None
290
+ target = Path(normalized_folder)
291
+ best_name: str | None = None
292
+ best_info: dict[str, Any] | None = None
293
+ best_depth = -1
294
+
295
+ with self._locked():
296
+ payload = self._read_unlocked()
297
+ for name, info in payload["projects"].items():
298
+ raw_folder = info.get("folder_path")
299
+ if not raw_folder:
300
+ continue
301
+ try:
302
+ candidate = Path(str(raw_folder)).expanduser().resolve(strict=False)
303
+ except OSError:
304
+ continue
305
+ if target != candidate and not target.is_relative_to(candidate):
306
+ continue
307
+ depth = len(candidate.parts)
308
+ if depth > best_depth:
309
+ best_name = name
310
+ best_info = info
311
+ best_depth = depth
312
+
313
+ if best_name is None or best_info is None:
314
+ return None
315
+
316
+ return {
317
+ "project": best_name,
318
+ "description": best_info.get("description", ""),
319
+ "folder_path": best_info.get("folder_path"),
320
+ "secret_count": len(best_info.get("secrets", {})),
321
+ "updated_at": best_info.get("updated_at"),
322
+ }
323
+
324
+ def get_project_env(self, project: str) -> dict[str, str]:
325
+ with self._locked():
326
+ payload = self._read_unlocked()
327
+ info = self._get_project(payload, project)
328
+ env: dict[str, str] = {}
329
+ for key, item in info.get("secrets", {}).items():
330
+ env[key] = str(item.get("value", ""))
331
+ return env