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 +21 -0
- edgevault/__main__.py +3 -0
- edgevault/age.py +268 -0
- edgevault/cli.py +311 -0
- edgevault/config.py +658 -0
- edgevault/crypto.py +58 -0
- edgevault/destinations.py +384 -0
- edgevault/errors.py +9 -0
- edgevault/notify.py +329 -0
- edgevault/pull.py +80 -0
- edgevault/py.typed +0 -0
- edgevault/report.py +165 -0
- edgevault/runner.py +187 -0
- edgevault/scaffold.py +357 -0
- edgevault/schedule.py +18 -0
- edgevault/sources/__init__.py +11 -0
- edgevault/sources/archive.py +131 -0
- edgevault/sources/postgres.py +274 -0
- edgevault/sources/sqlite.py +69 -0
- edgevault/store.py +247 -0
- edgevault/tools.py +177 -0
- edgevault/vault.py +415 -0
- edgevault-0.5.0.dist-info/METADATA +279 -0
- edgevault-0.5.0.dist-info/RECORD +27 -0
- edgevault-0.5.0.dist-info/WHEEL +4 -0
- edgevault-0.5.0.dist-info/entry_points.txt +2 -0
- edgevault-0.5.0.dist-info/licenses/LICENSE +21 -0
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
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()
|