vssh 3.3.10__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.10
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.10"
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.10
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
@@ -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
  )
@@ -492,17 +532,15 @@ def resolve_name(host: str) -> str:
492
532
  for srv_ip in WIRE_SERVERS:
493
533
  try:
494
534
  import urllib.request
495
- req = urllib.request.Request(f"http://{srv_ip}:8786/peers")
535
+ req = urllib.request.Request(f"http://{srv_ip}:8790/peers")
496
536
  with urllib.request.urlopen(req, timeout=3) as resp:
497
537
  data = json.loads(resp.read())
498
538
  peers = data.get("peers", [])
499
539
  _name_cache.clear()
500
540
  for p in peers:
501
541
  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
542
+ nn = p.get("node_name", "")
543
+ if nn: _name_cache[nn] = vpn_ip
506
544
  _name_cache_time = time.time()
507
545
  if host in _name_cache:
508
546
  return _name_cache[host]
@@ -3354,7 +3392,7 @@ def _cmd_install(args):
3354
3392
 
3355
3393
  # --- Step 1: Upgrade pip package ---
3356
3394
  print(f" pip : upgrading vssh...")
3357
- 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']
3358
3396
  if _plat.system() == 'Linux':
3359
3397
  pip_args.append('--break-system-packages')
3360
3398
  r = _sp.run(pip_args, capture_output=True, text=True)
@@ -3363,21 +3401,33 @@ def _cmd_install(args):
3363
3401
  else:
3364
3402
  print(f" pip : \u2717 {r.stderr.strip()[:120]}")
3365
3403
 
3366
- # --- Step 2: Find installed vssh.py (after upgrade) ---
3367
- import importlib, importlib.util as _ilu
3404
+ # --- Step 2: Find installed vssh.py from pip (not the running script) ---
3405
+ installed_py = None
3368
3406
  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__)
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
3372
3416
  except Exception:
3417
+ pass
3418
+ if not installed_py:
3373
3419
  installed_py = _os.path.abspath(__file__)
3374
3420
 
3375
3421
  # --- Step 3: Copy vssh.py to /usr/local/bin/vssh.py ---
3376
3422
  target_py = '/usr/local/bin/vssh.py'
3377
3423
  try:
3378
- _sh.copy2(installed_py, target_py)
3379
- _os.chmod(target_py, 0o755)
3380
- 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}")
3381
3431
  except PermissionError:
3382
3432
  print(f" warning : cannot write {target_py} (need root) — using {installed_py}")
3383
3433
  target_py = installed_py
@@ -3831,13 +3881,11 @@ Env: VSSH_SECRET
3831
3881
  _servers[k] = v
3832
3882
 
3833
3883
  def _check_node(name, ip):
3834
- """Check a single node — connect + optional INFO query"""
3884
+ """Check a single node — connect + optional INFO query (Wire VPN → Tailscale fallback)"""
3835
3885
  result = {'name': name, 'ip': ip, 'online': False, 'latency': 0}
3836
3886
  try:
3837
- s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
3838
- s.settimeout(1)
3839
3887
  t0 = time.time()
3840
- s.connect((ip, 48291))
3888
+ s = vssh_connect(ip, timeout=3.0)
3841
3889
  result['latency'] = (time.time() - t0) * 1000
3842
3890
  result['online'] = True
3843
3891
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes