akashcli 4.6.3__tar.gz → 4.6.4__tar.gz
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.
- {akashcli-4.6.3 → akashcli-4.6.4}/PKG-INFO +2 -3
- akashcli-4.6.4/akashcli/__init__.py +7 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/_net.py +2 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/cli.py +48 -36
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/exec.py +31 -23
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/relay.py +70 -83
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/restore.py +144 -56
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/terminal.py +150 -22
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/uploader.py +36 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/PKG-INFO +2 -3
- {akashcli-4.6.3 → akashcli-4.6.4}/pyproject.toml +7 -3
- akashcli-4.6.3/akashcli/__init__.py +0 -6
- {akashcli-4.6.3 → akashcli-4.6.4}/README.md +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/auth.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/client.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/config.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/crypto.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/env_manager.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/exceptions.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/integrity.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/logger.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/models.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/p2p.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/packages.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/snapshot_crypto.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/storage.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli/utils.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/SOURCES.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/dependency_links.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/entry_points.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/requires.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/akashcli.egg-info/top_level.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.4}/setup.cfg +0 -0
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: akashcli
|
|
3
|
-
Version: 4.6.
|
|
3
|
+
Version: 4.6.4
|
|
4
4
|
Summary: Akash CLI — secure remote shell relay, file vault, and device management
|
|
5
|
-
License: MIT
|
|
5
|
+
License-Expression: MIT
|
|
6
6
|
Keywords: akash,cli,relay,remote-shell,vault
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
9
8
|
Classifier: Operating System :: OS Independent
|
|
10
9
|
Classifier: Environment :: Console
|
|
11
10
|
Classifier: Topic :: Utilities
|
|
@@ -112,6 +112,8 @@ def ws_connect(url: str, **kwargs):
|
|
|
112
112
|
|
|
113
113
|
sock_timeout = kwargs.pop("_sock_timeout", 15)
|
|
114
114
|
sock = make_socket(host, port, timeout=sock_timeout)
|
|
115
|
+
# Remove socket timeout so the WebSocket connection remains open permanently
|
|
116
|
+
sock.settimeout(None)
|
|
115
117
|
|
|
116
118
|
# legacy=True: allow calling connect() outside of a `with` block.
|
|
117
119
|
# websockets ≥ 13 requires context manager usage by default;
|
|
@@ -141,6 +141,13 @@ def _upload_with_progress(client: AkashClient, tmp_path: str, password=None) ->
|
|
|
141
141
|
# `app` — primary commands (backup / restore / pull / run / shell …)
|
|
142
142
|
# `cloud_app` — file management (akash cloud upload/files/stream/delete/manage/bulk)
|
|
143
143
|
|
|
144
|
+
|
|
145
|
+
def _version_callback(value: bool):
|
|
146
|
+
if value:
|
|
147
|
+
from . import __version__
|
|
148
|
+
print("akashcli v" + __version__)
|
|
149
|
+
raise typer.Exit(0)
|
|
150
|
+
|
|
144
151
|
app = typer.Typer(
|
|
145
152
|
invoke_without_command=True,
|
|
146
153
|
rich_markup_mode=None,
|
|
@@ -150,6 +157,9 @@ app = typer.Typer(
|
|
|
150
157
|
|
|
151
158
|
@app.callback(invoke_without_command=True)
|
|
152
159
|
def _main(ctx: typer.Context,
|
|
160
|
+
version: bool = typer.Option(False, "--version", "-v", is_eager=True, expose_value=False,
|
|
161
|
+
callback=lambda v: (print("akashcli v1.5.0"), raise_exit()) if v else None,
|
|
162
|
+
help="Show version and exit."),
|
|
153
163
|
help: bool = typer.Option(False, "--help", "-h", is_eager=True, expose_value=False,
|
|
154
164
|
callback=lambda v: (_print_help(), raise_exit()) if v else None,
|
|
155
165
|
help="Show this message and exit.")):
|
|
@@ -256,6 +266,24 @@ def login(
|
|
|
256
266
|
|
|
257
267
|
# ── akash whoami ─────────────────────────────────────────────────────────────
|
|
258
268
|
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def version(url: Optional[str] = typer.Option(None, "--url", help="Server URL to check")):
|
|
272
|
+
"""Show akash CLI version and connected server version."""
|
|
273
|
+
from . import __version__
|
|
274
|
+
ver_tag = "v" + __version__
|
|
275
|
+
print(f"\n {_C}◈{_RS} {_W}akashcli{_RS} {_cy(ver_tag)}")
|
|
276
|
+
try:
|
|
277
|
+
_, base = _resolve_client(url, require_key=False)
|
|
278
|
+
resp = httpx.get(f"{base}/health", timeout=3.0)
|
|
279
|
+
if resp.status_code == 200:
|
|
280
|
+
s_ver = resp.json().get("version", "unknown")
|
|
281
|
+
srv_tag = "(" + s_ver + ")"
|
|
282
|
+
print(f" {_DM}server {_RS}{_cy(base)} {_dim(srv_tag)}")
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
print()
|
|
286
|
+
|
|
259
287
|
@app.command()
|
|
260
288
|
def whoami():
|
|
261
289
|
"""Show current account and storage usage."""
|
|
@@ -842,41 +870,10 @@ def pull(
|
|
|
842
870
|
except KeyError:
|
|
843
871
|
pass
|
|
844
872
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
dest = None
|
|
851
|
-
if name.startswith("home/"):
|
|
852
|
-
dest = home / name[len("home/"):]
|
|
853
|
-
elif name.startswith("abs/"):
|
|
854
|
-
dest = Path(name[len("abs"):])
|
|
855
|
-
elif name.startswith("extra/"):
|
|
856
|
-
dest = home / name[len("extra/"):]
|
|
857
|
-
else:
|
|
858
|
-
skipped_list.append(name)
|
|
859
|
-
continue
|
|
860
|
-
|
|
861
|
-
if dry_run:
|
|
862
|
-
restored_list.append(str(dest))
|
|
863
|
-
continue
|
|
864
|
-
|
|
865
|
-
try:
|
|
866
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
867
|
-
file_data = tar.extractfile(member)
|
|
868
|
-
if file_data is None:
|
|
869
|
-
skipped_list.append(str(dest))
|
|
870
|
-
continue
|
|
871
|
-
dest.write_bytes(file_data.read())
|
|
872
|
-
try:
|
|
873
|
-
os.chmod(dest, member.mode)
|
|
874
|
-
except OSError:
|
|
875
|
-
pass
|
|
876
|
-
restored_list.append(str(dest))
|
|
877
|
-
except (PermissionError, OSError) as e:
|
|
878
|
-
skipped_list.append(f"{dest} ({e})")
|
|
879
|
-
|
|
873
|
+
from .restore import validate_and_extract_members
|
|
874
|
+
restored_list, skipped_list = validate_and_extract_members(
|
|
875
|
+
tar, target_home=home, dry_run=dry_run
|
|
876
|
+
)
|
|
880
877
|
except Exception as e:
|
|
881
878
|
_err(f"extraction failed: {e}")
|
|
882
879
|
raise typer.Exit(1)
|
|
@@ -1250,7 +1247,8 @@ def shell(
|
|
|
1250
1247
|
|
|
1251
1248
|
@app.command()
|
|
1252
1249
|
def devices(
|
|
1253
|
-
revoke:
|
|
1250
|
+
revoke: Optional[str] = typer.Option(None, "--revoke", "-r", help="Revoke (unenroll) a device by label"),
|
|
1251
|
+
authorize: Optional[str] = typer.Option(None, "--authorize", "-a", help="Grant server execution access to device"),
|
|
1254
1252
|
raw: bool = typer.Option(False, "--json", help="Raw JSON output"),
|
|
1255
1253
|
):
|
|
1256
1254
|
"""List enrolled devices — or revoke one.
|
|
@@ -1292,6 +1290,20 @@ def devices(
|
|
|
1292
1290
|
|
|
1293
1291
|
_hdrs = {"x-api-key": api_key, "Origin": "https://akash.cli"}
|
|
1294
1292
|
|
|
1293
|
+
if authorize:
|
|
1294
|
+
try:
|
|
1295
|
+
import httpx as _hx
|
|
1296
|
+
resp = _hx.post(
|
|
1297
|
+
f"{base}/api/exec/devices/{authorize}/authorize",
|
|
1298
|
+
headers=_hdrs, timeout=10,
|
|
1299
|
+
)
|
|
1300
|
+
resp.raise_for_status()
|
|
1301
|
+
_ok(f"authorized {_ye(authorize)} for server execution")
|
|
1302
|
+
except Exception as e:
|
|
1303
|
+
_err(f"authorize failed: {e}")
|
|
1304
|
+
raise typer.Exit(1)
|
|
1305
|
+
return
|
|
1306
|
+
|
|
1295
1307
|
if revoke:
|
|
1296
1308
|
try:
|
|
1297
1309
|
import httpx as _hx
|
|
@@ -93,10 +93,21 @@ def _get_machine_key() -> bytes:
|
|
|
93
93
|
return key
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def _get_ed25519_key():
|
|
97
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519 as _ed25519
|
|
98
|
+
raw = _get_machine_key()
|
|
99
|
+
return _ed25519.Ed25519PrivateKey.from_private_bytes(raw)
|
|
100
|
+
|
|
101
|
+
def _get_public_key_b64() -> str:
|
|
102
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
103
|
+
pub = _get_ed25519_key().public_key()
|
|
104
|
+
raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
105
|
+
return base64.b64encode(raw).decode("utf-8")
|
|
106
|
+
|
|
96
107
|
def _sign_challenge(challenge: str) -> str:
|
|
97
|
-
|
|
98
|
-
sig =
|
|
99
|
-
return base64.b64encode(sig).decode()
|
|
108
|
+
priv = _get_ed25519_key()
|
|
109
|
+
sig = priv.sign(challenge.encode("utf-8"))
|
|
110
|
+
return base64.b64encode(sig).decode("utf-8")
|
|
100
111
|
|
|
101
112
|
|
|
102
113
|
def _device_id() -> str:
|
|
@@ -235,14 +246,6 @@ def _session_info() -> dict:
|
|
|
235
246
|
# ── Authentication ────────────────────────────────────────────────────────────
|
|
236
247
|
|
|
237
248
|
def acquire_exec_token(api_key: str, base_url: str, controller: bool = False) -> str:
|
|
238
|
-
"""
|
|
239
|
-
Obtain a short-lived exec JWT.
|
|
240
|
-
|
|
241
|
-
controller=True — skip device fingerprint enrollment (used by relay
|
|
242
|
-
connect / run / shell). Just authenticates as owner.
|
|
243
|
-
controller=False — full challenge/sign/enroll flow (used by akash serve).
|
|
244
|
-
"""
|
|
245
|
-
# Patch socket DNS for this host — bypasses Android stale DNS cache
|
|
246
249
|
from urllib.parse import urlparse as _up
|
|
247
250
|
_patch_dns(_up(base_url).hostname or "")
|
|
248
251
|
|
|
@@ -251,22 +254,27 @@ def acquire_exec_token(api_key: str, base_url: str, controller: bool = False) ->
|
|
|
251
254
|
return token
|
|
252
255
|
|
|
253
256
|
with httpx.Client(base_url=base_url, timeout=10) as client:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
ch = client.post("/api/exec/challenge",
|
|
259
|
-
headers={"x-api-key": api_key, "Origin": "https://akash.cli"})
|
|
260
|
-
if ch.status_code == 404:
|
|
261
|
-
return _auth_key_only(client, api_key, base_url)
|
|
257
|
+
ch = client.post(
|
|
258
|
+
"/api/exec/challenge",
|
|
259
|
+
headers={"x-api-key": api_key, "Origin": "https://akash.cli"}
|
|
260
|
+
)
|
|
262
261
|
ch.raise_for_status()
|
|
263
262
|
nonce = ch.json()["nonce"]
|
|
264
263
|
|
|
265
264
|
sig = _sign_challenge(nonce)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
265
|
+
pub_key = _get_public_key_b64()
|
|
266
|
+
label = _device_label()
|
|
267
|
+
|
|
268
|
+
ar = client.post(
|
|
269
|
+
"/api/exec/auth",
|
|
270
|
+
headers={"x-api-key": api_key, "Origin": "https://akash.cli"},
|
|
271
|
+
json={
|
|
272
|
+
"nonce": nonce,
|
|
273
|
+
"signature": sig,
|
|
274
|
+
"public_key": pub_key,
|
|
275
|
+
"label": label,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
270
278
|
if ar.status_code == 403:
|
|
271
279
|
raise PermissionError(ar.json().get("detail", "Access denied."))
|
|
272
280
|
ar.raise_for_status()
|
|
@@ -25,6 +25,8 @@ Wire protocol (all JSON frames over the relay WebSocket):
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
+
AKASH_VERSION = "4.6.4"
|
|
29
|
+
|
|
28
30
|
import base64
|
|
29
31
|
import json
|
|
30
32
|
import os
|
|
@@ -36,6 +38,7 @@ from typing import Optional
|
|
|
36
38
|
|
|
37
39
|
# ── ANSI (matches cli.py palette) ────────────────────────────────────────────
|
|
38
40
|
_C = "\033[1;36m"
|
|
41
|
+
_W = "\033[1;37m"
|
|
39
42
|
_G = "\033[1;32m"
|
|
40
43
|
_R = "\033[1;31m"
|
|
41
44
|
_Y = "\033[1;33m"
|
|
@@ -163,14 +166,31 @@ def _run_relay_device(ws, label: str) -> None:
|
|
|
163
166
|
continue
|
|
164
167
|
|
|
165
168
|
|
|
169
|
+
def _clean_display_cmd(cmd_str: str) -> str:
|
|
170
|
+
"""Extracts human-readable command, stripping shell wrapper sentinels."""
|
|
171
|
+
lines = [
|
|
172
|
+
line.strip() for line in cmd_str.strip().splitlines()
|
|
173
|
+
if line.strip() and not line.strip().startswith("cd ") and "__EC__" not in line and "__CWD__" not in line
|
|
174
|
+
]
|
|
175
|
+
if lines:
|
|
176
|
+
clean = " ".join(lines)
|
|
177
|
+
return clean if len(clean) <= 60 else clean[:57] + "…"
|
|
178
|
+
return cmd_str[:50]
|
|
179
|
+
|
|
166
180
|
def _handle_run(ws, frame: dict) -> None:
|
|
167
|
-
"""Execute a non-interactive command, stream output in real-time
|
|
181
|
+
"""Execute a non-interactive command, stream output in real-time with live host logging."""
|
|
168
182
|
import subprocess as _sp
|
|
169
183
|
import select as _sel
|
|
170
184
|
|
|
171
185
|
command = frame.get("command", "")
|
|
172
|
-
|
|
186
|
+
raw_cwd = frame.get("cwd", "~")
|
|
187
|
+
cwd = os.path.expanduser(raw_cwd) if os.path.isdir(os.path.expanduser(raw_cwd)) else os.path.expanduser("~")
|
|
173
188
|
env = {**os.environ, "TERM": "dumb"}
|
|
189
|
+
disp_cmd = _clean_display_cmd(command)
|
|
190
|
+
|
|
191
|
+
SPIN_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
192
|
+
spin_idx = 0
|
|
193
|
+
t0 = time.monotonic()
|
|
174
194
|
|
|
175
195
|
try:
|
|
176
196
|
proc = _sp.Popen(
|
|
@@ -180,9 +200,16 @@ def _handle_run(ws, frame: dict) -> None:
|
|
|
180
200
|
env=env,
|
|
181
201
|
)
|
|
182
202
|
fd = proc.stdout.fileno()
|
|
183
|
-
|
|
203
|
+
|
|
184
204
|
while True:
|
|
185
|
-
r, _, _ = _sel.select([fd], [], [], 0.
|
|
205
|
+
r, _, _ = _sel.select([fd], [], [], 0.06)
|
|
206
|
+
|
|
207
|
+
# Live host animation
|
|
208
|
+
f = SPIN_FRAMES[spin_idx % len(SPIN_FRAMES)]
|
|
209
|
+
spin_idx += 1
|
|
210
|
+
sys.stdout.write(f"\r {_C}{f}{_RS} {_DM}executing:{_RS} {_W}{disp_cmd}{_RS} ")
|
|
211
|
+
sys.stdout.flush()
|
|
212
|
+
|
|
186
213
|
if r:
|
|
187
214
|
chunk = os.read(fd, 16384)
|
|
188
215
|
if not chunk:
|
|
@@ -192,7 +219,6 @@ def _handle_run(ws, frame: dict) -> None:
|
|
|
192
219
|
"data": base64.b64encode(chunk).decode(),
|
|
193
220
|
}))
|
|
194
221
|
elif proc.poll() is not None:
|
|
195
|
-
# Drain any remaining bytes
|
|
196
222
|
try:
|
|
197
223
|
while True:
|
|
198
224
|
r2, _, _ = _sel.select([fd], [], [], 0)
|
|
@@ -209,20 +235,25 @@ def _handle_run(ws, frame: dict) -> None:
|
|
|
209
235
|
pass
|
|
210
236
|
break
|
|
211
237
|
proc.wait()
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
|
|
238
|
+
|
|
239
|
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
|
240
|
+
ec = proc.returncode or 0
|
|
241
|
+
sys.stdout.write("\r\033[2K")
|
|
242
|
+
if ec == 0:
|
|
243
|
+
print(f" {_G}✓{_RS} {_W}{disp_cmd}{_RS} {_G}[0]{_RS} {_DM}{elapsed_ms:.1f}ms{_RS}")
|
|
244
|
+
else:
|
|
245
|
+
print(f" {_R}✗{_RS} {_W}{disp_cmd}{_RS} {_R}[{ec}]{_RS} {_DM}{elapsed_ms:.1f}ms{_RS}")
|
|
246
|
+
sys.stdout.flush()
|
|
247
|
+
|
|
220
248
|
ws.send(json.dumps({
|
|
221
249
|
"type": "exit",
|
|
222
|
-
"code":
|
|
250
|
+
"code": ec,
|
|
223
251
|
"cwd": cwd,
|
|
224
252
|
}))
|
|
225
253
|
except Exception as e:
|
|
254
|
+
sys.stdout.write("\r\033[2K")
|
|
255
|
+
print(f" {_R}✗{_RS} {_W}{disp_cmd}{_RS} {_R}[error]{_RS} {_DM}{e}{_RS}")
|
|
256
|
+
sys.stdout.flush()
|
|
226
257
|
try:
|
|
227
258
|
ws.send(json.dumps({"type": "error", "message": str(e)}))
|
|
228
259
|
ws.send(json.dumps({"type": "exit", "code": 1, "cwd": cwd}))
|
|
@@ -652,10 +683,10 @@ def _guest_code_url(base: str) -> str:
|
|
|
652
683
|
return f"{base.rstrip('/')}/api/guest/relay/code"
|
|
653
684
|
|
|
654
685
|
def _guest_host_url(base: str, code: str) -> str:
|
|
655
|
-
return f"{_ws_base(base)}/api/guest/relay/host?code={code}"
|
|
686
|
+
return f"{_ws_base(base)}/api/guest/relay/host?code={code}&version={AKASH_VERSION}"
|
|
656
687
|
|
|
657
688
|
def _guest_join_url(base: str, code: str) -> str:
|
|
658
|
-
return f"{_ws_base(base)}/api/guest/relay/join?code={code}"
|
|
689
|
+
return f"{_ws_base(base)}/api/guest/relay/join?code={code}&version={AKASH_VERSION}"
|
|
659
690
|
|
|
660
691
|
|
|
661
692
|
def guest_serve(base_url: str) -> None:
|
|
@@ -703,7 +734,7 @@ def guest_serve(base_url: str) -> None:
|
|
|
703
734
|
# Wait for either a knock (someone wants in) or a ping
|
|
704
735
|
while True:
|
|
705
736
|
try:
|
|
706
|
-
raw = ws.recv(
|
|
737
|
+
raw = ws.recv()
|
|
707
738
|
except Exception:
|
|
708
739
|
# Timeout or disconnect — break to reconnect loop
|
|
709
740
|
break
|
|
@@ -983,7 +1014,7 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
983
1014
|
print(f" {_DM}fingerprint {_C}{fp}{_RS}", flush=True)
|
|
984
1015
|
print(f" {_DM}host has 30 s to respond{_RS}\n", flush=True)
|
|
985
1016
|
try:
|
|
986
|
-
second = json.loads(ws.recv(
|
|
1017
|
+
second = json.loads(ws.recv())
|
|
987
1018
|
except Exception:
|
|
988
1019
|
ws.close()
|
|
989
1020
|
raise RuntimeError("Timed out waiting for host approval.")
|
|
@@ -1022,7 +1053,7 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1022
1053
|
def _recv_worker():
|
|
1023
1054
|
while not _stop_recv.is_set():
|
|
1024
1055
|
try:
|
|
1025
|
-
raw = ws.recv(
|
|
1056
|
+
raw = ws.recv()
|
|
1026
1057
|
_q_frames.put(raw)
|
|
1027
1058
|
except Exception as exc:
|
|
1028
1059
|
if not _stop_recv.is_set():
|
|
@@ -1168,33 +1199,22 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1168
1199
|
|
|
1169
1200
|
# ── REPL command runner ───────────────────────────────────────────────────
|
|
1170
1201
|
def _run_cmd(cmd: str) -> tuple:
|
|
1171
|
-
"""
|
|
1172
|
-
Send `run` frame, collect stdout in real-time, return (lines, ec, new_cwd).
|
|
1173
|
-
Output is printed live as │ lines while collecting for the footer.
|
|
1174
|
-
"""
|
|
1175
|
-
# Wrap: cd to cwd, run command, emit sentinels
|
|
1176
|
-
actual = (
|
|
1177
|
-
f'cd {json.dumps(cwd)} 2>/dev/null\n'
|
|
1178
|
-
f'{cmd}\n'
|
|
1179
|
-
f'printf "\\x00__EC__%d\\x00" "$?"\n'
|
|
1180
|
-
f'printf "\\x00__CWD__%s\\x00" "$PWD"\n'
|
|
1181
|
-
)
|
|
1202
|
+
"""Send command cleanly and stream uncorrupted stdout lines in real-time."""
|
|
1182
1203
|
try:
|
|
1183
1204
|
ws.send(json.dumps({
|
|
1184
1205
|
"type": "run",
|
|
1185
|
-
"command":
|
|
1206
|
+
"command": cmd,
|
|
1186
1207
|
"cwd": cwd,
|
|
1187
1208
|
}))
|
|
1188
1209
|
except Exception:
|
|
1189
1210
|
return [], 1, cwd
|
|
1190
1211
|
|
|
1191
|
-
cols
|
|
1192
|
-
buf
|
|
1193
|
-
lines
|
|
1194
|
-
ec
|
|
1195
|
-
new_cwd
|
|
1196
|
-
deadline
|
|
1197
|
-
started = False
|
|
1212
|
+
cols = _term_cols()
|
|
1213
|
+
buf = b""
|
|
1214
|
+
lines : list[str] = []
|
|
1215
|
+
ec = 0
|
|
1216
|
+
new_cwd = cwd
|
|
1217
|
+
deadline = time.time() + 60.0
|
|
1198
1218
|
|
|
1199
1219
|
while time.time() < deadline:
|
|
1200
1220
|
try:
|
|
@@ -1209,51 +1229,22 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1209
1229
|
continue
|
|
1210
1230
|
ft = frame.get("type")
|
|
1211
1231
|
|
|
1212
|
-
if ft
|
|
1232
|
+
if ft in ("stdout", "stderr"):
|
|
1213
1233
|
chunk = base64.b64decode(frame.get("data", ""))
|
|
1214
1234
|
buf += chunk
|
|
1215
|
-
# Print lines as they arrive — real-time streaming
|
|
1216
1235
|
while b"\n" in buf:
|
|
1217
1236
|
line_b, buf = buf.split(b"\n", 1)
|
|
1218
|
-
line = _strip_ansi(line_b.decode("utf-8", errors="replace"))
|
|
1219
|
-
|
|
1220
|
-
if "\x00__EC__" in line:
|
|
1221
|
-
import re as _re
|
|
1222
|
-
m = _re.search(r'\x00__EC__(\d+)\x00', line)
|
|
1223
|
-
if m:
|
|
1224
|
-
ec = int(m.group(1))
|
|
1225
|
-
continue
|
|
1226
|
-
if "\x00__CWD__" in line:
|
|
1227
|
-
import re as _re
|
|
1228
|
-
m = _re.search(r'\x00__CWD__([^\x00]+)\x00', line)
|
|
1229
|
-
if m:
|
|
1230
|
-
new_cwd = m.group(1).strip()
|
|
1231
|
-
continue
|
|
1232
|
-
if not started:
|
|
1233
|
-
# Print blank separator before first output line
|
|
1234
|
-
sys.stdout.write("\n")
|
|
1235
|
-
started = True
|
|
1236
|
-
# Print live
|
|
1237
|
-
display = line.rstrip()
|
|
1238
|
-
# wrap
|
|
1239
|
-
wrap = max(cols - 6, 40)
|
|
1240
|
-
while len(display) > wrap:
|
|
1241
|
-
sys.stdout.write(f" {_SH_GR}│{_SH_RS} {display[:wrap]}\n")
|
|
1242
|
-
display = display[wrap:]
|
|
1243
|
-
sys.stdout.write(f" {_SH_GR}│{_SH_RS} {display}\n")
|
|
1237
|
+
line = _strip_ansi(line_b.decode("utf-8", errors="replace")).rstrip()
|
|
1238
|
+
sys.stdout.write(f" {_SH_GR}│{_SH_RS} {line}\n")
|
|
1244
1239
|
sys.stdout.flush()
|
|
1245
|
-
lines.append(line
|
|
1240
|
+
lines.append(line)
|
|
1246
1241
|
|
|
1247
1242
|
elif ft == "exit":
|
|
1248
|
-
ec = frame.get("code",
|
|
1249
|
-
new_cwd = frame.get("cwd",
|
|
1250
|
-
# Flush remaining buffer
|
|
1243
|
+
ec = frame.get("code", 0)
|
|
1244
|
+
new_cwd = frame.get("cwd", cwd) or cwd
|
|
1251
1245
|
if buf:
|
|
1252
1246
|
line = _strip_ansi(buf.decode("utf-8", errors="replace")).rstrip()
|
|
1253
|
-
if line
|
|
1254
|
-
if not started:
|
|
1255
|
-
sys.stdout.write("\n")
|
|
1256
|
-
started = True
|
|
1247
|
+
if line:
|
|
1257
1248
|
sys.stdout.write(f" {_SH_GR}│{_SH_RS} {line}\n")
|
|
1258
1249
|
sys.stdout.flush()
|
|
1259
1250
|
lines.append(line)
|
|
@@ -1261,9 +1252,6 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1261
1252
|
|
|
1262
1253
|
elif ft == "error":
|
|
1263
1254
|
msg = frame.get("message", "?")
|
|
1264
|
-
if not started:
|
|
1265
|
-
sys.stdout.write("\n")
|
|
1266
|
-
started = True
|
|
1267
1255
|
sys.stdout.write(f" {_SH_GR}│{_RS} {_R}error:{_RS} {msg}\n")
|
|
1268
1256
|
sys.stdout.flush()
|
|
1269
1257
|
lines.append(f"error: {msg}")
|
|
@@ -1271,10 +1259,8 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1271
1259
|
break
|
|
1272
1260
|
|
|
1273
1261
|
elif ft == "ping":
|
|
1274
|
-
try:
|
|
1275
|
-
|
|
1276
|
-
except Exception:
|
|
1277
|
-
pass
|
|
1262
|
+
try: ws.send(json.dumps({"type": "pong"}))
|
|
1263
|
+
except Exception: pass
|
|
1278
1264
|
|
|
1279
1265
|
return lines, ec, new_cwd
|
|
1280
1266
|
|
|
@@ -1368,8 +1354,9 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1368
1354
|
sys.stdout.flush()
|
|
1369
1355
|
_sh_footer(ec, elapsed_ms)
|
|
1370
1356
|
|
|
1371
|
-
except Exception:
|
|
1372
|
-
|
|
1357
|
+
except Exception as exc:
|
|
1358
|
+
sys.stderr.write(f"\r\n [REPL EXCEPTION]: {type(exc).__name__}: {exc}\r\n")
|
|
1359
|
+
sys.stderr.flush()
|
|
1373
1360
|
finally:
|
|
1374
1361
|
_stop_recv.set()
|
|
1375
1362
|
try:
|
|
@@ -12,7 +12,7 @@ import os
|
|
|
12
12
|
import tarfile
|
|
13
13
|
import httpx
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Optional, Callable
|
|
15
|
+
from typing import Optional, Callable, List, Tuple, Union
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def _download_archive(client, file_code: str, progress_cb: Optional[Callable] = None) -> bytes:
|
|
@@ -41,6 +41,143 @@ def _download_archive(client, file_code: str, progress_cb: Optional[Callable] =
|
|
|
41
41
|
return b"".join(chunks)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
|
|
45
|
+
def _is_safe_subpath(path: Path, base: Path) -> bool:
|
|
46
|
+
"""Verify that path resolves strictly inside base directory."""
|
|
47
|
+
try:
|
|
48
|
+
resolved_path = path.resolve()
|
|
49
|
+
resolved_base = base.resolve()
|
|
50
|
+
return resolved_path.is_relative_to(resolved_base)
|
|
51
|
+
except (ValueError, AttributeError):
|
|
52
|
+
try:
|
|
53
|
+
return os.path.commonpath([str(path.resolve()), str(base.resolve())]) == str(base.resolve())
|
|
54
|
+
except ValueError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_and_extract_members(
|
|
59
|
+
tar: tarfile.TarFile,
|
|
60
|
+
target_home: Optional[Union[str, Path]] = None,
|
|
61
|
+
dry_run: bool = False,
|
|
62
|
+
allowed_abs_prefixes: Optional[List[str]] = None,
|
|
63
|
+
) -> Tuple[List[str], List[str]]:
|
|
64
|
+
"""
|
|
65
|
+
Two-pass secure extractor:
|
|
66
|
+
Pass 1: Inspect and validate 100% of archive members before writing anything.
|
|
67
|
+
Pass 2: Safely extract approved files directly into the destination.
|
|
68
|
+
"""
|
|
69
|
+
home = Path(target_home).expanduser().resolve() if target_home else Path.home().resolve()
|
|
70
|
+
|
|
71
|
+
if allowed_abs_prefixes is None:
|
|
72
|
+
try:
|
|
73
|
+
from .terminal import _DEFAULT_ABS_PATHS
|
|
74
|
+
allowed_abs_prefixes = list(_DEFAULT_ABS_PATHS)
|
|
75
|
+
except Exception:
|
|
76
|
+
allowed_abs_prefixes = []
|
|
77
|
+
|
|
78
|
+
restored: List[str] = []
|
|
79
|
+
skipped: List[str] = []
|
|
80
|
+
planned: List[Tuple[tarfile.TarInfo, Path, Path]] = []
|
|
81
|
+
|
|
82
|
+
# ── PASS 1: Strict Pre-validation ────────────────────────────────────────
|
|
83
|
+
for member in tar.getmembers():
|
|
84
|
+
name = member.name
|
|
85
|
+
|
|
86
|
+
# Permitted metadata files
|
|
87
|
+
if name in ("MANIFEST.json", "manifest_meta.json", "akash.pkgs", "akash.largefiles"):
|
|
88
|
+
continue
|
|
89
|
+
if name.startswith("largeref/"):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Reject unsafe link entries
|
|
93
|
+
if member.issym() or member.islnk():
|
|
94
|
+
raise ValueError(f"Unsafe link entry rejected: {name}")
|
|
95
|
+
|
|
96
|
+
# Reject traversal or null bytes in member name
|
|
97
|
+
parts = Path(name).parts
|
|
98
|
+
if ".." in parts:
|
|
99
|
+
raise ValueError(f"Path traversal rejected in archive member: {name}")
|
|
100
|
+
if "\x00" in name:
|
|
101
|
+
raise ValueError(f"Null byte rejected in archive member: {name}")
|
|
102
|
+
|
|
103
|
+
if not member.isfile():
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
dest: Optional[Path] = None
|
|
107
|
+
effective_root: Path = home
|
|
108
|
+
|
|
109
|
+
if name.startswith("home/"):
|
|
110
|
+
rel_str = name[len("home/"):].lstrip("/")
|
|
111
|
+
if not rel_str or ".." in Path(rel_str).parts:
|
|
112
|
+
raise ValueError(f"Traversal in home member: {name}")
|
|
113
|
+
dest = home / rel_str
|
|
114
|
+
effective_root = home
|
|
115
|
+
|
|
116
|
+
elif name.startswith("extra/"):
|
|
117
|
+
rel_str = name[len("extra/"):].lstrip("/")
|
|
118
|
+
if not rel_str or ".." in Path(rel_str).parts:
|
|
119
|
+
raise ValueError(f"Traversal in extra member: {name}")
|
|
120
|
+
dest = home / rel_str
|
|
121
|
+
effective_root = home
|
|
122
|
+
|
|
123
|
+
elif name.startswith("abs/"):
|
|
124
|
+
rel_str = name[len("abs/"):].lstrip("/")
|
|
125
|
+
if not rel_str or ".." in Path(rel_str).parts:
|
|
126
|
+
raise ValueError(f"Traversal in abs member: {name}")
|
|
127
|
+
|
|
128
|
+
if target_home:
|
|
129
|
+
# Sandboxed restore: map abs/ paths safely under target directory
|
|
130
|
+
dest = home / rel_str
|
|
131
|
+
effective_root = home
|
|
132
|
+
else:
|
|
133
|
+
# Root restore: only permitted if path is in authorized prefix list
|
|
134
|
+
abs_dest = (Path("/") / rel_str).resolve()
|
|
135
|
+
matched = None
|
|
136
|
+
for prefix in allowed_abs_prefixes:
|
|
137
|
+
if _is_safe_subpath(abs_dest, Path(prefix)):
|
|
138
|
+
matched = prefix
|
|
139
|
+
break
|
|
140
|
+
if matched:
|
|
141
|
+
dest = abs_dest
|
|
142
|
+
effective_root = Path(matched).resolve()
|
|
143
|
+
else:
|
|
144
|
+
raise ValueError(f"Unexpected absolute path rejected: {name} ({abs_dest} is not authorized)")
|
|
145
|
+
else:
|
|
146
|
+
skipped.append(name)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if not _is_safe_subpath(dest, effective_root):
|
|
150
|
+
raise ValueError(f"Extraction destination {dest} escapes root {effective_root}")
|
|
151
|
+
|
|
152
|
+
planned.append((member, dest, effective_root))
|
|
153
|
+
|
|
154
|
+
# ── PASS 2: Extraction ───────────────────────────────────────────────────
|
|
155
|
+
for member, dest, effective_root in planned:
|
|
156
|
+
if dry_run:
|
|
157
|
+
restored.append(str(dest))
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
if not _is_safe_subpath(dest.parent, effective_root):
|
|
163
|
+
raise ValueError(f"Parent directory {dest.parent} escapes authorized root")
|
|
164
|
+
|
|
165
|
+
data = tar.extractfile(member)
|
|
166
|
+
if data is None:
|
|
167
|
+
skipped.append(str(dest))
|
|
168
|
+
continue
|
|
169
|
+
dest.write_bytes(data.read())
|
|
170
|
+
try:
|
|
171
|
+
os.chmod(dest, member.mode)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
restored.append(str(dest))
|
|
175
|
+
except (PermissionError, OSError) as e:
|
|
176
|
+
skipped.append(f"{dest} ({e})")
|
|
177
|
+
|
|
178
|
+
return restored, skipped
|
|
179
|
+
|
|
180
|
+
|
|
44
181
|
def restore_snapshot(
|
|
45
182
|
client,
|
|
46
183
|
file_code: str,
|
|
@@ -112,61 +249,12 @@ def restore_snapshot(
|
|
|
112
249
|
except KeyError:
|
|
113
250
|
pass # no large-file manifest in this snapshot
|
|
114
251
|
|
|
115
|
-
# ── 3c. Restore each member
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"akash.pkgs", "akash.largefiles"):
|
|
122
|
-
continue
|
|
123
|
-
# Skip large-file placeholder prefix (only references, not data)
|
|
124
|
-
if name.startswith("largeref/"):
|
|
125
|
-
continue
|
|
126
|
-
|
|
127
|
-
if not member.isfile():
|
|
128
|
-
continue
|
|
129
|
-
|
|
130
|
-
# ── Map archive path → real filesystem path ──────────────────────
|
|
131
|
-
dest: Optional[Path] = None
|
|
132
|
-
|
|
133
|
-
if name.startswith("home/"):
|
|
134
|
-
rel = name[len("home/"):]
|
|
135
|
-
dest = home / rel
|
|
136
|
-
|
|
137
|
-
elif name.startswith("abs/"):
|
|
138
|
-
# Stored as "abs/absolute/path/to/file"
|
|
139
|
-
abs_str = name[len("abs"):] # "/absolute/path/to/file"
|
|
140
|
-
dest = Path(abs_str)
|
|
141
|
-
|
|
142
|
-
elif name.startswith("extra/"):
|
|
143
|
-
# Extra files land directly in home
|
|
144
|
-
dest = home / name[len("extra/"):]
|
|
145
|
-
|
|
146
|
-
else:
|
|
147
|
-
skipped.append(name)
|
|
148
|
-
continue
|
|
149
|
-
|
|
150
|
-
if dry_run:
|
|
151
|
-
restored.append(str(dest))
|
|
152
|
-
continue
|
|
153
|
-
|
|
154
|
-
# ── Write file ───────────────────────────────────────────────────
|
|
155
|
-
try:
|
|
156
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
-
data = tar.extractfile(member)
|
|
158
|
-
if data is None:
|
|
159
|
-
skipped.append(str(dest))
|
|
160
|
-
continue
|
|
161
|
-
dest.write_bytes(data.read())
|
|
162
|
-
# Preserve original permissions where possible
|
|
163
|
-
try:
|
|
164
|
-
os.chmod(dest, member.mode)
|
|
165
|
-
except OSError:
|
|
166
|
-
pass
|
|
167
|
-
restored.append(str(dest))
|
|
168
|
-
except (PermissionError, OSError) as e:
|
|
169
|
-
skipped.append(f"{dest} ({e})")
|
|
252
|
+
# ── 3c. Restore each member (safe validation & extraction) ───────────
|
|
253
|
+
r_list, s_list = validate_and_extract_members(
|
|
254
|
+
tar, target_home=home, dry_run=dry_run
|
|
255
|
+
)
|
|
256
|
+
restored.extend(r_list)
|
|
257
|
+
skipped.extend(s_list)
|
|
170
258
|
|
|
171
259
|
# ── 4. Reinstall packages from akash.pkgs ────────────────────────────────
|
|
172
260
|
pkg_results = None
|
|
@@ -19,7 +19,7 @@ import subprocess
|
|
|
19
19
|
import tempfile
|
|
20
20
|
from datetime import datetime, timezone
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import List, Optional, Callable
|
|
22
|
+
from typing import List, Optional, Callable, Tuple
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# ---------------------------------------------------------------------------
|
|
@@ -49,8 +49,6 @@ _DEFAULT_HOME_GLOBS = [
|
|
|
49
49
|
".pip/pip.conf", ".config/pip/pip.conf",
|
|
50
50
|
# Misc tools
|
|
51
51
|
".curlrc", ".wgetrc", ".ripgreprc", ".alacritty.yml",
|
|
52
|
-
# akashcli own config (so login survives the reset)
|
|
53
|
-
".akash",
|
|
54
52
|
]
|
|
55
53
|
|
|
56
54
|
# Absolute paths always collected (non-home)
|
|
@@ -61,6 +59,120 @@ _DEFAULT_ABS_PATHS: List[str] = []
|
|
|
61
59
|
# .akashignore loader
|
|
62
60
|
# ---------------------------------------------------------------------------
|
|
63
61
|
|
|
62
|
+
|
|
63
|
+
import re
|
|
64
|
+
|
|
65
|
+
PRIVATE_KEY_BANNERS = (
|
|
66
|
+
b"-----BEGIN OPENSSH PRIVATE KEY-----",
|
|
67
|
+
b"-----BEGIN RSA PRIVATE KEY-----",
|
|
68
|
+
b"-----BEGIN DSA PRIVATE KEY-----",
|
|
69
|
+
b"-----BEGIN EC PRIVATE KEY-----",
|
|
70
|
+
b"-----BEGIN PRIVATE KEY-----",
|
|
71
|
+
b"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
|
72
|
+
b"PuTTY-User-Key-File-",
|
|
73
|
+
b"-----BEGIN PGP PRIVATE KEY BLOCK-----",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
EXCLUDED_CREDENTIAL_FILENAMES = {
|
|
77
|
+
".git-credentials", ".git-credentials.helper",
|
|
78
|
+
".netrc", "_netrc", ".pypirc",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
EXCLUDED_CREDENTIAL_DIR_FILES = {
|
|
82
|
+
".aws": {"credentials", "config"},
|
|
83
|
+
".kube": {"config", "config.json"},
|
|
84
|
+
".docker": {"config.json"},
|
|
85
|
+
".gh": {"hosts.yml", "config.yml"},
|
|
86
|
+
".akash": {
|
|
87
|
+
"machine.key", "config.enc", "exec_token.json",
|
|
88
|
+
"exec_session.json", "identity.json", "serve.pid"
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def is_credential_file(fpath: Path, home: Path, include_ssh_private: bool = False) -> Tuple[bool, str]:
|
|
93
|
+
"""Inspect path and content to identify credentials. Never trust extension alone."""
|
|
94
|
+
name = fpath.name.lower()
|
|
95
|
+
parts = fpath.parts
|
|
96
|
+
|
|
97
|
+
# 1. Akash authentication and machine secrets
|
|
98
|
+
if ".akash" in parts:
|
|
99
|
+
if name in EXCLUDED_CREDENTIAL_DIR_FILES[".akash"] or "envs" in parts:
|
|
100
|
+
return True, "Akash authentication secret or session file"
|
|
101
|
+
|
|
102
|
+
# 2. Known credential files
|
|
103
|
+
if name in EXCLUDED_CREDENTIAL_FILENAMES:
|
|
104
|
+
return True, "Authentication credentials file"
|
|
105
|
+
|
|
106
|
+
for dname, fnames in EXCLUDED_CREDENTIAL_DIR_FILES.items():
|
|
107
|
+
if dname in parts and name in fnames:
|
|
108
|
+
return True, f"{dname} configuration/credentials file"
|
|
109
|
+
|
|
110
|
+
if ".gnupg" in parts and (name in ("secring.gpg", "trustdb.gpg") or "private-keys-v1.d" in parts):
|
|
111
|
+
return True, "GPG private key material"
|
|
112
|
+
|
|
113
|
+
# 3. Environment secrets (.env, .env.local, .env.production, etc., except examples)
|
|
114
|
+
if name == ".env" or (name.startswith(".env.") and not name.endswith((".example", ".sample", ".template", ".dist"))):
|
|
115
|
+
return True, "Environment secrets file"
|
|
116
|
+
|
|
117
|
+
# 4. SSH directory checks
|
|
118
|
+
if ".ssh" in parts:
|
|
119
|
+
if name not in ("config", "known_hosts", "authorized_keys") and not name.endswith(".pub"):
|
|
120
|
+
if not include_ssh_private:
|
|
121
|
+
return True, "SSH private key"
|
|
122
|
+
|
|
123
|
+
# 5. Content-level inspection (first 4KB): detect private key signatures regardless of extension
|
|
124
|
+
try:
|
|
125
|
+
if fpath.is_file() and fpath.stat().st_size > 0:
|
|
126
|
+
with open(fpath, "rb") as fh:
|
|
127
|
+
head = fh.read(4096)
|
|
128
|
+
for banner in PRIVATE_KEY_BANNERS:
|
|
129
|
+
if banner in head:
|
|
130
|
+
if include_ssh_private and ".ssh" in parts:
|
|
131
|
+
return False, ""
|
|
132
|
+
return True, "File content contains private key signature"
|
|
133
|
+
except (OSError, PermissionError):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
return False, ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def redact_url_credentials(text: str) -> str:
|
|
140
|
+
"""Redact passwords embedded in URLs (e.g. postgres://user:pass@host)."""
|
|
141
|
+
return re.sub(
|
|
142
|
+
r'(?i)([a-z][a-z0-9+.-]*://)([^:@\s]*):([^@\s]+)@',
|
|
143
|
+
r'\1\2:[REDACTED]@',
|
|
144
|
+
text
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def redact_connection_strings(text: str) -> str:
|
|
148
|
+
"""Redact password attributes in database connection strings."""
|
|
149
|
+
return re.sub(
|
|
150
|
+
r'(?i)\b(password|passwd|pwd|secret|token|api_?key)\s*=\s*([^;\s\n]+)',
|
|
151
|
+
r'\1=[REDACTED]',
|
|
152
|
+
text
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def redact_serialized_secrets(text: str) -> str:
|
|
156
|
+
pattern = r"""(?i)(["']?(?:password|passwd|pwd|secret|token|api[_-]?key|auth[_-]?token|private[_-]?key|credentials?)["']?\s*[:=]\s*["']?)([^"'\s,;{}]+)(["']?)"""
|
|
157
|
+
return re.sub(pattern, r"\1[REDACTED]\3", text)
|
|
158
|
+
|
|
159
|
+
def redact_token_signatures(text: str) -> str:
|
|
160
|
+
"""Redact high-entropy token formats (JWT, AWS keys, tokens)."""
|
|
161
|
+
text = re.sub(r'\beyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\b', '[REDACTED_JWT]', text)
|
|
162
|
+
text = re.sub(r'\bAKIA[0-9A-Z]{16}\b', '[REDACTED_AWS_KEY]', text)
|
|
163
|
+
text = re.sub(r'\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b', '[REDACTED_TOKEN]', text)
|
|
164
|
+
text = re.sub(r'\bakash_[A-Za-z0-9_-]{16,}\b', '[REDACTED_API_KEY]', text)
|
|
165
|
+
return text
|
|
166
|
+
|
|
167
|
+
def sanitize_secret_text(text: str) -> str:
|
|
168
|
+
if not text:
|
|
169
|
+
return text
|
|
170
|
+
text = redact_url_credentials(text)
|
|
171
|
+
text = redact_connection_strings(text)
|
|
172
|
+
text = redact_serialized_secrets(text)
|
|
173
|
+
text = redact_token_signatures(text)
|
|
174
|
+
return text
|
|
175
|
+
|
|
64
176
|
def _load_ignore_patterns(home: Path) -> List[str]:
|
|
65
177
|
"""
|
|
66
178
|
Load exclusion patterns from ~/.akashignore (or .akashignore in CWD).
|
|
@@ -119,7 +231,23 @@ def _run(cmd: str) -> str:
|
|
|
119
231
|
|
|
120
232
|
|
|
121
233
|
def _collect_metadata() -> dict:
|
|
122
|
-
"""Gather environment metadata
|
|
234
|
+
"""Gather environment metadata with strict redaction of sensitive credentials."""
|
|
235
|
+
raw_cron = _run("crontab -l 2>/dev/null")
|
|
236
|
+
sanitized_cron = sanitize_secret_text(raw_cron)
|
|
237
|
+
|
|
238
|
+
sensitive_key_needles = (
|
|
239
|
+
"SECRET", "TOKEN", "PASSWORD", "PASSWD", "PWD", "KEY", "PASS",
|
|
240
|
+
"HASH", "SALT", "PRIVATE", "CREDENTIAL", "AUTH", "BEARER",
|
|
241
|
+
"APIKEY", "SIGNATURE", "SESSION"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
clean_env = {}
|
|
245
|
+
for k, v in os.environ.items():
|
|
246
|
+
k_upper = k.upper()
|
|
247
|
+
if any(s in k_upper for s in sensitive_key_needles):
|
|
248
|
+
continue
|
|
249
|
+
clean_env[k] = sanitize_secret_text(v)
|
|
250
|
+
|
|
123
251
|
return {
|
|
124
252
|
"hostname": platform.node(),
|
|
125
253
|
"system": platform.system(),
|
|
@@ -128,15 +256,9 @@ def _collect_metadata() -> dict:
|
|
|
128
256
|
"shell": os.getenv("SHELL", "unknown"),
|
|
129
257
|
"user": os.getenv("USER") or os.getenv("LOGNAME") or "unknown",
|
|
130
258
|
"snapshot_at": datetime.now(timezone.utc).isoformat(),
|
|
131
|
-
"crontab":
|
|
259
|
+
"crontab": sanitized_cron,
|
|
132
260
|
"path_env": os.getenv("PATH", ""),
|
|
133
|
-
"env_vars":
|
|
134
|
-
k: v for k, v in os.environ.items()
|
|
135
|
-
if not any(s in k.upper() for s in (
|
|
136
|
-
"SECRET", "TOKEN", "PASSWORD", "KEY", "PASS",
|
|
137
|
-
"HASH", "SALT", "PRIVATE", "CREDENTIAL"
|
|
138
|
-
))
|
|
139
|
-
},
|
|
261
|
+
"env_vars": clean_env,
|
|
140
262
|
}
|
|
141
263
|
|
|
142
264
|
|
|
@@ -260,13 +382,10 @@ def build_snapshot(
|
|
|
260
382
|
if _is_ignored_fn(fpath):
|
|
261
383
|
skipped.append(f"{fpath} (ignored)")
|
|
262
384
|
continue
|
|
263
|
-
|
|
264
|
-
if
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
):
|
|
268
|
-
skipped.append(str(fpath))
|
|
269
|
-
continue
|
|
385
|
+
is_cred, reason = is_credential_file(fpath, home, include_ssh_private=include_ssh_private)
|
|
386
|
+
if is_cred:
|
|
387
|
+
skipped.append(f"{fpath} ({reason})")
|
|
388
|
+
continue
|
|
270
389
|
# Large home-dir files (unusual but possible) → treat as ref
|
|
271
390
|
tier = classify_file(fpath)
|
|
272
391
|
if tier != FileTier.INLINE:
|
|
@@ -314,6 +433,10 @@ def build_snapshot(
|
|
|
314
433
|
|
|
315
434
|
# ── 4. Extra paths — inline tier only (Tier B/C handled after tar) ─
|
|
316
435
|
for fpath in extra_inline:
|
|
436
|
+
is_cred, reason = is_credential_file(fpath, home, include_ssh_private=include_ssh_private)
|
|
437
|
+
if is_cred:
|
|
438
|
+
skipped.append(f"{fpath} ({reason})")
|
|
439
|
+
continue
|
|
317
440
|
arc_path = f"extra/{fpath.name}"
|
|
318
441
|
try:
|
|
319
442
|
tar.add(str(fpath), arcname=arc_path)
|
|
@@ -392,8 +515,13 @@ def build_snapshot(
|
|
|
392
515
|
manifest["security"] = detect_tier(final_bytes)
|
|
393
516
|
else:
|
|
394
517
|
final_bytes = raw_bytes
|
|
395
|
-
manifest["security"] = {
|
|
396
|
-
|
|
397
|
-
|
|
518
|
+
manifest["security"] = {
|
|
519
|
+
"tier": 0,
|
|
520
|
+
"tier_label": "Unencrypted (Public)",
|
|
521
|
+
"encrypted": False,
|
|
522
|
+
"requires_password": False,
|
|
523
|
+
"device_locked": False,
|
|
524
|
+
"warning": "Snapshot is unencrypted and public. Protect sensitive backups using --password or --device-lock."
|
|
525
|
+
}
|
|
398
526
|
|
|
399
527
|
return final_bytes, manifest, archive_name
|
|
@@ -34,6 +34,42 @@ class VaultUploader:
|
|
|
34
34
|
if password:
|
|
35
35
|
headers["x-vault-password"] = password
|
|
36
36
|
|
|
37
|
+
# 0. Distributed Upload Worker Integration: Attempt direct worker ticket ingestion
|
|
38
|
+
if content is None and file_path and os.path.exists(file_path):
|
|
39
|
+
try:
|
|
40
|
+
ticket_url = f"{self.client.base_url}/api/upload/ticket"
|
|
41
|
+
t_resp = httpx.post(
|
|
42
|
+
ticket_url,
|
|
43
|
+
headers=self.client._get_headers(headers),
|
|
44
|
+
timeout=10.0
|
|
45
|
+
)
|
|
46
|
+
if t_resp.status_code == 200:
|
|
47
|
+
t_data = t_resp.json()
|
|
48
|
+
ingest_url = t_data.get("ingest_url")
|
|
49
|
+
ticket_jwt = t_data.get("ticket")
|
|
50
|
+
file_code = t_data.get("file_code")
|
|
51
|
+
if ingest_url and ticket_jwt:
|
|
52
|
+
file_size = os.path.getsize(file_path)
|
|
53
|
+
with open(file_path, "rb") as fh:
|
|
54
|
+
w_files = {"file": (os.path.basename(file_path), fh)}
|
|
55
|
+
w_headers = {"x-upload-ticket": ticket_jwt}
|
|
56
|
+
w_resp = httpx.post(ingest_url, files=w_files, headers=w_headers, timeout=None)
|
|
57
|
+
if w_resp.status_code == 200:
|
|
58
|
+
d = w_resp.json()
|
|
59
|
+
res = UploadResult(
|
|
60
|
+
status=d.get("status", "processing"),
|
|
61
|
+
file_code=file_code,
|
|
62
|
+
embed_url=f"{self.client.base_url}/embed/{file_code}",
|
|
63
|
+
direct_url=f"{self.client.base_url}/download/{file_code}",
|
|
64
|
+
input_type="file",
|
|
65
|
+
dna=d.get("dna", {})
|
|
66
|
+
)
|
|
67
|
+
if res.status == "processing":
|
|
68
|
+
res = self._poll_until_done(res)
|
|
69
|
+
return res
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # Fall back to standard monolithic upload
|
|
72
|
+
|
|
37
73
|
url = f"{self.client.base_url}/api/upload"
|
|
38
74
|
|
|
39
75
|
try:
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: akashcli
|
|
3
|
-
Version: 4.6.
|
|
3
|
+
Version: 4.6.4
|
|
4
4
|
Summary: Akash CLI — secure remote shell relay, file vault, and device management
|
|
5
|
-
License: MIT
|
|
5
|
+
License-Expression: MIT
|
|
6
6
|
Keywords: akash,cli,relay,remote-shell,vault
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
9
8
|
Classifier: Operating System :: OS Independent
|
|
10
9
|
Classifier: Environment :: Console
|
|
11
10
|
Classifier: Topic :: Utilities
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "akashcli"
|
|
3
|
-
version = "4.6.
|
|
3
|
+
version = "4.6.4"
|
|
4
4
|
description = "Akash CLI — secure remote shell relay, file vault, and device management"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
7
|
-
license =
|
|
7
|
+
license = "MIT"
|
|
8
8
|
keywords = ["akash", "cli", "relay", "remote-shell", "vault"]
|
|
9
9
|
classifiers = [
|
|
10
10
|
"Programming Language :: Python :: 3",
|
|
11
|
-
"License :: OSI Approved :: MIT License",
|
|
12
11
|
"Operating System :: OS Independent",
|
|
13
12
|
"Environment :: Console",
|
|
14
13
|
"Topic :: Utilities",
|
|
@@ -28,3 +27,8 @@ akash = "akashcli.cli:app"
|
|
|
28
27
|
[build-system]
|
|
29
28
|
requires = ["setuptools", "wheel"]
|
|
30
29
|
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
include = ["akashcli*"]
|
|
34
|
+
exclude = ["patches*", "dist*", "tests*"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|