cdx-manager 0.3.3 → 0.4.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/README.md +183 -13
- package/bin/cdx +5 -2
- package/changelogs/CHANGELOGS_0_3_4.md +31 -0
- package/changelogs/CHANGELOGS_0_4_0.md +36 -0
- package/checksums/release-archives.json +9 -0
- package/install.ps1 +102 -0
- package/install.sh +51 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +134 -0
- package/src/cli.py +111 -13
- package/src/cli_commands.py +360 -38
- package/src/health.py +13 -1
- package/src/provider_runtime.py +10 -1
- package/src/session_service.py +185 -2
- package/src/session_store.py +11 -0
- package/src/status_source.py +3 -1
- package/src/update_check.py +107 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .errors import CdxError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
BUNDLE_SCHEMA_VERSION = 1
|
|
12
|
+
_SALT_BYTES = 16
|
|
13
|
+
_NONCE_BYTES = 16
|
|
14
|
+
_SCRYPT_N = 2 ** 14
|
|
15
|
+
_SCRYPT_R = 8
|
|
16
|
+
_SCRYPT_P = 1
|
|
17
|
+
_PBKDF2_ITERATIONS = 200000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _now_iso():
|
|
21
|
+
return datetime.now(timezone.utc).astimezone().isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64_encode(data):
|
|
25
|
+
return base64.b64encode(data).decode("ascii")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _b64_decode(data):
|
|
29
|
+
try:
|
|
30
|
+
return base64.b64decode(data.encode("ascii"))
|
|
31
|
+
except (AttributeError, ValueError, UnicodeEncodeError) as error:
|
|
32
|
+
raise CdxError("Bundle contains invalid base64 data.") from error
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_bundle_meta(data):
|
|
36
|
+
try:
|
|
37
|
+
wrapper = json.loads(data.decode("utf-8"))
|
|
38
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
39
|
+
raise CdxError("Invalid bundle format.") from error
|
|
40
|
+
|
|
41
|
+
if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
|
|
42
|
+
raise CdxError("Unsupported bundle schema version.")
|
|
43
|
+
return wrapper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _derive_keys(passphrase, salt):
|
|
47
|
+
if not passphrase:
|
|
48
|
+
raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
|
|
49
|
+
if isinstance(passphrase, str):
|
|
50
|
+
passphrase = passphrase.encode("utf-8")
|
|
51
|
+
if hasattr(hashlib, "scrypt"):
|
|
52
|
+
key_material = hashlib.scrypt(
|
|
53
|
+
passphrase,
|
|
54
|
+
salt=salt,
|
|
55
|
+
n=_SCRYPT_N,
|
|
56
|
+
r=_SCRYPT_R,
|
|
57
|
+
p=_SCRYPT_P,
|
|
58
|
+
dklen=64,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
key_material = hashlib.pbkdf2_hmac(
|
|
62
|
+
"sha256",
|
|
63
|
+
passphrase,
|
|
64
|
+
salt,
|
|
65
|
+
_PBKDF2_ITERATIONS,
|
|
66
|
+
dklen=64,
|
|
67
|
+
)
|
|
68
|
+
return key_material[:32], key_material[32:]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _xor_keystream(data, key, nonce):
|
|
72
|
+
output = bytearray()
|
|
73
|
+
counter = 0
|
|
74
|
+
while len(output) < len(data):
|
|
75
|
+
block = hashlib.sha256(key + nonce + counter.to_bytes(8, "big")).digest()
|
|
76
|
+
output.extend(block)
|
|
77
|
+
counter += 1
|
|
78
|
+
return bytes(a ^ b for a, b in zip(data, output[:len(data)]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def encode_bundle(payload, include_auth=False, passphrase=None):
|
|
82
|
+
payload_bytes = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
83
|
+
wrapper = {
|
|
84
|
+
"schema_version": BUNDLE_SCHEMA_VERSION,
|
|
85
|
+
"bundle_version": 1,
|
|
86
|
+
"created_at": _now_iso(),
|
|
87
|
+
"include_auth": bool(include_auth),
|
|
88
|
+
"encrypted": bool(include_auth),
|
|
89
|
+
"session_names": [item["name"] for item in payload.get("sessions", [])],
|
|
90
|
+
}
|
|
91
|
+
if include_auth:
|
|
92
|
+
salt = os.urandom(_SALT_BYTES)
|
|
93
|
+
nonce = os.urandom(_NONCE_BYTES)
|
|
94
|
+
enc_key, mac_key = _derive_keys(passphrase, salt)
|
|
95
|
+
ciphertext = _xor_keystream(payload_bytes, enc_key, nonce)
|
|
96
|
+
mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
97
|
+
wrapper.update({
|
|
98
|
+
"salt": _b64_encode(salt),
|
|
99
|
+
"nonce": _b64_encode(nonce),
|
|
100
|
+
"hmac_sha256": _b64_encode(mac),
|
|
101
|
+
"payload": _b64_encode(ciphertext),
|
|
102
|
+
})
|
|
103
|
+
else:
|
|
104
|
+
wrapper["payload"] = _b64_encode(payload_bytes)
|
|
105
|
+
return json.dumps(wrapper, indent=2).encode("utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def decode_bundle(data, passphrase=None):
|
|
109
|
+
wrapper = read_bundle_meta(data)
|
|
110
|
+
|
|
111
|
+
encrypted = bool(wrapper.get("encrypted"))
|
|
112
|
+
payload_b64 = wrapper.get("payload")
|
|
113
|
+
if not isinstance(payload_b64, str):
|
|
114
|
+
raise CdxError("Bundle payload is missing.")
|
|
115
|
+
|
|
116
|
+
if encrypted:
|
|
117
|
+
salt = _b64_decode(wrapper.get("salt", ""))
|
|
118
|
+
nonce = _b64_decode(wrapper.get("nonce", ""))
|
|
119
|
+
expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
|
|
120
|
+
ciphertext = _b64_decode(payload_b64)
|
|
121
|
+
enc_key, mac_key = _derive_keys(passphrase, salt)
|
|
122
|
+
actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
123
|
+
if not hmac.compare_digest(actual_mac, expected_mac):
|
|
124
|
+
raise CdxError("Invalid bundle passphrase or corrupted bundle.")
|
|
125
|
+
payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
|
|
126
|
+
else:
|
|
127
|
+
payload_bytes = _b64_decode(payload_b64)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
131
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
132
|
+
raise CdxError("Bundle payload is corrupt.") from error
|
|
133
|
+
|
|
134
|
+
return {"meta": wrapper, "payload": payload}
|
package/src/cli.py
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import sys
|
|
5
6
|
|
|
6
7
|
from .cli_commands import (
|
|
8
|
+
API_SCHEMA_VERSION,
|
|
7
9
|
STATUS_USAGE,
|
|
8
10
|
handle_add,
|
|
9
11
|
handle_clean,
|
|
10
12
|
handle_copy,
|
|
11
13
|
handle_doctor,
|
|
14
|
+
handle_export,
|
|
15
|
+
handle_import,
|
|
12
16
|
handle_launch,
|
|
13
17
|
handle_login,
|
|
14
18
|
handle_logout,
|
|
@@ -38,8 +42,9 @@ from .status_view import (
|
|
|
38
42
|
_format_status_detail,
|
|
39
43
|
_format_status_rows,
|
|
40
44
|
)
|
|
45
|
+
from .update_check import check_for_update
|
|
41
46
|
|
|
42
|
-
VERSION = "0.
|
|
47
|
+
VERSION = "0.4.0"
|
|
43
48
|
|
|
44
49
|
|
|
45
50
|
# ---------------------------------------------------------------------------
|
|
@@ -52,21 +57,24 @@ def _print_help(use_color=False):
|
|
|
52
57
|
"",
|
|
53
58
|
_style("Usage:", "1", use_color),
|
|
54
59
|
f" {_style('cdx', '36', use_color)}",
|
|
60
|
+
f" {_style('cdx --json', '36', use_color)}",
|
|
55
61
|
f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
|
|
56
62
|
f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
|
|
57
63
|
f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
|
|
58
|
-
f" {_style('cdx add [provider] <name>', '36', use_color)}",
|
|
59
|
-
f" {_style('cdx cp <source> <dest>', '36', use_color)}",
|
|
60
|
-
f" {_style('cdx ren <source> <dest>', '36', use_color)}",
|
|
61
|
-
f" {_style('cdx login <name>', '36', use_color)}",
|
|
62
|
-
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
63
|
-
f" {_style('cdx rmv <name> [--force]', '36', use_color)}",
|
|
64
|
-
f" {_style('cdx clean [name]', '36', use_color)}",
|
|
64
|
+
f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
|
|
65
|
+
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
66
|
+
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
|
67
|
+
f" {_style('cdx login <name> [--json]', '36', use_color)}",
|
|
68
|
+
f" {_style('cdx logout <name> [--json]', '36', use_color)}",
|
|
69
|
+
f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
|
|
70
|
+
f" {_style('cdx clean [name] [--json]', '36', use_color)}",
|
|
71
|
+
f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
|
|
72
|
+
f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
|
|
65
73
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
66
74
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
67
|
-
f" {_style('cdx notify <name> --at-reset', '36', use_color)}",
|
|
68
|
-
f" {_style('cdx notify --next-ready', '36', use_color)}",
|
|
69
|
-
f" {_style('cdx <name>', '36', use_color)}",
|
|
75
|
+
f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
|
|
76
|
+
f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
|
|
77
|
+
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
70
78
|
f" {_style('cdx --help', '36', use_color)}",
|
|
71
79
|
f" {_style('cdx --version', '36', use_color)}",
|
|
72
80
|
])
|
|
@@ -76,6 +84,63 @@ def _print_version():
|
|
|
76
84
|
return VERSION
|
|
77
85
|
|
|
78
86
|
|
|
87
|
+
def wants_json(argv):
|
|
88
|
+
return "--json" in argv
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_json_error(error):
|
|
92
|
+
message = str(error)
|
|
93
|
+
code = "cdx_error"
|
|
94
|
+
if message.startswith("Usage:"):
|
|
95
|
+
code = "invalid_usage"
|
|
96
|
+
elif message.startswith("Unknown session:"):
|
|
97
|
+
code = "unknown_session"
|
|
98
|
+
elif message.startswith("Unknown command:"):
|
|
99
|
+
code = "unknown_command"
|
|
100
|
+
elif message.startswith("Session already exists:"):
|
|
101
|
+
code = "session_exists"
|
|
102
|
+
elif "requires an interactive terminal" in message or "requires confirmation" in message:
|
|
103
|
+
code = "interactive_terminal_required"
|
|
104
|
+
return json.dumps({
|
|
105
|
+
"schema_version": API_SCHEMA_VERSION,
|
|
106
|
+
"ok": False,
|
|
107
|
+
"error": {
|
|
108
|
+
"code": code,
|
|
109
|
+
"message": message,
|
|
110
|
+
"exit_code": error.exit_code,
|
|
111
|
+
},
|
|
112
|
+
}, indent=2)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _get_update_notice(service, env, options):
|
|
116
|
+
checker = options.get("checkForUpdate") or check_for_update
|
|
117
|
+
return checker(
|
|
118
|
+
service["base_dir"],
|
|
119
|
+
VERSION,
|
|
120
|
+
env=env,
|
|
121
|
+
now_fn=options.get("now"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _update_warning_payload(notice):
|
|
126
|
+
if not notice:
|
|
127
|
+
return []
|
|
128
|
+
message = f"Update available: cdx-manager {notice['latest_version']} (current {VERSION})"
|
|
129
|
+
return [{
|
|
130
|
+
"code": "update_available",
|
|
131
|
+
"message": message,
|
|
132
|
+
"latest_version": notice["latest_version"],
|
|
133
|
+
"url": notice.get("url"),
|
|
134
|
+
}]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _update_warning_text(notice):
|
|
138
|
+
if not notice:
|
|
139
|
+
return None
|
|
140
|
+
suffix = f" {notice['url']}" if notice.get("url") else ""
|
|
141
|
+
return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}).{suffix}"
|
|
142
|
+
|
|
143
|
+
|
|
79
144
|
# ---------------------------------------------------------------------------
|
|
80
145
|
# main()
|
|
81
146
|
# ---------------------------------------------------------------------------
|
|
@@ -114,14 +179,24 @@ def main(argv, options=None):
|
|
|
114
179
|
out(f"{_print_version()}\n")
|
|
115
180
|
return 0
|
|
116
181
|
|
|
182
|
+
if argv == ["--json"]:
|
|
183
|
+
rows = service["format_list_rows"]()
|
|
184
|
+
notice = _get_update_notice(service, env, options)
|
|
185
|
+
out(f"{json.dumps(_list_json_payload(rows, notice=notice), indent=2)}\n")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
117
188
|
if not argv:
|
|
189
|
+
notice = _get_update_notice(service, env, options)
|
|
118
190
|
out(f"{_format_sessions(service, use_color=use_color)}\n")
|
|
191
|
+
if notice:
|
|
192
|
+
out(f"{_style(_update_warning_text(notice), '33', use_color)}\n")
|
|
119
193
|
return 0
|
|
120
194
|
|
|
121
195
|
command, *rest = argv
|
|
122
196
|
ctx = {
|
|
123
197
|
"env": env,
|
|
124
198
|
"options": options,
|
|
199
|
+
"raw_args": argv,
|
|
125
200
|
"err": err,
|
|
126
201
|
"out": out,
|
|
127
202
|
"refresh_fn": refresh_fn,
|
|
@@ -130,6 +205,9 @@ def main(argv, options=None):
|
|
|
130
205
|
"spawn": spawn,
|
|
131
206
|
"spawn_sync": spawn_sync,
|
|
132
207
|
"stdin_is_tty": stdin_is_tty,
|
|
208
|
+
"update_notice": _get_update_notice(service, env, options) if command not in (
|
|
209
|
+
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "notify", "status", "login", "logout", "export", "import", "help", "version"
|
|
210
|
+
) else None,
|
|
133
211
|
"use_color": use_color,
|
|
134
212
|
}
|
|
135
213
|
|
|
@@ -148,6 +226,12 @@ def main(argv, options=None):
|
|
|
148
226
|
if command == "clean":
|
|
149
227
|
return handle_clean(rest, ctx)
|
|
150
228
|
|
|
229
|
+
if command == "export":
|
|
230
|
+
return handle_export(rest, ctx)
|
|
231
|
+
|
|
232
|
+
if command == "import":
|
|
233
|
+
return handle_import(rest, ctx)
|
|
234
|
+
|
|
151
235
|
if command == "doctor":
|
|
152
236
|
return handle_doctor(rest, ctx)
|
|
153
237
|
|
|
@@ -174,12 +258,23 @@ def main(argv, options=None):
|
|
|
174
258
|
out(f"{_print_version()}\n")
|
|
175
259
|
return 0
|
|
176
260
|
|
|
177
|
-
if not rest:
|
|
261
|
+
if not rest or rest == ["--json"]:
|
|
178
262
|
return handle_launch(command, ctx)
|
|
179
263
|
|
|
180
264
|
raise CdxError(f"Unknown command: {command}. Use cdx --help.")
|
|
181
265
|
|
|
182
266
|
|
|
267
|
+
def _list_json_payload(rows, notice=None):
|
|
268
|
+
return {
|
|
269
|
+
"schema_version": API_SCHEMA_VERSION,
|
|
270
|
+
"ok": True,
|
|
271
|
+
"action": "list",
|
|
272
|
+
"message": "Listed known sessions",
|
|
273
|
+
"warnings": _update_warning_payload(notice),
|
|
274
|
+
"sessions": rows,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
183
278
|
def _enable_windows_ansi():
|
|
184
279
|
if sys.platform != "win32":
|
|
185
280
|
return
|
|
@@ -212,7 +307,10 @@ def cli_entry():
|
|
|
212
307
|
try:
|
|
213
308
|
raise SystemExit(main(sys.argv[1:]))
|
|
214
309
|
except CdxError as error:
|
|
215
|
-
sys.
|
|
310
|
+
if wants_json(sys.argv[1:]):
|
|
311
|
+
sys.stderr.write(f"{format_json_error(error)}\n")
|
|
312
|
+
else:
|
|
313
|
+
sys.stderr.write(f"{format_error(error)}\n")
|
|
216
314
|
raise SystemExit(error.exit_code)
|
|
217
315
|
|
|
218
316
|
|