Skip to content

Commit 689e44b

Browse files
committed
feat: support multiple accounts in Vaultwarden backup
1 parent ff15a89 commit 689e44b

2 files changed

Lines changed: 119 additions & 48 deletions

File tree

scripts/bw_backup.py

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
import subprocess
99
import sys
1010
import zipfile
11+
from collections.abc import Mapping, Sequence
1112
from datetime import datetime
1213
from pathlib import Path
14+
from typing import Any
1315

1416
import common
1517
import requests
@@ -22,8 +24,11 @@
2224
GITHUB_API = "https://api.github.com/repos/bitwarden/clients/releases"
2325
REQUEST_TIMEOUT = 30
2426

27+
AccountConfig = dict[str, Any]
28+
AssetInfo = dict[str, str]
2529

26-
def sha256_file(path, chunk_size=1024 * 1024):
30+
31+
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
2732
"""Return SHA-256 hash of a file."""
2833

2934
digest = hashlib.sha256()
@@ -35,7 +40,11 @@ def sha256_file(path, chunk_size=1024 * 1024):
3540
return digest.hexdigest()
3641

3742

38-
def run(command, env=None, timeout=120):
43+
def run(
44+
command: Sequence[str | os.PathLike[str]],
45+
env: Mapping[str, str] | None = None,
46+
timeout: int = 120,
47+
) -> str:
3948
"""Run a command and return stdout."""
4049

4150
try:
@@ -58,21 +67,38 @@ def run(command, env=None, timeout=120):
5867
return result.stdout.strip()
5968

6069

61-
def load_config():
62-
"""Load configuration from bw-backup.json."""
63-
64-
config = common.Config("bw-backup.json").get_config()
70+
def _validate_account_config(config: Mapping[str, Any]) -> None:
71+
"""Validate the required settings for one Bitwarden account."""
6572

6673
required = ["vault_url", "client_id", "client_secret"]
6774

6875
for key in required:
6976
if not config.get(key):
7077
raise RuntimeError(f"Missing '{key}' in bw-backup.json")
7178

79+
80+
def load_config() -> AccountConfig:
81+
"""Load configuration from bw-backup.json."""
82+
83+
config = common.Config("bw-backup.json").get_config()
84+
85+
_validate_account_config(config)
86+
7287
return config
7388

7489

75-
def get_platform_info():
90+
def load_configs() -> list[AccountConfig]:
91+
"""Load one or more Bitwarden account configurations."""
92+
93+
config = common.Config("bw-backup.json").get_config()
94+
95+
if "accounts" not in config:
96+
return [load_config()]
97+
98+
return config["accounts"]
99+
100+
101+
def get_platform_info() -> tuple[str, str]:
76102
"""Determine the Bitwarden CLI asset for this machine."""
77103

78104
system = platform.system().lower()
@@ -103,7 +129,7 @@ def get_platform_info():
103129
return f"bw-oss-{platform_name}-{architecture_suffix}", executable
104130

105131

106-
def get_latest_bw_asset():
132+
def get_latest_bw_asset() -> AssetInfo:
107133
"""Find the latest stable Bitwarden CLI release."""
108134

109135
prefix, executable = get_platform_info()
@@ -151,7 +177,7 @@ def get_latest_bw_asset():
151177
)
152178

153179

154-
def safe_extract_zip(archive, destination):
180+
def safe_extract_zip(archive: Path, destination: Path) -> None:
155181
"""
156182
Extract ZIP while preventing path traversal.
157183
"""
@@ -168,14 +194,14 @@ def safe_extract_zip(archive, destination):
168194
zf.extractall(destination)
169195

170196

171-
def safe_filename(name):
197+
def safe_filename(name: str) -> str:
172198
"""Return a filesystem-safe filename component."""
173199

174200
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-")
175201
return name or "unnamed"
176202

177203

178-
def prepare_bw():
204+
def prepare_bw() -> Path:
179205
"""
180206
Download the latest Bitwarden CLI if necessary.
181207
@@ -266,12 +292,15 @@ def prepare_bw():
266292
return bw_path
267293

268294

269-
def create_backup(bw, config):
295+
def create_backup(bw: Path, config: AccountConfig) -> None:
270296
"""Create an encrypted Bitwarden JSON backup."""
271297

272-
master_password = getpass.getpass("✍️ Vaultwarden master password: ")
298+
account_name = config.get("name")
299+
log_prefix = f"[{account_name or 'default'}]"
273300

274-
export_password = getpass.getpass("✍️ Backup encryption password: ")
301+
master_password = getpass.getpass(f"✍️ {log_prefix} Vaultwarden master password: ")
302+
303+
export_password = getpass.getpass(f"✍️ {log_prefix} Backup encryption password: ")
275304

276305
if not export_password:
277306
raise RuntimeError("Backup encryption password cannot be empty.")
@@ -293,25 +322,15 @@ def create_backup(bw, config):
293322
)
294323

295324
if current_server.rstrip("/") != config["vault_url"].rstrip("/"):
296-
print("🌐 Changing Vaultwarden server...")
325+
print(f"🌐 {log_prefix} Changing Vaultwarden server...")
297326
run([str(bw), "logout"], env=env)
298327
run([str(bw), "config", "server", config["vault_url"]], env=env)
299328

300-
status = json.loads(
301-
run(
302-
[
303-
str(bw),
304-
"status",
305-
],
306-
env=env,
307-
)
308-
)
309-
310-
if status["status"] == "unauthenticated":
311-
print("🔑 Logging in with API key...")
312-
run([str(bw), "login", "--apikey"], env=env)
329+
print(f"🔑 {log_prefix} Logging in with API key...")
330+
run([str(bw), "logout"], env=env)
331+
run([str(bw), "login", "--apikey"], env=env)
313332

314-
print("🔓 Unlocking vault...")
333+
print(f"🔓 {log_prefix} Unlocking vault...")
315334

316335
unlock_command = [
317336
str(bw),
@@ -321,13 +340,7 @@ def create_backup(bw, config):
321340
"--raw",
322341
]
323342

324-
try:
325-
session = run(unlock_command, env=env)
326-
except RuntimeError:
327-
print("🔐 Local login state is invalid, logging in again...")
328-
run([str(bw), "logout"], env=env)
329-
run([str(bw), "login", "--apikey"], env=env)
330-
session = run(unlock_command, env=env)
343+
session = run(unlock_command, env=env)
331344

332345
if not session:
333346
raise RuntimeError("bw unlock did not return a session.")
@@ -337,7 +350,7 @@ def create_backup(bw, config):
337350
master_password = None
338351
env.pop("BW_MASTER_PASSWORD", None)
339352

340-
print("🔄 Synchronizing vault...")
353+
print(f"🔄 {log_prefix} Synchronizing vault...")
341354

342355
run(
343356
[
@@ -358,10 +371,12 @@ def create_backup(bw, config):
358371
for organization in organizations
359372
]
360373

361-
print("💾 Creating encrypted backups...")
374+
print(f"💾 {log_prefix} Creating encrypted backups...")
375+
376+
filename_prefix = f"{safe_filename(account_name)}-" if account_name else ""
362377

363378
for name, organization_id in exports:
364-
output = BACKUP_DIR / f"vault-{timestamp}-{name}.json"
379+
output = BACKUP_DIR / f"vault-{timestamp}-{filename_prefix}{name}.json"
365380
command = [
366381
str(bw),
367382
"export",
@@ -380,12 +395,12 @@ def create_backup(bw, config):
380395
if output.stat().st_size == 0:
381396
raise RuntimeError(f"Backup file is empty: {output}")
382397

383-
print(f"✅ Backup successfully created: {output}")
398+
print(f"✅ {log_prefix} Backup successfully created: {output}")
384399

385400
export_password = None
386401

387402
finally:
388-
print("🔒 Locking Bitwarden vault...")
403+
print(f"🔒 {log_prefix} Locking Bitwarden vault...")
389404

390405
try:
391406
run(
@@ -396,11 +411,11 @@ def create_backup(bw, config):
396411
env=env,
397412
)
398413

399-
print("✅ Vault locked")
414+
print(f"✅ {log_prefix} Vault locked")
400415

401416
except Exception as exc: # noqa: BLE001
402417
print(
403-
f"⚠️ Could not lock vault: {exc}",
418+
f"⚠️ {log_prefix} Could not lock vault: {exc}",
404419
file=sys.stderr,
405420
)
406421

@@ -411,10 +426,10 @@ def create_backup(bw, config):
411426
export_password = None
412427

413428

414-
def main():
429+
def main() -> None:
415430
bw = prepare_bw()
416-
config = load_config()
417-
create_backup(bw, config)
431+
for config in load_configs():
432+
create_backup(bw, config)
418433

419434

420435
if __name__ == "__main__":

tests/test_bw_backup.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,62 @@ def test_sha256_file(tmp_path: Path):
1616
assert bw_backup.sha256_file(path) == hashlib.sha256(b"backup data").hexdigest()
1717

1818

19+
def test_load_configs_supports_multiple_accounts(
20+
monkeypatch: pytest.MonkeyPatch,
21+
):
22+
config = {
23+
"accounts": [
24+
{
25+
"name": "personal",
26+
"vault_url": "https://personal.example.com",
27+
"client_id": "personal-client",
28+
"client_secret": "personal-secret",
29+
},
30+
{
31+
"name": "work",
32+
"vault_url": "https://work.example.com",
33+
"client_id": "work-client",
34+
"client_secret": "work-secret",
35+
},
36+
]
37+
}
38+
39+
class Config:
40+
def __init__(self, _filename):
41+
pass
42+
43+
def get_config(self):
44+
return config
45+
46+
monkeypatch.setattr(bw_backup.common, "Config", Config)
47+
48+
accounts = bw_backup.load_configs()
49+
50+
assert [account["name"] for account in accounts] == ["personal", "work"]
51+
52+
53+
def test_load_configs_supports_single_account(
54+
monkeypatch: pytest.MonkeyPatch,
55+
):
56+
config = {
57+
"name": "personal",
58+
"vault_url": "https://personal.example.com",
59+
"client_id": "personal-client",
60+
"client_secret": "personal-secret",
61+
}
62+
63+
class Config:
64+
def __init__(self, _filename):
65+
pass
66+
67+
def get_config(self):
68+
return config
69+
70+
monkeypatch.setattr(bw_backup.common, "Config", Config)
71+
72+
assert bw_backup.load_configs() == [config]
73+
74+
1975
@pytest.mark.parametrize(
2076
("system", "machine", "expected_prefix", "expected_executable"),
2177
[
@@ -171,8 +227,8 @@ def fake_run(command, env=None):
171227
},
172228
)
173229

174-
assert not any(command[1:] == ["logout"] for command in commands)
175-
assert not any(command[1:] == ["login", "--apikey"] for command in commands)
230+
assert any(command[1:] == ["logout"] for command in commands)
231+
assert any(command[1:] == ["login", "--apikey"] for command in commands)
176232
assert any(command[1:] == ["lock"] for command in commands)
177233
exports = [command for command in commands if command[1] == "export"]
178234
assert len(exports) == 2

0 commit comments

Comments
 (0)