edgevault 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
edgevault/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """edgevault — зашифрованные бэкапы баз, файлов и секретов.
2
+
3
+ Как библиотека — в коде проекта (см. edgevault.vault):
4
+
5
+ from edgevault import Vault, Postgres, Secrets, S3, Telegram
6
+
7
+ Как программа — `edgevault init` и один конфиг edgevault.toml на проект.
8
+ """
9
+
10
+ __version__ = "0.5.0"
11
+
12
+ from edgevault.config import Files, Keep, Mirror, Postgres, Secrets, Sqlite
13
+ from edgevault.destinations import S3, Folder, Rclone
14
+ from edgevault.errors import ConfigError
15
+ from edgevault.report import Problem, Report
16
+ from edgevault.vault import Telegram, Vault, Webhook
17
+
18
+ __all__ = [
19
+ "S3", "ConfigError", "Files", "Folder", "Keep", "Mirror", "Postgres", "Problem",
20
+ "Rclone", "Report", "Secrets", "Sqlite", "Telegram", "Vault", "Webhook", "__version__",
21
+ ] # fmt: skip
edgevault/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from edgevault.cli import main
2
+
3
+ main()
edgevault/age.py ADDED
@@ -0,0 +1,268 @@
1
+ """Формат age (https://age-encryption.org/v1) на Python: без программы age.
2
+
3
+ Копии, зашифрованные здесь, расшифровывает обычный `age -d`, и наоборот. Нужна
4
+ только библиотека `cryptography`. Поддерживаются ключи X25519 — те, что делает
5
+ `age-keygen` (`age1…` и `AGE-SECRET-KEY-1…`).
6
+
7
+ Шифруем потоком, кусками по 64 КиБ: дамп любого размера не читается в память.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import datetime
14
+ import hmac
15
+ import os
16
+ from collections.abc import Iterator, Sequence
17
+ from hashlib import sha256
18
+ from pathlib import Path
19
+ from typing import BinaryIO
20
+
21
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
22
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
23
+ from cryptography.hazmat.primitives.hashes import SHA256
24
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
25
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
26
+
27
+ INTRO = b"age-encryption.org/v1\n"
28
+ CHUNK = 64 * 1024
29
+ _TAG = 16
30
+ _X25519_INFO = b"age-encryption.org/v1/X25519"
31
+ PUBLIC_HRP = "age"
32
+ SECRET_HRP = "age-secret-key-"
33
+
34
+
35
+ class AgeError(ValueError):
36
+ """Ключ не тот, файл не age или повреждён."""
37
+
38
+
39
+ # ─── bech32 (BIP 173): в нём записаны ключи age ──────────────────────────────
40
+
41
+ _CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
42
+
43
+
44
+ def _polymod(values: Sequence[int]) -> int:
45
+ generator = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
46
+ check = 1
47
+ for value in values:
48
+ top = check >> 25
49
+ check = (check & 0x1FFFFFF) << 5 ^ value
50
+ for i in range(5):
51
+ check ^= generator[i] if (top >> i) & 1 else 0
52
+ return check
53
+
54
+
55
+ def _hrp_expand(hrp: str) -> list[int]:
56
+ return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
57
+
58
+
59
+ def _convert(data: Sequence[int], source: int, target: int, pad: bool) -> list[int]:
60
+ acc = bits = 0
61
+ out = []
62
+ mask = (1 << target) - 1
63
+ for value in data:
64
+ acc = (acc << source) | value
65
+ bits += source
66
+ while bits >= target:
67
+ bits -= target
68
+ out.append((acc >> bits) & mask)
69
+ if pad and bits:
70
+ out.append((acc << (target - bits)) & mask)
71
+ elif not pad and (bits >= source or (acc << (target - bits)) & mask):
72
+ raise AgeError("ключ повреждён: лишние биты")
73
+ return out
74
+
75
+
76
+ def bech32_encode(hrp: str, data: bytes) -> str:
77
+ words = _convert(data, 8, 5, pad=True)
78
+ polymod = _polymod([*_hrp_expand(hrp), *words, 0, 0, 0, 0, 0, 0]) ^ 1
79
+ checksum = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
80
+ return hrp + "1" + "".join(_CHARSET[w] for w in [*words, *checksum])
81
+
82
+
83
+ def bech32_decode(text: str) -> tuple[str, bytes]:
84
+ if text.lower() != text and text.upper() != text:
85
+ raise AgeError("ключ повреждён: смешаны регистры")
86
+ text = text.lower()
87
+ hrp, sep, rest = text.rpartition("1")
88
+ if not sep or not hrp or len(rest) < 6 or any(c not in _CHARSET for c in rest):
89
+ raise AgeError("это не ключ age")
90
+ words = [_CHARSET.index(c) for c in rest]
91
+ if _polymod([*_hrp_expand(hrp), *words]) != 1:
92
+ raise AgeError("ключ повреждён: не сходится контрольная сумма — опечатка?")
93
+ return hrp, bytes(_convert(words[:-6], 5, 8, pad=False))
94
+
95
+
96
+ # ─── Ключи ───────────────────────────────────────────────────────────────────
97
+
98
+
99
+ def _raw_public(key: X25519PublicKey) -> bytes:
100
+ return key.public_bytes(Encoding.Raw, PublicFormat.Raw)
101
+
102
+
103
+ def parse_recipient(text: str) -> X25519PublicKey:
104
+ """Открытый ключ `age1…`."""
105
+ hrp, data = bech32_decode(text.strip())
106
+ if hrp != PUBLIC_HRP or len(data) != 32:
107
+ raise AgeError(f"«{text[:12]}…» — не открытый ключ age (age1…)")
108
+ return X25519PublicKey.from_public_bytes(data)
109
+
110
+
111
+ def parse_identity(text: str) -> X25519PrivateKey:
112
+ """Закрытый ключ `AGE-SECRET-KEY-1…`."""
113
+ hrp, data = bech32_decode(text.strip())
114
+ if hrp != SECRET_HRP or len(data) != 32:
115
+ raise AgeError("это не закрытый ключ age (AGE-SECRET-KEY-1…)")
116
+ return X25519PrivateKey.from_private_bytes(data)
117
+
118
+
119
+ def read_identities(path: Path) -> list[X25519PrivateKey]:
120
+ """Файл ключей, как его пишет age-keygen: комментарии и строки AGE-SECRET-KEY-1…"""
121
+ keys = [
122
+ parse_identity(line)
123
+ for line in path.read_text(encoding="utf-8").splitlines()
124
+ if line.strip() and not line.startswith("#")
125
+ ]
126
+ if not keys:
127
+ raise AgeError(f"в {path} нет закрытого ключа AGE-SECRET-KEY-1…")
128
+ return keys
129
+
130
+
131
+ def generate() -> tuple[str, str]:
132
+ """Новая пара ключей: (закрытый AGE-SECRET-KEY-1…, открытый age1…)."""
133
+ secret = X25519PrivateKey.generate()
134
+ raw = secret.private_bytes_raw()
135
+ return (
136
+ bech32_encode(SECRET_HRP, raw).upper(),
137
+ bech32_encode(PUBLIC_HRP, _raw_public(secret.public_key())),
138
+ )
139
+
140
+
141
+ def key_file(secret: str, public: str) -> str:
142
+ """Текст файла ключа в формате age-keygen."""
143
+ created = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
144
+ return f"# created: {created}\n# public key: {public}\n{secret}\n"
145
+
146
+
147
+ # ─── Шифрование ──────────────────────────────────────────────────────────────
148
+
149
+
150
+ def _hkdf(ikm: bytes, salt: bytes, info: bytes) -> bytes:
151
+ return HKDF(algorithm=SHA256(), length=32, salt=salt, info=info).derive(ikm)
152
+
153
+
154
+ def _b64(data: bytes) -> bytes:
155
+ return base64.b64encode(data).rstrip(b"=")
156
+
157
+
158
+ def _unb64(data: bytes) -> bytes:
159
+ if data.endswith(b"=") or b"\n" in data:
160
+ raise AgeError("файл age повреждён: неверная запись base64")
161
+ return base64.b64decode(data + b"=" * (-len(data) % 4), validate=True)
162
+
163
+
164
+ def _stanza(recipient: X25519PublicKey, file_key: bytes) -> bytes:
165
+ ephemeral = X25519PrivateKey.generate()
166
+ share = _raw_public(ephemeral.public_key())
167
+ shared = ephemeral.exchange(recipient)
168
+ wrap = _hkdf(shared, share + _raw_public(recipient), _X25519_INFO)
169
+ body = ChaCha20Poly1305(wrap).encrypt(b"\x00" * 12, file_key, None)
170
+ return b"-> X25519 " + _b64(share) + b"\n" + _b64(body) + b"\n"
171
+
172
+
173
+ def _nonce(counter: int, last: bool) -> bytes:
174
+ return counter.to_bytes(11, "big") + (b"\x01" if last else b"\x00")
175
+
176
+
177
+ def _chunks(source: BinaryIO, size: int) -> Iterator[tuple[bytes, bool]]:
178
+ """Куски по `size` байт с пометкой «последний» (нужен заглядывать на шаг вперёд)."""
179
+ current = source.read(size)
180
+ while True:
181
+ following = source.read(size)
182
+ yield current, not following
183
+ if not following:
184
+ return
185
+ current = following
186
+
187
+
188
+ def encrypt_stream(source: BinaryIO, sink: BinaryIO, recipients: Sequence[str]) -> None:
189
+ if not recipients:
190
+ raise AgeError("нет открытого ключа: шифровать нечем")
191
+ keys = [parse_recipient(r) for r in recipients]
192
+ file_key = os.urandom(16)
193
+ header = INTRO + b"".join(_stanza(k, file_key) for k in keys) + b"---"
194
+ mac = hmac.new(_hkdf(file_key, b"", b"header"), header, sha256).digest()
195
+ sink.write(header + b" " + _b64(mac) + b"\n")
196
+ nonce = os.urandom(16)
197
+ sink.write(nonce)
198
+ cipher = ChaCha20Poly1305(_hkdf(file_key, nonce, b"payload"))
199
+ for counter, (chunk, last) in enumerate(_chunks(source, CHUNK)):
200
+ sink.write(cipher.encrypt(_nonce(counter, last), chunk, None))
201
+
202
+
203
+ def decrypt_stream(
204
+ source: BinaryIO, sink: BinaryIO, identities: Sequence[X25519PrivateKey]
205
+ ) -> None:
206
+ intro = source.readline()
207
+ if intro != INTRO:
208
+ raise AgeError("это не файл age")
209
+ header = intro
210
+ file_key = None
211
+ while True:
212
+ line = source.readline()
213
+ if not line:
214
+ raise AgeError("файл age обрезан: нет конца заголовка")
215
+ if line.startswith(b"---"):
216
+ header += b"---"
217
+ mac = _unb64(line[4:].rstrip(b"\n"))
218
+ break
219
+ header += line
220
+ if not line.startswith(b"-> "):
221
+ raise AgeError("файл age повреждён: неверный заголовок")
222
+ args = line[3:].rstrip(b"\n").split(b" ")
223
+ body = b""
224
+ while True: # тело записи — строки по 64 символа, последняя короче
225
+ part = source.readline()
226
+ header += part
227
+ body += part.rstrip(b"\n")
228
+ if len(part.rstrip(b"\n")) < 64:
229
+ break
230
+ if args[0] != b"X25519" or len(args) != 2 or file_key is not None:
231
+ continue
232
+ share = _unb64(args[1])
233
+ wrapped = _unb64(body)
234
+ for identity in identities:
235
+ try:
236
+ shared = identity.exchange(X25519PublicKey.from_public_bytes(share))
237
+ except ValueError:
238
+ continue
239
+ wrap = _hkdf(shared, share + _raw_public(identity.public_key()), _X25519_INFO)
240
+ try:
241
+ file_key = ChaCha20Poly1305(wrap).decrypt(b"\x00" * 12, wrapped, None)
242
+ break
243
+ except Exception:
244
+ continue
245
+ if file_key is None:
246
+ raise AgeError("ни один ключ не подошёл: копия зашифрована другим ключом")
247
+ expected = hmac.new(_hkdf(file_key, b"", b"header"), header, sha256).digest()
248
+ if not hmac.compare_digest(expected, mac):
249
+ raise AgeError("заголовок файла age повреждён или подменён")
250
+ nonce = source.read(16)
251
+ if len(nonce) != 16:
252
+ raise AgeError("файл age обрезан")
253
+ cipher = ChaCha20Poly1305(_hkdf(file_key, nonce, b"payload"))
254
+ for counter, (chunk, last) in enumerate(_chunks(source, CHUNK + _TAG)):
255
+ try:
256
+ sink.write(cipher.decrypt(_nonce(counter, last), chunk, None))
257
+ except Exception as exc:
258
+ raise AgeError("файл age повреждён или обрезан") from exc
259
+
260
+
261
+ def encrypt_file(plain: Path, target: Path, recipients: Sequence[str]) -> None:
262
+ with plain.open("rb") as source, target.open("wb") as sink:
263
+ encrypt_stream(source, sink, recipients)
264
+
265
+
266
+ def decrypt_file(encrypted: Path, target: Path, identities: Sequence[X25519PrivateKey]) -> None:
267
+ with encrypted.open("rb") as source, target.open("wb") as sink:
268
+ decrypt_stream(source, sink, identities)
edgevault/cli.py ADDED
@@ -0,0 +1,311 @@
1
+ """edgevault — зашифрованные бэкапы баз, файлов и секретов.
2
+
3
+ Подключить к проекту: edgevault init → edgevault check → edgevault run
4
+ Конфиг — `-c путь`, переменная EDGEVAULT_CONFIG или ./edgevault.toml.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import datetime
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from edgevault import __version__, age, crypto, notify, pull, runner, scaffold, schedule, tools
19
+ from edgevault.config import Config, load
20
+ from edgevault.errors import ConfigError
21
+ from edgevault.report import describe
22
+ from edgevault.sources import postgres, sqlite
23
+
24
+
25
+ def _config(args: argparse.Namespace) -> Config:
26
+ path = args.config or os.environ.get("EDGEVAULT_CONFIG") or "edgevault.toml"
27
+ return load(path)
28
+
29
+
30
+ def cmd_run(args: argparse.Namespace) -> int:
31
+ config = _config(args)
32
+ report = runner.run(config)
33
+ print(describe(report), flush=True)
34
+ return 0 if report.ok else 1
35
+
36
+
37
+ def cmd_daemon(args: argparse.Namespace) -> int:
38
+ config = _config(args)
39
+ print(
40
+ f"edgevault {__version__}: {config.name}, бэкап каждый день в {', '.join(config.schedule)}",
41
+ flush=True,
42
+ )
43
+ while True:
44
+ time.sleep(schedule.seconds_until(config.schedule, datetime.datetime.now()))
45
+ report = runner.run(config) # не падает: любой сбой — в отчёт и в сообщение
46
+ print(describe(report), flush=True)
47
+
48
+
49
+ def cmd_pull(args: argparse.Namespace) -> int:
50
+ config = _config(args)
51
+ if config.pull is None:
52
+ raise ConfigError("В конфиге нет раздела [pull]: откуда забирать копии")
53
+ if args.once:
54
+ problems = pull.once(config)
55
+ for problem in problems:
56
+ print(problem, flush=True)
57
+ return 1 if problems else 0
58
+ print(f"Забираю копии каждые {config.pull.every_minutes} мин в {config.pull.dest}", flush=True)
59
+ pull.watch(config)
60
+ return 0
61
+
62
+
63
+ def cmd_check(args: argparse.Namespace) -> int:
64
+ """Проверить конфиг и показать, что будет сохраняться. Базы только читаются."""
65
+ config = _config(args)
66
+ ok = True
67
+ print(f"edgevault {__version__} · проект {config.name} · копии в {config.store}")
68
+ lacking = {p.where: p for p in tools.missing(config)}
69
+ for tool in tools.required(config):
70
+ if tool in lacking:
71
+ print(f" ✗ {tool} — {lacking[tool].error}\n → {lacking[tool].hint}")
72
+ ok = False
73
+ else:
74
+ print(f" ✓ {tool}")
75
+ for settings in config.postgres:
76
+ try:
77
+ found_pg = postgres.find(settings)
78
+ except Exception as exc:
79
+ print(f" ✗ Postgres: {exc}")
80
+ ok = False
81
+ continue
82
+ for target in found_pg.targets:
83
+ print(f" · Postgres {target.label}")
84
+ for where, error in found_pg.failures:
85
+ print(f" ✗ Postgres {where}: {error}")
86
+ ok = False
87
+ for item in config.sqlite:
88
+ try:
89
+ for path in sqlite.files(item):
90
+ print(f" · SQLite {path}")
91
+ except Exception as exc:
92
+ print(f" ✗ {exc}")
93
+ ok = False
94
+ for folder in config.files:
95
+ exists = Path(folder.path).is_dir()
96
+ print(f" {'·' if exists else '✗'} папка {folder.path}{'' if exists else ' — нет'}")
97
+ ok = ok and exists
98
+ if config.secrets:
99
+ print(f" · секреты из {config.secrets.root}: {', '.join(config.secrets.patterns)}")
100
+ for place in config.all_destinations:
101
+ error = place.probe() if hasattr(place, "probe") else ""
102
+ print(f" {'✗' if error else '→'} {place.label}{' — ' + error if error else ''}")
103
+ ok = ok and not error
104
+ if not config.all_destinations:
105
+ print(" ! мест хранения нет: копии только на этой машине")
106
+ for settings in config.postgres:
107
+ if problem := tools.pg_dump_version_problem(settings):
108
+ print(f" ✗ {problem.where}: {problem.error}\n → {problem.hint}")
109
+ ok = False
110
+ if config.notify.enabled:
111
+ places: list[str] = []
112
+ if config.notify.telegram_chats:
113
+ places.append(f"Telegram {', '.join(config.notify.telegram_chats)}")
114
+ if config.notify.webhook:
115
+ places.append("webhook")
116
+ print(
117
+ f" ✉ сообщения: {' и '.join(places)} от «{notify.server_name(config.notify)}»"
118
+ " — проверить: edgevault test-notify"
119
+ )
120
+ for line in notify.proxies(config.notify):
121
+ print(f" {line}")
122
+ else:
123
+ print(" ! сообщений нет: о сбое бэкапа никто не узнает — заполните [notify]")
124
+ print(f" расписание: {', '.join(config.schedule)}")
125
+ last = config.store / "last-run.txt"
126
+ if last.is_file():
127
+ print(f" последний прогон: {last.read_text(encoding='utf-8').strip()}")
128
+ return 0 if ok else 1
129
+
130
+
131
+ def cmd_test_notify(args: argparse.Namespace) -> int:
132
+ config = _config(args)
133
+ if not config.notify.enabled:
134
+ raise ConfigError("[notify] пуст: задайте telegram_token и telegram_chat или webhook")
135
+ errors = notify.deliver(config.notify, notify.test_message())
136
+ for line in notify.proxies(config.notify):
137
+ print(f" {line}")
138
+ print("Не ушло: " + "; ".join(errors) if errors else "Отправлено — проверьте чат")
139
+ return 1 if errors else 0
140
+
141
+
142
+ def cmd_init(args: argparse.Namespace) -> int:
143
+ """Посмотреть на проект и написать для него конфиг."""
144
+ root = Path(args.dir).resolve()
145
+ target = root / "edgevault.toml"
146
+ if target.exists() and not args.force:
147
+ raise ConfigError(f"{target} уже есть — не перезаписываю (--force, если нужно)")
148
+ project = scaffold.scan(root)
149
+ target.write_text(scaffold.render_config(project), encoding="utf-8")
150
+
151
+ print(f"Готово: {target}\n")
152
+ print("Нашлось:")
153
+ for db in project.databases:
154
+ state = "включён" if db.running else "база не запущена здесь — в конфиге закомментирована"
155
+ print(f" · Postgres {', '.join(db.services)} из {db.file} (проект {db.project}): {state}")
156
+ if not project.databases:
157
+ print(" · Postgres в compose-файлах не найден")
158
+ for path in project.sqlite:
159
+ print(f" · SQLite {path}")
160
+ for env in project.env_files:
161
+ print(f" · секреты {env}")
162
+ if project.folders:
163
+ print(f" ? папки {', '.join(project.folders)} — включите в конфиге, если нужны")
164
+ print("\nДальше:")
165
+ print(" 1. На СВОЁМ компьютере: edgevault keygen — ключ age-key.txt в менеджер паролей")
166
+ env_path = root / ".env"
167
+ print(f" 2. В {env_path}{'' if env_path.exists() else ' (создайте его)'}:")
168
+ for line in scaffold.env_variables(project):
169
+ print(f" {line}")
170
+ print(" 3. Вписать места хранения (targets, [remotes]) в edgevault.toml")
171
+ print(" 4. edgevault check && edgevault test-notify && edgevault run")
172
+ print(" 5. Запускать по расписанию — одно из:")
173
+ if project.compose_file:
174
+ print(f" а) сервис в {project.compose_file.name}:\n")
175
+ print(scaffold.compose_service(project))
176
+ print()
177
+ print(" б) без Docker: sudo edgevault systemd --install")
178
+ else:
179
+ print(" sudo edgevault systemd --install")
180
+ print("\nВ .gitignore: backups/ · age-key.txt (а конфиг можно хранить в git)")
181
+ return 0
182
+
183
+
184
+ def cmd_systemd(args: argparse.Namespace) -> int:
185
+ """Таймер systemd: бэкап по расписанию из конфига без Docker и без cron."""
186
+ config = _config(args)
187
+ path = Path(args.config or os.environ.get("EDGEVAULT_CONFIG") or "edgevault.toml").resolve()
188
+ units = scaffold.systemd_units(config.name, path, config.schedule)
189
+ if not args.install:
190
+ for name, text in units.items():
191
+ print(f"# /etc/systemd/system/{name}\n{text}")
192
+ print("Установить: sudo edgevault -c " + str(path) + " systemd --install")
193
+ return 0
194
+ folder = Path("/etc/systemd/system")
195
+ for name, text in units.items():
196
+ (folder / name).write_text(text, encoding="utf-8")
197
+ print(f"Записан {folder / name}")
198
+ timer = next(name for name in units if name.endswith(".timer"))
199
+ subprocess.run(["systemctl", "daemon-reload"], check=True)
200
+ subprocess.run(["systemctl", "enable", "--now", timer], check=True)
201
+ print(f"Включён {timer}. Журнал: journalctl -u {timer.removesuffix('.timer')}.service")
202
+ return 0
203
+
204
+
205
+ def cmd_keygen(args: argparse.Namespace) -> int:
206
+ public = crypto.keygen(Path(args.path))
207
+ print(f"Закрытый ключ: {args.path} — в менеджер паролей, на сервер его НЕ кладут.")
208
+ print(f"Открытый ключ для age_recipients:\n{public}")
209
+ return 0
210
+
211
+
212
+ def cmd_decrypt(args: argparse.Namespace) -> int:
213
+ """Расшифровать копию закрытым ключом. Ничего не перезаписывает без --force."""
214
+ source = Path(args.file)
215
+ if args.output:
216
+ out = Path(args.output)
217
+ elif source.suffix == ".age":
218
+ out = source.with_suffix("")
219
+ else:
220
+ raise ConfigError(f"{source} без .age в конце: укажите, куда расшифровать (-o)")
221
+ if out.resolve() == source.resolve():
222
+ raise ConfigError("Расшифровка поверх самой копии: укажите другой -o")
223
+ if out.exists() and not args.force:
224
+ raise ConfigError(f"{out} уже есть — не перезаписываю (--force, если нужно)")
225
+ try:
226
+ crypto.decrypt(source, out, Path(args.key))
227
+ except (age.AgeError, OSError) as exc:
228
+ print(f"Не расшифровалось: {exc}", file=sys.stderr)
229
+ return 1
230
+ print(f"Готово: {out}")
231
+ return 0
232
+
233
+
234
+ #: Облака со входом через браузер: имя в команде → тип rclone и флаги входа.
235
+ #: Google Диск — с доступом только к своим файлам (drive.file): программа видит
236
+ #: лишь то, что сама положила, а не весь Диск владельца.
237
+ AUTHORIZE: dict[str, tuple[str, list[str], str]] = {
238
+ "gdrive": ("drive", ["--drive-scope", "drive.file"], "GDRIVE_TOKEN"),
239
+ "yandex": ("yandex", [], "YADISK_TOKEN"),
240
+ }
241
+
242
+
243
+ def authorize_command(provider: str) -> list[str]:
244
+ kind, flags, _ = AUTHORIZE[provider]
245
+ return ["rclone", "authorize", kind, *flags]
246
+
247
+
248
+ def cmd_authorize(args: argparse.Namespace) -> int:
249
+ """Получить токен облака. Делать на своём компьютере: откроется вход в браузере."""
250
+ if not shutil.which("rclone"):
251
+ raise ConfigError("Нет программы rclone: https://rclone.org/install/")
252
+ _, _, variable = AUTHORIZE[args.provider]
253
+ print('Сейчас откроется вход в браузере. После входа здесь появится строка {"access_token":…}')
254
+ print(f"Её целиком — в переменную {variable} в .env на сервере.\n", flush=True)
255
+ return subprocess.run(authorize_command(args.provider)).returncode
256
+
257
+
258
+ def build_parser() -> argparse.ArgumentParser:
259
+ parser = argparse.ArgumentParser(
260
+ prog="edgevault", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
261
+ )
262
+ parser.add_argument("--version", action="version", version=f"edgevault {__version__}")
263
+ parser.add_argument("-c", "--config", help="путь к конфигу (по умолчанию ./edgevault.toml)")
264
+ sub = parser.add_subparsers(dest="command", required=True)
265
+
266
+ init = sub.add_parser("init", help="подключить к проекту: написать конфиг по папке проекта")
267
+ init.add_argument("dir", nargs="?", default=".", help="папка проекта (по умолчанию текущая)")
268
+ init.add_argument("--force", action="store_true", help="перезаписать edgevault.toml")
269
+ init.set_defaults(func=cmd_init)
270
+ sub.add_parser("check", help="проверить конфиг и показать, что сохраняется").set_defaults(
271
+ func=cmd_check
272
+ )
273
+ sub.add_parser("test-notify", help="прислать проверочное сообщение").set_defaults(
274
+ func=cmd_test_notify
275
+ )
276
+ sub.add_parser("run", help="сделать бэкап сейчас (для cron/systemd)").set_defaults(func=cmd_run)
277
+ sub.add_parser("daemon", help="работать постоянно, по расписанию").set_defaults(func=cmd_daemon)
278
+ systemd = sub.add_parser("systemd", help="таймер systemd по расписанию из конфига")
279
+ systemd.add_argument("--install", action="store_true", help="записать и включить (sudo)")
280
+ systemd.set_defaults(func=cmd_systemd)
281
+ pull_parser = sub.add_parser("pull", help="забирать копии к себе")
282
+ pull_parser.add_argument("--once", action="store_true", help="один раз и выйти")
283
+ pull_parser.set_defaults(func=cmd_pull)
284
+ keygen = sub.add_parser("keygen", help="новая пара ключей age")
285
+ keygen.add_argument("path", nargs="?", default="age-key.txt")
286
+ keygen.set_defaults(func=cmd_keygen)
287
+ decrypt = sub.add_parser("decrypt", help="расшифровать копию")
288
+ decrypt.add_argument("file")
289
+ decrypt.add_argument("-k", "--key", default="age-key.txt", help="закрытый ключ")
290
+ decrypt.add_argument("-o", "--output")
291
+ decrypt.add_argument("--force", action="store_true", help="перезаписать готовый файл")
292
+ decrypt.set_defaults(func=cmd_decrypt)
293
+ authorize = sub.add_parser("authorize", help="токен Google Диска или Яндекс Диска")
294
+ authorize.add_argument("provider", choices=sorted(AUTHORIZE))
295
+ authorize.set_defaults(func=cmd_authorize)
296
+ return parser
297
+
298
+
299
+ def main(argv: list[str] | None = None) -> None:
300
+ args = build_parser().parse_args(argv)
301
+ try:
302
+ sys.exit(args.func(args))
303
+ except ConfigError as exc:
304
+ print(f"Ошибка: {exc}", file=sys.stderr)
305
+ sys.exit(2)
306
+ except KeyboardInterrupt:
307
+ sys.exit(130)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()