akashcli 4.6.3__tar.gz → 4.6.5__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.5}/PKG-INFO +2 -3
- akashcli-4.6.5/akashcli/__init__.py +7 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/_net.py +2 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/cli.py +48 -36
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/exec.py +31 -23
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/relay.py +95 -110
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/restore.py +144 -56
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/terminal.py +150 -22
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/uploader.py +36 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli.egg-info/PKG-INFO +2 -3
- {akashcli-4.6.3 → akashcli-4.6.5}/pyproject.toml +7 -3
- akashcli-4.6.3/akashcli/__init__.py +0 -6
- {akashcli-4.6.3 → akashcli-4.6.5}/README.md +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/auth.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/client.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/config.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/crypto.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/env_manager.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/exceptions.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/integrity.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/logger.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/models.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/p2p.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/packages.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/snapshot_crypto.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/storage.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli/utils.py +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli.egg-info/SOURCES.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli.egg-info/dependency_links.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli.egg-info/entry_points.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli.egg-info/requires.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/akashcli.egg-info/top_level.txt +0 -0
- {akashcli-4.6.3 → akashcli-4.6.5}/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.5
|
|
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.5"
|
|
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}))
|
|
@@ -259,31 +290,24 @@ def _handle_pty(ws, frame: dict) -> None:
|
|
|
259
290
|
import subprocess as _sp
|
|
260
291
|
import tempfile as _tmp
|
|
261
292
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
# On Android non-root, TIOCSCTTY is blocked by SELinux so bash prints:
|
|
265
|
-
# "cannot set terminal process group … no job control"
|
|
266
|
-
# Fix: launch bash with --rcfile pointing to a temp file that sources
|
|
267
|
-
# ~/.bashrc then runs `set +m` (disable job-control monitoring).
|
|
268
|
-
# This silences the warning while keeping the shell fully interactive.
|
|
269
|
-
_env = {**os.environ,
|
|
270
|
-
"TERM": "xterm-256color",
|
|
271
|
-
"COLUMNS": str(cols), "LINES": str(rows)}
|
|
293
|
+
shell_bin = os.environ.get("SHELL", "/bin/bash")
|
|
294
|
+
_env = {**os.environ, "TERM": "xterm-256color", "COLUMNS": str(cols), "LINES": str(rows)}
|
|
272
295
|
_bash_env_file = None
|
|
273
|
-
|
|
274
|
-
if
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
296
|
+
|
|
297
|
+
if command:
|
|
298
|
+
# Evaluate commands with flags/arguments through the shell
|
|
299
|
+
_shell_args = [shell_bin, "-c", command]
|
|
300
|
+
else:
|
|
301
|
+
_shell_args = [shell_bin]
|
|
302
|
+
if "bash" in shell_bin.lower():
|
|
303
|
+
try:
|
|
304
|
+
_fd, _bash_env_file = _tmp.mkstemp(prefix=".akash_rc_", suffix=".sh")
|
|
305
|
+
_rc = b'[ -f ~/.bashrc ] && . ~/.bashrc\nset +m\n'
|
|
306
|
+
os.write(_fd, _rc)
|
|
307
|
+
os.close(_fd)
|
|
308
|
+
_shell_args = [shell_bin, "--rcfile", _bash_env_file]
|
|
309
|
+
except Exception:
|
|
310
|
+
_bash_env_file = None
|
|
287
311
|
|
|
288
312
|
try:
|
|
289
313
|
proc = _sp.Popen(
|
|
@@ -652,10 +676,10 @@ def _guest_code_url(base: str) -> str:
|
|
|
652
676
|
return f"{base.rstrip('/')}/api/guest/relay/code"
|
|
653
677
|
|
|
654
678
|
def _guest_host_url(base: str, code: str) -> str:
|
|
655
|
-
return f"{_ws_base(base)}/api/guest/relay/host?code={code}"
|
|
679
|
+
return f"{_ws_base(base)}/api/guest/relay/host?code={code}&version={AKASH_VERSION}"
|
|
656
680
|
|
|
657
681
|
def _guest_join_url(base: str, code: str) -> str:
|
|
658
|
-
return f"{_ws_base(base)}/api/guest/relay/join?code={code}"
|
|
682
|
+
return f"{_ws_base(base)}/api/guest/relay/join?code={code}&version={AKASH_VERSION}"
|
|
659
683
|
|
|
660
684
|
|
|
661
685
|
def guest_serve(base_url: str) -> None:
|
|
@@ -703,7 +727,7 @@ def guest_serve(base_url: str) -> None:
|
|
|
703
727
|
# Wait for either a knock (someone wants in) or a ping
|
|
704
728
|
while True:
|
|
705
729
|
try:
|
|
706
|
-
raw = ws.recv(
|
|
730
|
+
raw = ws.recv()
|
|
707
731
|
except Exception:
|
|
708
732
|
# Timeout or disconnect — break to reconnect loop
|
|
709
733
|
break
|
|
@@ -888,9 +912,14 @@ _PTY_APPS = frozenset({
|
|
|
888
912
|
|
|
889
913
|
def _is_pty_cmd(cmd: str) -> bool:
|
|
890
914
|
"""Return True if cmd should run in PTY mode (TUI / interactive app)."""
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
915
|
+
parts = cmd.strip().lstrip("\\").split()
|
|
916
|
+
if not parts:
|
|
917
|
+
return False
|
|
918
|
+
first = parts[0].split("/")[-1]
|
|
919
|
+
# Commands with -c, -e, or file arguments are batch scripts, NOT interactive TUI sessions
|
|
920
|
+
if first in ("python", "python3", "ipython", "node", "ruby", "perl"):
|
|
921
|
+
if len(parts) > 1 and (parts[1] in ("-c", "-e") or not parts[1].startswith("-")):
|
|
922
|
+
return False
|
|
894
923
|
return first in _PTY_APPS
|
|
895
924
|
|
|
896
925
|
|
|
@@ -983,7 +1012,7 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
983
1012
|
print(f" {_DM}fingerprint {_C}{fp}{_RS}", flush=True)
|
|
984
1013
|
print(f" {_DM}host has 30 s to respond{_RS}\n", flush=True)
|
|
985
1014
|
try:
|
|
986
|
-
second = json.loads(ws.recv(
|
|
1015
|
+
second = json.loads(ws.recv())
|
|
987
1016
|
except Exception:
|
|
988
1017
|
ws.close()
|
|
989
1018
|
raise RuntimeError("Timed out waiting for host approval.")
|
|
@@ -1022,7 +1051,7 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1022
1051
|
def _recv_worker():
|
|
1023
1052
|
while not _stop_recv.is_set():
|
|
1024
1053
|
try:
|
|
1025
|
-
raw = ws.recv(
|
|
1054
|
+
raw = ws.recv()
|
|
1026
1055
|
_q_frames.put(raw)
|
|
1027
1056
|
except Exception as exc:
|
|
1028
1057
|
if not _stop_recv.is_set():
|
|
@@ -1168,33 +1197,22 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1168
1197
|
|
|
1169
1198
|
# ── REPL command runner ───────────────────────────────────────────────────
|
|
1170
1199
|
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
|
-
)
|
|
1200
|
+
"""Send command cleanly and stream uncorrupted stdout lines in real-time."""
|
|
1182
1201
|
try:
|
|
1183
1202
|
ws.send(json.dumps({
|
|
1184
1203
|
"type": "run",
|
|
1185
|
-
"command":
|
|
1204
|
+
"command": cmd,
|
|
1186
1205
|
"cwd": cwd,
|
|
1187
1206
|
}))
|
|
1188
1207
|
except Exception:
|
|
1189
1208
|
return [], 1, cwd
|
|
1190
1209
|
|
|
1191
|
-
cols
|
|
1192
|
-
buf
|
|
1193
|
-
lines
|
|
1194
|
-
ec
|
|
1195
|
-
new_cwd
|
|
1196
|
-
deadline
|
|
1197
|
-
started = False
|
|
1210
|
+
cols = _term_cols()
|
|
1211
|
+
buf = b""
|
|
1212
|
+
lines : list[str] = []
|
|
1213
|
+
ec = 0
|
|
1214
|
+
new_cwd = cwd
|
|
1215
|
+
deadline = time.time() + 60.0
|
|
1198
1216
|
|
|
1199
1217
|
while time.time() < deadline:
|
|
1200
1218
|
try:
|
|
@@ -1209,51 +1227,22 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1209
1227
|
continue
|
|
1210
1228
|
ft = frame.get("type")
|
|
1211
1229
|
|
|
1212
|
-
if ft
|
|
1230
|
+
if ft in ("stdout", "stderr"):
|
|
1213
1231
|
chunk = base64.b64decode(frame.get("data", ""))
|
|
1214
1232
|
buf += chunk
|
|
1215
|
-
# Print lines as they arrive — real-time streaming
|
|
1216
1233
|
while b"\n" in buf:
|
|
1217
1234
|
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")
|
|
1235
|
+
line = _strip_ansi(line_b.decode("utf-8", errors="replace")).rstrip()
|
|
1236
|
+
sys.stdout.write(f" {_SH_GR}│{_SH_RS} {line}\n")
|
|
1244
1237
|
sys.stdout.flush()
|
|
1245
|
-
lines.append(line
|
|
1238
|
+
lines.append(line)
|
|
1246
1239
|
|
|
1247
1240
|
elif ft == "exit":
|
|
1248
|
-
ec = frame.get("code",
|
|
1249
|
-
new_cwd = frame.get("cwd",
|
|
1250
|
-
# Flush remaining buffer
|
|
1241
|
+
ec = frame.get("code", 0)
|
|
1242
|
+
new_cwd = frame.get("cwd", cwd) or cwd
|
|
1251
1243
|
if buf:
|
|
1252
1244
|
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
|
|
1245
|
+
if line:
|
|
1257
1246
|
sys.stdout.write(f" {_SH_GR}│{_SH_RS} {line}\n")
|
|
1258
1247
|
sys.stdout.flush()
|
|
1259
1248
|
lines.append(line)
|
|
@@ -1261,9 +1250,6 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1261
1250
|
|
|
1262
1251
|
elif ft == "error":
|
|
1263
1252
|
msg = frame.get("message", "?")
|
|
1264
|
-
if not started:
|
|
1265
|
-
sys.stdout.write("\n")
|
|
1266
|
-
started = True
|
|
1267
1253
|
sys.stdout.write(f" {_SH_GR}│{_RS} {_R}error:{_RS} {msg}\n")
|
|
1268
1254
|
sys.stdout.flush()
|
|
1269
1255
|
lines.append(f"error: {msg}")
|
|
@@ -1271,10 +1257,8 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1271
1257
|
break
|
|
1272
1258
|
|
|
1273
1259
|
elif ft == "ping":
|
|
1274
|
-
try:
|
|
1275
|
-
|
|
1276
|
-
except Exception:
|
|
1277
|
-
pass
|
|
1260
|
+
try: ws.send(json.dumps({"type": "pong"}))
|
|
1261
|
+
except Exception: pass
|
|
1278
1262
|
|
|
1279
1263
|
return lines, ec, new_cwd
|
|
1280
1264
|
|
|
@@ -1368,8 +1352,9 @@ def guest_connect_shell(base_url: str, code: str) -> int:
|
|
|
1368
1352
|
sys.stdout.flush()
|
|
1369
1353
|
_sh_footer(ec, elapsed_ms)
|
|
1370
1354
|
|
|
1371
|
-
except Exception:
|
|
1372
|
-
|
|
1355
|
+
except Exception as exc:
|
|
1356
|
+
sys.stderr.write(f"\r\n [REPL EXCEPTION]: {type(exc).__name__}: {exc}\r\n")
|
|
1357
|
+
sys.stderr.flush()
|
|
1373
1358
|
finally:
|
|
1374
1359
|
_stop_recv.set()
|
|
1375
1360
|
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.5
|
|
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.5"
|
|
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
|