vssh 3.3.10__tar.gz → 3.6.2__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.10
3
+ Version: 3.6.2
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.10"
7
+ version = "3.6.2"
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.10
3
+ Version: 3.6.2
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
@@ -55,7 +55,9 @@ HMAC_WINDOW = 60 # seconds - allow 60s clock skew
55
55
  # Loaded from ~/.vssh/config (TAILSCALE.<wire_ip> = <ts_ip>) or ~/.vssh/tailscale_map
56
56
 
57
57
  def _load_tailscale_map() -> dict:
58
- """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
+ """
59
61
  ts_map = {}
60
62
 
61
63
  # 1. From config: TAILSCALE.10.99.85.143 = 100.80.191.6
@@ -75,43 +77,81 @@ def _load_tailscale_map() -> dict:
75
77
  parts = line.split()
76
78
  if len(parts) >= 2:
77
79
  ts_map[parts[0]] = parts[1] # wire_ip tailscale_ip
78
- except (OSError, ValueError) as e:
79
- 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
80
113
 
81
114
  return ts_map
82
115
 
83
116
  TAILSCALE_MAP = _load_tailscale_map()
84
- _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
85
119
 
86
120
  def vssh_connect(host: str, timeout: float = 5.0) -> socket.socket:
87
121
  """Connect to vssh server with automatic Tailscale failover.
88
122
 
89
123
  1. Try Wire VPN IP (primary)
90
- 2. If Wire fails → try Tailscale IP (fallback)
91
- 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
92
126
 
93
127
  Returns connected socket or raises ConnectionError.
94
128
  """
95
- # 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)
96
130
  if host in _FAILOVER_ACTIVE:
97
- ts_ip = _FAILOVER_ACTIVE[host]
98
- try:
99
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
100
- sock.settimeout(timeout)
101
- sock.connect((ts_ip, PORT))
102
- return sock
103
- except (OSError, socket.error):
104
- # 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)
105
144
  del _FAILOVER_ACTIVE[host]
106
145
 
107
146
  # Primary: try Wire VPN IP
108
147
  try:
109
148
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
110
- sock.settimeout(timeout)
149
+ sock.settimeout(min(timeout, 2.0)) # 2s: enough for WireGuard handshake
111
150
  sock.connect((host, PORT))
151
+ sock.settimeout(timeout) # Restore full timeout for I/O
112
152
  return sock
113
- except (ConnectionRefusedError, ConnectionError, OSError, socket.timeout) as wire_err:
114
- pass # wire_err silenced
153
+ except (ConnectionRefusedError, ConnectionError, OSError, socket.timeout):
154
+ pass # Wire failed, try Tailscale
115
155
 
116
156
  # Fallback: try Tailscale IP
117
157
  ts_ip = TAILSCALE_MAP.get(host)
@@ -122,10 +162,10 @@ def vssh_connect(host: str, timeout: float = 5.0) -> socket.socket:
122
162
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
123
163
  sock.settimeout(timeout)
124
164
  sock.connect((ts_ip, PORT))
125
- _FAILOVER_ACTIVE[host] = ts_ip
165
+ _FAILOVER_ACTIVE[host] = (ts_ip, time.time())
126
166
  print(f'[FAILOVER] {host} → {ts_ip} (Tailscale)')
127
167
  return sock
128
- except Exception as ts_err:
168
+ except Exception:
129
169
  raise ConnectionError(
130
170
  f'Both Wire ({host}) and Tailscale ({ts_ip}) failed'
131
171
  )
@@ -475,40 +515,44 @@ _name_cache = {} # name -> vpn_ip
475
515
  _name_cache_time = 0
476
516
 
477
517
  def resolve_name(host: str) -> str:
478
- """Resolve server name to VPN IP via Wire VPN /peers API (standalone)"""
518
+ """Resolve server name to VPN IP via Wire VPN /peers API (coordinator-first)
519
+
520
+ Priority:
521
+ 1. Fresh in-memory cache (< 60s) — avoids repeated network calls
522
+ 2. Wire coordinator /peers — source of truth (IPs change when nodes move)
523
+ 3. ~/.vssh/config static mapping — fallback when coordinator unreachable
524
+ """
479
525
  global _name_cache, _name_cache_time
480
526
  # Strip user@ prefix
481
527
  if '@' in host:
482
528
  host = host.split('@', 1)[1]
483
529
  if not host or host[0].isdigit():
484
530
  return host
485
- # 1. Check ~/.vssh/config local mapping (instant, no network)
486
- if host in VSSH_CONFIG:
487
- return VSSH_CONFIG[host]
488
- # Use cache if fresh (< 60s)
531
+ # 1. Use fresh cache coordinator already queried recently
489
532
  if host in _name_cache and time.time() - _name_cache_time < 60:
490
533
  return _name_cache[host]
491
- # Query Wire VPN server
534
+ # 2. Query Wire coordinator (source of truth — IPs change when nodes move)
535
+ import urllib.request
492
536
  for srv_ip in WIRE_SERVERS:
493
537
  try:
494
- import urllib.request
495
- req = urllib.request.Request(f"http://{srv_ip}:8786/peers")
538
+ req = urllib.request.Request(f"http://{srv_ip}:8790/peers")
496
539
  with urllib.request.urlopen(req, timeout=3) as resp:
497
540
  data = json.loads(resp.read())
498
541
  peers = data.get("peers", [])
499
542
  _name_cache.clear()
500
543
  for p in peers:
501
544
  vpn_ip = p.get("vpn_ip", "")
502
- dn = p.get("display_name", "")
503
- hn = p.get("hostname", "")
504
- if dn: _name_cache[dn] = vpn_ip
505
- if hn: _name_cache[hn] = vpn_ip
545
+ nn = p.get("node_name", "")
546
+ if nn: _name_cache[nn] = vpn_ip
506
547
  _name_cache_time = time.time()
507
548
  if host in _name_cache:
508
549
  return _name_cache[host]
509
- break # got peers, just no match
550
+ break # got peers from coordinator, just no match for this host
510
551
  except (OSError, ValueError, TimeoutError):
511
552
  continue
553
+ # 3. Fall back to static config (useful when all coordinators unreachable)
554
+ if host in VSSH_CONFIG:
555
+ return VSSH_CONFIG[host]
512
556
  return host
513
557
 
514
558
 
@@ -3354,7 +3398,7 @@ def _cmd_install(args):
3354
3398
 
3355
3399
  # --- Step 1: Upgrade pip package ---
3356
3400
  print(f" pip : upgrading vssh...")
3357
- pip_args = [python_exe, '-m', 'pip', 'install', 'vssh', '--upgrade', '-q']
3401
+ pip_args = [python_exe, '-m', 'pip', 'install', 'git+https://github.com/meshpop/vssh.git', '--upgrade', '-q']
3358
3402
  if _plat.system() == 'Linux':
3359
3403
  pip_args.append('--break-system-packages')
3360
3404
  r = _sp.run(pip_args, capture_output=True, text=True)
@@ -3363,21 +3407,33 @@ def _cmd_install(args):
3363
3407
  else:
3364
3408
  print(f" pip : \u2717 {r.stderr.strip()[:120]}")
3365
3409
 
3366
- # --- Step 2: Find installed vssh.py (after upgrade) ---
3367
- import importlib, importlib.util as _ilu
3410
+ # --- Step 2: Find installed vssh.py from pip (not the running script) ---
3411
+ installed_py = None
3368
3412
  try:
3369
- importlib.invalidate_caches()
3370
- spec = _ilu.find_spec('vssh')
3371
- installed_py = spec.origin if (spec and spec.origin) else _os.path.abspath(__file__)
3413
+ r2 = _sp.run([python_exe, '-m', 'pip', 'show', 'vssh'],
3414
+ capture_output=True, text=True)
3415
+ for _line in r2.stdout.splitlines():
3416
+ if _line.startswith('Location:'):
3417
+ _loc = _line.split(':', 1)[1].strip()
3418
+ _cand = _os.path.join(_loc, 'vssh.py')
3419
+ if _os.path.isfile(_cand):
3420
+ installed_py = _cand
3421
+ break
3372
3422
  except Exception:
3423
+ pass
3424
+ if not installed_py:
3373
3425
  installed_py = _os.path.abspath(__file__)
3374
3426
 
3375
3427
  # --- Step 3: Copy vssh.py to /usr/local/bin/vssh.py ---
3376
3428
  target_py = '/usr/local/bin/vssh.py'
3377
3429
  try:
3378
- _sh.copy2(installed_py, target_py)
3379
- _os.chmod(target_py, 0o755)
3380
- print(f" copied : {installed_py} \u2192 {target_py}")
3430
+ import os.path as _osp
3431
+ if _osp.exists(target_py) and _osp.samefile(installed_py, target_py):
3432
+ print(f" skipped : already up to date ({target_py})")
3433
+ else:
3434
+ _sh.copy2(installed_py, target_py)
3435
+ _os.chmod(target_py, 0o755)
3436
+ print(f" copied : {installed_py} \u2192 {target_py}")
3381
3437
  except PermissionError:
3382
3438
  print(f" warning : cannot write {target_py} (need root) — using {installed_py}")
3383
3439
  target_py = installed_py
@@ -3823,21 +3879,33 @@ Env: VSSH_SECRET
3823
3879
  import socket as _sock
3824
3880
  import concurrent.futures
3825
3881
  full_mode = '--full' in args or '-f' in args
3826
- # Use vssh standalone config (no mpop dependency)
3882
+ # Build server list: coordinator first (source of truth), config as supplement
3827
3883
  _servers = {}
3884
+ import urllib.request as _ureq
3885
+ for _srv_ip in WIRE_SERVERS:
3886
+ try:
3887
+ with _ureq.urlopen(f"http://{_srv_ip}:8790/peers", timeout=3) as _r:
3888
+ _peers = json.loads(_r.read()).get("peers", [])
3889
+ for _p in _peers:
3890
+ _nn = _p.get("node_name", "")
3891
+ _vip = _p.get("vpn_ip", "")
3892
+ if _nn and _vip:
3893
+ _servers[_nn] = _vip
3894
+ break
3895
+ except (OSError, ValueError, TimeoutError):
3896
+ continue
3897
+ # Also include any config entries not in coordinator (offline/static nodes)
3828
3898
  for k, v in VSSH_CONFIG.items():
3829
- if k in ('SECRET','SSH_PORT','SCP_PORT'): continue
3830
- if v and v[0].isdigit():
3899
+ if k in ('SECRET', 'SSH_PORT', 'SCP_PORT'): continue
3900
+ if v and v[0].isdigit() and k not in _servers:
3831
3901
  _servers[k] = v
3832
3902
 
3833
3903
  def _check_node(name, ip):
3834
- """Check a single node — connect + optional INFO query"""
3904
+ """Check a single node — connect + optional INFO query (Wire VPN → Tailscale fallback)"""
3835
3905
  result = {'name': name, 'ip': ip, 'online': False, 'latency': 0}
3836
3906
  try:
3837
- s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
3838
- s.settimeout(1)
3839
3907
  t0 = time.time()
3840
- s.connect((ip, 48291))
3908
+ s = vssh_connect(ip, timeout=3.0)
3841
3909
  result['latency'] = (time.time() - t0) * 1000
3842
3910
  result['online'] = True
3843
3911
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes