vssh 3.3.1__tar.gz → 3.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vssh
3
- Version: 3.3.1
3
+ Version: 3.3.4
4
4
  Summary: Secure SSH/SCP tool with Tailscale failover, P2P transport, and MCP server
5
5
  Author-email: MeshPOP <mpop@mpop.dev>
6
6
  License: MIT
@@ -41,10 +41,10 @@ pip install vssh
41
41
 
42
42
  ```bash
43
43
  # Execute remote command
44
- vssh exec v1 "uptime"
44
+ vssh exec relay1 "uptime"
45
45
 
46
46
  # Transfer file at 50+ MB/s
47
- vssh put v1 ./deploy.tar.gz /opt/deploy.tar.gz
47
+ vssh put relay1 ./deploy.tar.gz /opt/deploy.tar.gz
48
48
 
49
49
  # Check connection status
50
50
  vssh status
@@ -18,10 +18,10 @@ pip install vssh
18
18
 
19
19
  ```bash
20
20
  # Execute remote command
21
- vssh exec v1 "uptime"
21
+ vssh exec relay1 "uptime"
22
22
 
23
23
  # Transfer file at 50+ MB/s
24
- vssh put v1 ./deploy.tar.gz /opt/deploy.tar.gz
24
+ vssh put relay1 ./deploy.tar.gz /opt/deploy.tar.gz
25
25
 
26
26
  # Check connection status
27
27
  vssh status
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vssh"
7
- version = "3.3.1"
7
+ version = "3.3.4"
8
8
  description = "Secure SSH/SCP tool with Tailscale failover, P2P transport, and MCP server"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -34,5 +34,8 @@ vssh-mcp = "vssh_mcp_server:main"
34
34
  Homepage = "https://github.com/meshpop/vssh"
35
35
  Repository = "https://github.com/meshpop/vssh"
36
36
 
37
+ [project.entry-points."meshpop.mcp"]
38
+ vssh = "vssh_mcp_server"
39
+
37
40
  [tool.setuptools]
38
- py-modules = ["vssh", "vssh_p2p", "vssh_p2p_fast", "vssh_quic", "vssh_mcp_server"]
41
+ py-modules = ["vssh", "vssh_p2p", "vssh_mcp_server"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vssh
3
- Version: 3.3.1
3
+ Version: 3.3.4
4
4
  Summary: Secure SSH/SCP tool with Tailscale failover, P2P transport, and MCP server
5
5
  Author-email: MeshPOP <mpop@mpop.dev>
6
6
  License: MIT
@@ -41,10 +41,10 @@ pip install vssh
41
41
 
42
42
  ```bash
43
43
  # Execute remote command
44
- vssh exec v1 "uptime"
44
+ vssh exec relay1 "uptime"
45
45
 
46
46
  # Transfer file at 50+ MB/s
47
- vssh put v1 ./deploy.tar.gz /opt/deploy.tar.gz
47
+ vssh put relay1 ./deploy.tar.gz /opt/deploy.tar.gz
48
48
 
49
49
  # Check connection status
50
50
  vssh status
@@ -4,8 +4,6 @@ pyproject.toml
4
4
  vssh.py
5
5
  vssh_mcp_server.py
6
6
  vssh_p2p.py
7
- vssh_p2p_fast.py
8
- vssh_quic.py
9
7
  vssh.egg-info/PKG-INFO
10
8
  vssh.egg-info/SOURCES.txt
11
9
  vssh.egg-info/dependency_links.txt
@@ -1,3 +1,6 @@
1
1
  [console_scripts]
2
2
  vssh = vssh:main
3
3
  vssh-mcp = vssh_mcp_server:main
4
+
5
+ [meshpop.mcp]
6
+ vssh = vssh_mcp_server
@@ -1,5 +1,3 @@
1
1
  vssh
2
2
  vssh_mcp_server
3
3
  vssh_p2p
4
- vssh_p2p_fast
5
- vssh_quic
@@ -68,7 +68,7 @@ def _load_tailscale_map() -> dict:
68
68
  if len(parts) >= 2:
69
69
  ts_map[parts[0]] = parts[1] # wire_ip tailscale_ip
70
70
  except (OSError, ValueError) as e:
71
- pass # TODO: log error
71
+ pass # e silenced
72
72
 
73
73
  return ts_map
74
74
 
@@ -103,7 +103,7 @@ def vssh_connect(host: str, timeout: float = 5.0) -> socket.socket:
103
103
  sock.connect((host, PORT))
104
104
  return sock
105
105
  except (ConnectionRefusedError, ConnectionError, OSError, socket.timeout) as wire_err:
106
- pass # TODO: log error
106
+ pass # wire_err silenced
107
107
 
108
108
  # Fallback: try Tailscale IP
109
109
  ts_ip = TAILSCALE_MAP.get(host)
@@ -380,7 +380,7 @@ def get_public_ip() -> str:
380
380
  except (urllib.error.URLError, OSError):
381
381
  continue
382
382
  except Exception as e:
383
- pass # TODO: log error
383
+ pass # e silenced
384
384
  return ''
385
385
 
386
386
  def get_network_info_local() -> dict:
@@ -435,7 +435,7 @@ def is_safe_network(host: str) -> Tuple[bool, str]:
435
435
  if 16 <= second <= 31:
436
436
  return True, "lan"
437
437
  except (ValueError, IndexError) as e:
438
- pass # TODO: log error
438
+ pass # e silenced
439
439
 
440
440
  # Everything else is public IP - UNSAFE!
441
441
  return False, "public_ip_unencrypted"
@@ -499,7 +499,7 @@ def resolve_name(host: str) -> str:
499
499
  if host in _name_cache:
500
500
  return _name_cache[host]
501
501
  break # got peers, just no match
502
- except (OSError, subprocess.SubprocessError):
502
+ except (OSError, ValueError, TimeoutError):
503
503
  continue
504
504
  return host
505
505
 
@@ -748,7 +748,7 @@ def _rpc_get_logs(payload: dict) -> dict:
748
748
  )
749
749
  return {'service': service, 'lines': r.stdout.strip().split('\n')}
750
750
  except (subprocess.SubprocessError, OSError) as e:
751
- pass # TODO: log error
751
+ pass # e silenced
752
752
 
753
753
  path = log_paths.get(service)
754
754
  if path and os.path.exists(path):
@@ -2421,11 +2421,11 @@ def pty_session(host: str, name: str = ''):
2421
2421
  sys.stdout.buffer.write(data)
2422
2422
  sys.stdout.buffer.flush()
2423
2423
  except (BlockingIOError) as e:
2424
- pass # TODO: log error
2424
+ pass # e silenced
2425
2425
  except (EOFError, ConnectionError):
2426
2426
  raise EOFError
2427
2427
  except (EOFError, KeyboardInterrupt, ConnectionError, BrokenPipeError) as e:
2428
- pass # TODO: log error
2428
+ pass # e silenced
2429
2429
  finally:
2430
2430
  # Restore terminal
2431
2431
  if old_settings:
@@ -2588,7 +2588,7 @@ class VsshSession:
2588
2588
  self.sock.sendall(f"CLOSE:{self.session_id}\n".encode())
2589
2589
  self.sock.recv(32) # CLOSE_OK
2590
2590
  except OSError as e:
2591
- pass # TODO: log error
2591
+ pass # e silenced
2592
2592
  finally:
2593
2593
  self.sock.close()
2594
2594
  self.sock = None
@@ -3213,7 +3213,7 @@ def server():
3213
3213
  try:
3214
3214
  payload = json.loads(payload_data.decode())
3215
3215
  except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
3216
- pass # TODO: log error
3216
+ pass # e silenced
3217
3217
 
3218
3218
  result = handle_rpc(method, payload, sender_ip=addr[0])
3219
3219
  conn.sendall(f'RPC_RESULT:{len(result)}\n'.encode())
@@ -3281,6 +3281,14 @@ def server():
3281
3281
 
3282
3282
  def main():
3283
3283
  args = sys.argv[1:]
3284
+ # Handle --version and --help flags
3285
+ if args and args[0] in ("--version", "-v", "-V"):
3286
+ try:
3287
+ from importlib.metadata import version as _v
3288
+ print(f"vssh v{_v('vssh')}")
3289
+ except Exception:
3290
+ print("vssh v3.3.2")
3291
+ return
3284
3292
  if not args:
3285
3293
  print('''vssh v3 - Distributed command & file transport daemon for server meshes
3286
3294
 
@@ -3469,7 +3477,7 @@ Env: VSSH_SECRET
3469
3477
  try:
3470
3478
  days = int(args[1])
3471
3479
  except (ValueError, TypeError) as e:
3472
- pass # TODO: log error
3480
+ pass # e silenced
3473
3481
  stats = get_transfer_stats(days)
3474
3482
  print(f'Transfer Statistics (last {days} days)')
3475
3483
  print(f' Total transfers: {stats["total"]}')
@@ -3634,7 +3642,7 @@ Env: VSSH_SECRET
3634
3642
  try:
3635
3643
  result['info'] = json.loads(buf.decode())
3636
3644
  except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
3637
- pass # TODO: log error
3645
+ pass # e silenced
3638
3646
  s.close()
3639
3647
  except OSError:
3640
3648
  pass # safe to ignore
@@ -3723,7 +3731,7 @@ Env: VSSH_SECRET
3723
3731
  print(f" {r['name']:6s} {r['ip']:18s} ○ offline")
3724
3732
  print(f"\nTotal: {online}/{total} online")
3725
3733
  else:
3726
- # Unknown command -> try as PTY session shortcut (vssh g3 = vssh session g3)
3734
+ # Unknown command -> try as PTY session shortcut (vssh node3 = vssh session node3)
3727
3735
  host = resolve_name(cmd)
3728
3736
  rc = pty_session(host, cmd)
3729
3737
  if rc != 0:
@@ -237,9 +237,12 @@ def vssh_download(ip: str, remote_path: str, local_path: str) -> dict:
237
237
  if not resp.startswith(b'OK:'):
238
238
  return {"error": resp.decode()}
239
239
 
240
- # Parse size
241
- parts = resp.decode().split(':')
242
- size = int(parts[1])
240
+ # Parse size — format: OK:<size>\n or OK:<size>:<checksum>\n
241
+ try:
242
+ parts = resp.decode().split(':')
243
+ size = int(parts[1].strip())
244
+ except (IndexError, ValueError) as parse_err:
245
+ return {"error": f"Unexpected server response: {resp[:64]!r} ({parse_err})"}
243
246
 
244
247
  # Receive file data
245
248
  start = time.time()
@@ -253,8 +256,9 @@ def vssh_download(ip: str, remote_path: str, local_path: str) -> dict:
253
256
  elapsed = time.time() - start
254
257
  sock.close()
255
258
 
256
- # Save file
257
- os.makedirs(os.path.dirname(local_path) or '.', exist_ok=True)
259
+ # Save file — ensure parent dir exists for bare filenames too
260
+ local_dir = os.path.dirname(os.path.abspath(local_path))
261
+ os.makedirs(local_dir, exist_ok=True)
258
262
  with open(local_path, 'wb') as f:
259
263
  f.write(data)
260
264
 
@@ -342,28 +346,57 @@ def tool_vssh_get(server: str, remote_path: str, local_path: str):
342
346
  return vssh_download(ip, remote_path, local_path)
343
347
 
344
348
  def tool_vssh_sync(source: str, dest: str, path: str):
345
- """Sync directory between servers"""
349
+ """Sync directory from source server to dest server via local relay"""
346
350
  servers = get_servers()
347
351
 
348
- if source not in servers or dest not in servers:
349
- return {"error": "Unknown server(s)"}
352
+ if source not in servers:
353
+ return {"error": f"Unknown source server: {source}"}
354
+ if dest not in servers:
355
+ return {"error": f"Unknown dest server: {dest}"}
356
+
357
+ src_ip = servers[source].get("ip", "").strip()
358
+ dst_ip = servers[dest].get("ip", "").strip()
359
+
360
+ if not src_ip:
361
+ return {"error": f"No IP configured for source server: {source}"}
362
+ if not dst_ip:
363
+ return {"error": f"No IP configured for dest server: {dest}"}
350
364
 
351
365
  # Get file list from source
352
- src_ip = servers[source].get("ip", "")
353
366
  files_output = vssh_exec_cmd(src_ip, f"find {path} -type f 2>/dev/null | head -100")
354
-
355
367
  if files_output.startswith("Error"):
356
- return {"error": files_output}
357
-
358
- files = [f for f in files_output.split('\n') if f.strip()]
368
+ return {"error": f"Cannot list files on {source}: {files_output}"}
369
+
370
+ files = [f.strip() for f in files_output.split("\n") if f.strip()]
371
+ if not files:
372
+ return {"source": source, "dest": dest, "path": path,
373
+ "synced": 0, "message": "No files found at path"}
374
+
375
+ # Relay: download each file from source, upload to dest
376
+ import tempfile
377
+ synced, failed = [], []
378
+ with tempfile.TemporaryDirectory(prefix="vssh_sync_") as tmpdir:
379
+ for remote_file in files:
380
+ fname = os.path.basename(remote_file)
381
+ local_tmp = os.path.join(tmpdir, fname)
382
+ dl = vssh_download(src_ip, remote_file, local_tmp)
383
+ if "error" in dl:
384
+ failed.append({"file": remote_file, "error": dl["error"]})
385
+ continue
386
+ ul = vssh_upload(dst_ip, local_tmp, remote_file)
387
+ if "error" in ul:
388
+ failed.append({"file": remote_file, "error": ul["error"]})
389
+ else:
390
+ synced.append(remote_file)
359
391
 
360
392
  return {
361
393
  "source": source,
362
394
  "dest": dest,
363
395
  "path": path,
364
- "files_found": len(files),
365
- "files": files[:20],
366
- "note": "Use vssh_put for each file to sync"
396
+ "total_files": len(files),
397
+ "synced": len(synced),
398
+ "failed": len(failed),
399
+ "errors": failed[:10] if failed else []
367
400
  }
368
401
 
369
402
  def tool_vssh_speed_test(server: str, size_mb: int = 10):
@@ -433,41 +466,97 @@ def tool_vssh_p2p_status():
433
466
  }
434
467
 
435
468
  def tool_vssh_tunnel(server: str, local_port: int, remote_port: int, remote_host: str = "localhost"):
436
- """Create SSH tunnel (port forwarding)"""
469
+ """Create SSH tunnel (port forwarding). Launches tunnel in background and returns PID."""
437
470
  servers = get_servers()
438
471
 
439
472
  if server not in servers:
440
473
  return {"error": f"Unknown server: {server}"}
441
474
 
442
- ip = servers[server].get("ip", "")
475
+ ip = servers[server].get("ip", "").strip()
476
+ if not ip:
477
+ return {"error": f"No IP configured for server: {server}"}
443
478
 
444
- # Check if tunnel already exists
479
+ user = servers[server].get("user", "root")
480
+
481
+ # Check if local port is already in use
445
482
  import subprocess
446
483
  check = subprocess.run(
447
484
  f"lsof -i :{local_port} 2>/dev/null | grep LISTEN",
448
485
  shell=True, capture_output=True, text=True
449
486
  )
450
-
451
487
  if check.stdout.strip():
488
+ # Check if it's already our tunnel
489
+ if ip in check.stdout or server in check.stdout:
490
+ return {
491
+ "status": "already_running",
492
+ "local_port": local_port,
493
+ "message": f"Tunnel to {server}:{remote_port} already running on localhost:{local_port}"
494
+ }
452
495
  return {
453
496
  "status": "port_in_use",
454
497
  "local_port": local_port,
455
- "message": f"Port {local_port} is already in use"
498
+ "message": f"Port {local_port} is already in use by another process"
456
499
  }
457
500
 
458
- # Create tunnel command
459
- tunnel_cmd = f"ssh -f -N -L {local_port}:{remote_host}:{remote_port} {ip}"
501
+ # Build SSH tunnel command
502
+ ssh_args = [
503
+ "ssh", "-f", "-N",
504
+ "-o", "StrictHostKeyChecking=no",
505
+ "-o", "ConnectTimeout=10",
506
+ "-o", "BatchMode=yes",
507
+ "-L", f"{local_port}:{remote_host}:{remote_port}",
508
+ f"{user}@{ip}"
509
+ ]
510
+ tunnel_cmd = " ".join(ssh_args)
511
+
512
+ # Launch tunnel as background process
513
+ try:
514
+ proc = subprocess.Popen(
515
+ ssh_args,
516
+ stdout=subprocess.DEVNULL,
517
+ stderr=subprocess.PIPE,
518
+ start_new_session=True
519
+ )
520
+ # Wait briefly to detect immediate failures
521
+ import time
522
+ time.sleep(1.5)
523
+ if proc.poll() is not None:
524
+ # Process already exited — SSH failed
525
+ err = proc.stderr.read().decode(errors="replace").strip()
526
+ return {
527
+ "status": "failed",
528
+ "error": f"SSH tunnel failed: {err or 'exited immediately'}",
529
+ "command": tunnel_cmd,
530
+ "hint": "Check SSH key auth: ssh-copy-id " + user + "@" + ip
531
+ }
460
532
 
461
- return {
462
- "status": "ready",
463
- "server": server,
464
- "local_port": local_port,
465
- "remote_host": remote_host,
466
- "remote_port": remote_port,
467
- "command": tunnel_cmd,
468
- "usage": f"Access {remote_host}:{remote_port} on {server} via localhost:{local_port}",
469
- "note": "Run the command to create the tunnel. Use 'pkill -f \"ssh.*-L {local_port}\"' to close."
470
- }
533
+ # Verify port is now listening
534
+ verify = subprocess.run(
535
+ f"lsof -i :{local_port} 2>/dev/null | grep LISTEN",
536
+ shell=True, capture_output=True, text=True
537
+ )
538
+ listening = bool(verify.stdout.strip())
539
+
540
+ return {
541
+ "status": "running" if listening else "started",
542
+ "pid": proc.pid,
543
+ "server": server,
544
+ "local_port": local_port,
545
+ "remote_host": remote_host,
546
+ "remote_port": remote_port,
547
+ "access": f"localhost:{local_port}",
548
+ "usage": f"Connect to {remote_host}:{remote_port} on {server} via localhost:{local_port}",
549
+ "close_cmd": f"kill {proc.pid} # or: pkill -f \'ssh.*-L {local_port}\'"
550
+ }
551
+
552
+ except FileNotFoundError:
553
+ return {
554
+ "status": "no_ssh",
555
+ "error": "ssh not found — install OpenSSH",
556
+ "command": tunnel_cmd
557
+ }
558
+ except Exception as e:
559
+ return {"status": "error", "error": str(e), "command": tunnel_cmd}
471
560
 
472
561
  def tool_vssh_keys():
473
562
  """Manage vssh keys and secrets"""
@@ -493,7 +582,7 @@ def tool_vssh_keys():
493
582
  "permissions": oct(os.stat(fpath).st_mode)[-3:]
494
583
  })
495
584
 
496
- # Check servers with vssh daemon
585
+ # Check servers with vssh server running
497
586
  servers = get_servers()
498
587
  keys["servers_with_vssh"] = []
499
588
 
@@ -634,7 +723,7 @@ TOOLS = [
634
723
  },
635
724
  {
636
725
  "name": "vssh_tunnel",
637
- "description": "Create SSH tunnel (port forwarding) to access remote services locally.",
726
+ "description": "Create SSH tunnel (port forwarding) to access remote services locally. Launches tunnel in background, returns PID and access URL.",
638
727
  "inputSchema": {
639
728
  "type": "object",
640
729
  "properties": {
@@ -758,5 +847,21 @@ def main():
758
847
  sys.stderr.write(f"Error: {e}\n")
759
848
  break
760
849
 
850
+
851
+ def handle_tool(name: str, arguments: dict) -> str:
852
+ """Unified MCP compatibility wrapper."""
853
+ resp = handle_request({
854
+ "method": "tools/call",
855
+ "id": 1,
856
+ "params": {"name": name, "arguments": arguments},
857
+ })
858
+ if resp and "result" in resp:
859
+ content = resp["result"].get("content", [])
860
+ if content:
861
+ return content[0].get("text", "{}")
862
+ if resp and "error" in resp:
863
+ return json.dumps({"error": resp["error"]})
864
+ return json.dumps({"error": "no result"})
865
+
761
866
  if __name__ == "__main__":
762
867
  main()
@@ -519,9 +519,9 @@ def p2p_tcp_connect(peer_ip: str, peer_port: int, local_port: int,
519
519
  return None
520
520
 
521
521
  def signal_exchange(peer_vpn_ip: str, my_external: tuple, relay_ip: str = None, timeout: int = 30) -> tuple:
522
- """Exchange external addresses via relay server (v1)
522
+ """Exchange external addresses via relay server (relay1)
523
523
 
524
- Both peers register with v1, then query for each other.
524
+ Both peers register with relay1, then query for each other.
525
525
  Returns peer's (external_ip, external_port, local_port) or (None, None, None)
526
526
  """
527
527
  import subprocess