vssh 3.3.9__tar.gz → 3.6.1__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.9
3
+ Version: 3.6.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vssh"
7
- version = "3.3.9"
7
+ version = "3.6.1"
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"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vssh
3
- Version: 3.3.9
3
+ Version: 3.6.1
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
@@ -12,7 +12,6 @@ from pathlib import Path
12
12
  from concurrent.futures import ThreadPoolExecutor, as_completed
13
13
  from typing import Tuple, Optional
14
14
 
15
- __version__ = '3.3.9'
16
15
 
17
16
  # ========== Standalone Config (no mpop dependency) ==========
18
17
  VSSH_DIR_CONF = Path.home() / '.vssh'
@@ -56,7 +55,9 @@ HMAC_WINDOW = 60 # seconds - allow 60s clock skew
56
55
  # Loaded from ~/.vssh/config (TAILSCALE.<wire_ip> = <ts_ip>) or ~/.vssh/tailscale_map
57
56
 
58
57
  def _load_tailscale_map() -> dict:
59
- """Load Wire VPN IP → Tailscale IP mapping"""
58
+ """Load Wire VPN IP → Tailscale IP mapping.
59
+ Priority: manual config > tailscale_map file > auto-detect (tailscale + wire server)
60
+ """
60
61
  ts_map = {}
61
62
 
62
63
  # 1. From config: TAILSCALE.10.99.85.143 = 100.80.191.6
@@ -76,43 +77,81 @@ def _load_tailscale_map() -> dict:
76
77
  parts = line.split()
77
78
  if len(parts) >= 2:
78
79
  ts_map[parts[0]] = parts[1] # wire_ip tailscale_ip
79
- except (OSError, ValueError) as e:
80
- pass # e silenced
80
+ except (OSError, ValueError):
81
+ pass
82
+
83
+ # 3. Auto-detect: cross-reference `tailscale status --json` + wire /peers
84
+ if not ts_map:
85
+ try:
86
+ import urllib.request as _ur, subprocess as _sp2
87
+ ts_out = _sp2.run(['tailscale', 'status', '--json'],
88
+ capture_output=True, text=True, timeout=5)
89
+ if ts_out.returncode == 0:
90
+ ts_data = json.loads(ts_out.stdout)
91
+ ts_host = {}
92
+ for peer in list(ts_data.get('Peer', {}).values()) + [ts_data.get('Self', {})]:
93
+ if not peer:
94
+ continue
95
+ name = peer.get('HostName', '').lower().split('.')[0]
96
+ ips = [ip for ip in peer.get('TailscaleIPs', []) if ':' not in ip]
97
+ if name and ips:
98
+ ts_host[name] = ips[0]
99
+ for srv_ip in ['10.99.85.143', '158.247.247.115']:
100
+ try:
101
+ with _ur.urlopen(f'http://{srv_ip}:8790/peers', timeout=3) as r:
102
+ for p in json.loads(r.read()).get('peers', []):
103
+ name = p.get('node_name', '').lower()
104
+ wire_ip = p.get('vpn_ip', '')
105
+ ts_ip = ts_host.get(name)
106
+ if wire_ip and ts_ip:
107
+ ts_map[wire_ip] = ts_ip
108
+ break
109
+ except Exception:
110
+ continue
111
+ except Exception:
112
+ pass
81
113
 
82
114
  return ts_map
83
115
 
84
116
  TAILSCALE_MAP = _load_tailscale_map()
85
- _FAILOVER_ACTIVE = {} # host -> tailscale_ip (cache of active failovers)
117
+ _FAILOVER_ACTIVE = {} # host -> (tailscale_ip, timestamp) Wire retry after 60s
118
+ _FAILOVER_RETRY_INTERVAL = 60 # seconds before retrying Wire after failover
86
119
 
87
120
  def vssh_connect(host: str, timeout: float = 5.0) -> socket.socket:
88
121
  """Connect to vssh server with automatic Tailscale failover.
89
122
 
90
123
  1. Try Wire VPN IP (primary)
91
- 2. If Wire fails → try Tailscale IP (fallback)
92
- 3. Cache successful failover for subsequent connections
124
+ 2. If Wire fails → try Tailscale IP (fallback), cache for 60s
125
+ 3. After 60s, retry Wire if Wire recovered, clear failover cache
93
126
 
94
127
  Returns connected socket or raises ConnectionError.
95
128
  """
96
- # If this host already failed Wire and succeeded via Tailscale, go direct
129
+ # If this host recently failed Wire, use cached Tailscale (but retry Wire after 60s)
97
130
  if host in _FAILOVER_ACTIVE:
98
- ts_ip = _FAILOVER_ACTIVE[host]
99
- try:
100
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
101
- sock.settimeout(timeout)
102
- sock.connect((ts_ip, PORT))
103
- return sock
104
- except (OSError, socket.error):
105
- # Tailscale also failed — clear cache, try Wire again
131
+ ts_ip, failover_time = _FAILOVER_ACTIVE[host]
132
+ if time.time() - failover_time < _FAILOVER_RETRY_INTERVAL:
133
+ # Still within retry window — use Tailscale directly
134
+ try:
135
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
136
+ sock.settimeout(timeout)
137
+ sock.connect((ts_ip, PORT))
138
+ return sock
139
+ except (OSError, socket.error):
140
+ # Tailscale also failed — clear cache, fall through to Wire retry
141
+ del _FAILOVER_ACTIVE[host]
142
+ else:
143
+ # 60s elapsed — try Wire again first (Wire may have recovered)
106
144
  del _FAILOVER_ACTIVE[host]
107
145
 
108
146
  # Primary: try Wire VPN IP
109
147
  try:
110
148
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
111
- sock.settimeout(timeout)
149
+ sock.settimeout(min(timeout, 2.0)) # 2s: enough for WireGuard handshake
112
150
  sock.connect((host, PORT))
151
+ sock.settimeout(timeout) # Restore full timeout for I/O
113
152
  return sock
114
- except (ConnectionRefusedError, ConnectionError, OSError, socket.timeout) as wire_err:
115
- pass # wire_err silenced
153
+ except (ConnectionRefusedError, ConnectionError, OSError, socket.timeout):
154
+ pass # Wire failed, try Tailscale
116
155
 
117
156
  # Fallback: try Tailscale IP
118
157
  ts_ip = TAILSCALE_MAP.get(host)
@@ -123,10 +162,10 @@ def vssh_connect(host: str, timeout: float = 5.0) -> socket.socket:
123
162
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
124
163
  sock.settimeout(timeout)
125
164
  sock.connect((ts_ip, PORT))
126
- _FAILOVER_ACTIVE[host] = ts_ip
165
+ _FAILOVER_ACTIVE[host] = (ts_ip, time.time())
127
166
  print(f'[FAILOVER] {host} → {ts_ip} (Tailscale)')
128
167
  return sock
129
- except Exception as ts_err:
168
+ except Exception:
130
169
  raise ConnectionError(
131
170
  f'Both Wire ({host}) and Tailscale ({ts_ip}) failed'
132
171
  )
@@ -493,17 +532,15 @@ def resolve_name(host: str) -> str:
493
532
  for srv_ip in WIRE_SERVERS:
494
533
  try:
495
534
  import urllib.request
496
- req = urllib.request.Request(f"http://{srv_ip}:8786/peers")
535
+ req = urllib.request.Request(f"http://{srv_ip}:8790/peers")
497
536
  with urllib.request.urlopen(req, timeout=3) as resp:
498
537
  data = json.loads(resp.read())
499
538
  peers = data.get("peers", [])
500
539
  _name_cache.clear()
501
540
  for p in peers:
502
541
  vpn_ip = p.get("vpn_ip", "")
503
- dn = p.get("display_name", "")
504
- hn = p.get("hostname", "")
505
- if dn: _name_cache[dn] = vpn_ip
506
- if hn: _name_cache[hn] = vpn_ip
542
+ nn = p.get("node_name", "")
543
+ if nn: _name_cache[nn] = vpn_ip
507
544
  _name_cache_time = time.time()
508
545
  if host in _name_cache:
509
546
  return _name_cache[host]
@@ -3355,7 +3392,7 @@ def _cmd_install(args):
3355
3392
 
3356
3393
  # --- Step 1: Upgrade pip package ---
3357
3394
  print(f" pip : upgrading vssh...")
3358
- pip_args = [python_exe, '-m', 'pip', 'install', 'vssh', '--upgrade', '-q']
3395
+ pip_args = [python_exe, '-m', 'pip', 'install', 'git+https://github.com/meshpop/vssh.git', '--upgrade', '-q']
3359
3396
  if _plat.system() == 'Linux':
3360
3397
  pip_args.append('--break-system-packages')
3361
3398
  r = _sp.run(pip_args, capture_output=True, text=True)
@@ -3364,21 +3401,33 @@ def _cmd_install(args):
3364
3401
  else:
3365
3402
  print(f" pip : \u2717 {r.stderr.strip()[:120]}")
3366
3403
 
3367
- # --- Step 2: Find installed vssh.py (after upgrade) ---
3368
- import importlib, importlib.util as _ilu
3404
+ # --- Step 2: Find installed vssh.py from pip (not the running script) ---
3405
+ installed_py = None
3369
3406
  try:
3370
- importlib.invalidate_caches()
3371
- spec = _ilu.find_spec('vssh')
3372
- installed_py = spec.origin if (spec and spec.origin) else _os.path.abspath(__file__)
3407
+ r2 = _sp.run([python_exe, '-m', 'pip', 'show', 'vssh'],
3408
+ capture_output=True, text=True)
3409
+ for _line in r2.stdout.splitlines():
3410
+ if _line.startswith('Location:'):
3411
+ _loc = _line.split(':', 1)[1].strip()
3412
+ _cand = _os.path.join(_loc, 'vssh.py')
3413
+ if _os.path.isfile(_cand):
3414
+ installed_py = _cand
3415
+ break
3373
3416
  except Exception:
3417
+ pass
3418
+ if not installed_py:
3374
3419
  installed_py = _os.path.abspath(__file__)
3375
3420
 
3376
3421
  # --- Step 3: Copy vssh.py to /usr/local/bin/vssh.py ---
3377
3422
  target_py = '/usr/local/bin/vssh.py'
3378
3423
  try:
3379
- _sh.copy2(installed_py, target_py)
3380
- _os.chmod(target_py, 0o755)
3381
- print(f" copied : {installed_py} \u2192 {target_py}")
3424
+ import os.path as _osp
3425
+ if _osp.exists(target_py) and _osp.samefile(installed_py, target_py):
3426
+ print(f" skipped : already up to date ({target_py})")
3427
+ else:
3428
+ _sh.copy2(installed_py, target_py)
3429
+ _os.chmod(target_py, 0o755)
3430
+ print(f" copied : {installed_py} \u2192 {target_py}")
3382
3431
  except PermissionError:
3383
3432
  print(f" warning : cannot write {target_py} (need root) — using {installed_py}")
3384
3433
  target_py = installed_py
@@ -3490,7 +3539,11 @@ def _cmd_install(args):
3490
3539
 
3491
3540
 
3492
3541
  def _get_vssh_version():
3493
- return __version__
3542
+ try:
3543
+ from importlib.metadata import version as _v
3544
+ return _v('vssh')
3545
+ except Exception:
3546
+ return '?'
3494
3547
 
3495
3548
 
3496
3549
 
@@ -3502,7 +3555,7 @@ def main():
3502
3555
  from importlib.metadata import version as _v
3503
3556
  print(f"vssh v{_v('vssh')}")
3504
3557
  except Exception:
3505
- print(f"vssh v{__version__}")
3558
+ print(f"vssh v{_get_vssh_version()}")
3506
3559
  return
3507
3560
  if not args:
3508
3561
  print('''vssh v3 - Distributed command & file transport daemon for server meshes
@@ -3828,13 +3881,11 @@ Env: VSSH_SECRET
3828
3881
  _servers[k] = v
3829
3882
 
3830
3883
  def _check_node(name, ip):
3831
- """Check a single node — connect + optional INFO query"""
3884
+ """Check a single node — connect + optional INFO query (Wire VPN → Tailscale fallback)"""
3832
3885
  result = {'name': name, 'ip': ip, 'online': False, 'latency': 0}
3833
3886
  try:
3834
- s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
3835
- s.settimeout(1)
3836
3887
  t0 = time.time()
3837
- s.connect((ip, 48291))
3888
+ s = vssh_connect(ip, timeout=3.0)
3838
3889
  result['latency'] = (time.time() - t0) * 1000
3839
3890
  result['online'] = True
3840
3891
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes