vssh 3.6.1__tar.gz → 3.6.6__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.6.1
3
+ Version: 3.6.6
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.6.1"
7
+ version = "3.6.6"
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.6.1
3
+ Version: 3.6.6
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
@@ -144,14 +144,31 @@ def vssh_connect(host: str, timeout: float = 5.0) -> socket.socket:
144
144
  del _FAILOVER_ACTIVE[host]
145
145
 
146
146
  # Primary: try Wire VPN IP
147
+ # First attempt: 2s fast path
148
+ # If timeout (cold WireGuard peer re-handshake), retry once with longer timeout
149
+ _wire_refused = False
147
150
  try:
148
151
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
149
- sock.settimeout(min(timeout, 2.0)) # 2s: enough for WireGuard handshake
152
+ sock.settimeout(min(timeout, 2.0)) # 2s: fast path
150
153
  sock.connect((host, PORT))
151
- sock.settimeout(timeout) # Restore full timeout for I/O
154
+ sock.settimeout(timeout)
152
155
  return sock
153
- except (ConnectionRefusedError, ConnectionError, OSError, socket.timeout):
154
- pass # Wire failed, try Tailscale
156
+ except ConnectionRefusedError:
157
+ _wire_refused = True # Port closed no point retrying
158
+ except (ConnectionError, OSError, socket.timeout):
159
+ pass # Timeout — WireGuard may be re-handshaking, retry once
160
+
161
+ if not _wire_refused:
162
+ # Retry: wait 1s for WireGuard handshake to complete, then retry
163
+ time.sleep(1.0)
164
+ try:
165
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
166
+ sock.settimeout(min(timeout, 5.0)) # 5s: handshake should be done by now
167
+ sock.connect((host, PORT))
168
+ sock.settimeout(timeout)
169
+ return sock
170
+ except (ConnectionRefusedError, ConnectionError, OSError, socket.timeout):
171
+ pass # Wire truly failed, fall through to Tailscale
155
172
 
156
173
  # Fallback: try Tailscale IP
157
174
  ts_ip = TAILSCALE_MAP.get(host)
@@ -515,23 +532,26 @@ _name_cache = {} # name -> vpn_ip
515
532
  _name_cache_time = 0
516
533
 
517
534
  def resolve_name(host: str) -> str:
518
- """Resolve server name to VPN IP via Wire VPN /peers API (standalone)"""
535
+ """Resolve server name to VPN IP via Wire VPN /peers API (coordinator-first)
536
+
537
+ Priority:
538
+ 1. Fresh in-memory cache (< 60s) — avoids repeated network calls
539
+ 2. Wire coordinator /peers — source of truth (IPs change when nodes move)
540
+ 3. ~/.vssh/config static mapping — fallback when coordinator unreachable
541
+ """
519
542
  global _name_cache, _name_cache_time
520
543
  # Strip user@ prefix
521
544
  if '@' in host:
522
545
  host = host.split('@', 1)[1]
523
546
  if not host or host[0].isdigit():
524
547
  return host
525
- # 1. Check ~/.vssh/config local mapping (instant, no network)
526
- if host in VSSH_CONFIG:
527
- return VSSH_CONFIG[host]
528
- # Use cache if fresh (< 60s)
548
+ # 1. Use fresh cache coordinator already queried recently
529
549
  if host in _name_cache and time.time() - _name_cache_time < 60:
530
550
  return _name_cache[host]
531
- # Query Wire VPN server
551
+ # 2. Query Wire coordinator (source of truth — IPs change when nodes move)
552
+ import urllib.request
532
553
  for srv_ip in WIRE_SERVERS:
533
554
  try:
534
- import urllib.request
535
555
  req = urllib.request.Request(f"http://{srv_ip}:8790/peers")
536
556
  with urllib.request.urlopen(req, timeout=3) as resp:
537
557
  data = json.loads(resp.read())
@@ -544,9 +564,12 @@ def resolve_name(host: str) -> str:
544
564
  _name_cache_time = time.time()
545
565
  if host in _name_cache:
546
566
  return _name_cache[host]
547
- break # got peers, just no match
567
+ break # got peers from coordinator, just no match for this host
548
568
  except (OSError, ValueError, TimeoutError):
549
569
  continue
570
+ # 3. Fall back to static config (useful when all coordinators unreachable)
571
+ if host in VSSH_CONFIG:
572
+ return VSSH_CONFIG[host]
550
573
  return host
551
574
 
552
575
 
@@ -3860,6 +3883,65 @@ Env: VSSH_SECRET
3860
3883
  print(f' Done: {result["ok"]} ok, {result["skip"]} skip, {result["fail"]} fail')
3861
3884
 
3862
3885
  sys.exit(0 if result['fail'] == 0 else 1)
3886
+ elif cmd == 'up':
3887
+ # Start vssh daemon (systemd or direct)
3888
+ import subprocess as _sp, os as _os
3889
+ if _os.path.exists("/run/systemd/system"):
3890
+ r = _sp.run(["systemctl", "start", "vssh"], capture_output=True)
3891
+ if r.returncode == 0:
3892
+ print("vssh started")
3893
+ else:
3894
+ print(r.stderr.decode().strip() or "Failed to start vssh via systemctl")
3895
+ else:
3896
+ print("Starting vssh daemon...")
3897
+ server()
3898
+ elif cmd == 'down':
3899
+ # Stop vssh daemon
3900
+ import subprocess as _sp, os as _os
3901
+ if _os.path.exists("/run/systemd/system"):
3902
+ r = _sp.run(["systemctl", "stop", "vssh"], capture_output=True)
3903
+ if r.returncode == 0:
3904
+ print("vssh stopped")
3905
+ else:
3906
+ print(r.stderr.decode().strip() or "Failed to stop vssh via systemctl")
3907
+ else:
3908
+ import signal as _sig
3909
+ import glob as _glob
3910
+ killed = 0
3911
+ for _f in _glob.glob("/tmp/vssh_*.pid") + _glob.glob("/var/run/vssh.pid"):
3912
+ try:
3913
+ _pid = int(open(_f).read().strip())
3914
+ _os.kill(_pid, _sig.SIGTERM)
3915
+ _os.unlink(_f)
3916
+ killed += 1
3917
+ except (ValueError, OSError):
3918
+ pass
3919
+ if killed:
3920
+ print(f"vssh stopped ({killed} process(es))")
3921
+ else:
3922
+ import subprocess as _sp2
3923
+ r = _sp2.run(["pkill", "-f", "vssh.*server"], capture_output=True)
3924
+ print("vssh stopped" if r.returncode == 0 else "vssh was not running")
3925
+ elif cmd == 'restart':
3926
+ # Restart vssh daemon
3927
+ import subprocess as _sp, os as _os
3928
+ if _os.path.exists("/run/systemd/system"):
3929
+ r = _sp.run(["systemctl", "restart", "vssh"], capture_output=True)
3930
+ if r.returncode == 0:
3931
+ print("vssh restarted")
3932
+ else:
3933
+ print(r.stderr.decode().strip() or "Failed to restart vssh via systemctl")
3934
+ else:
3935
+ import signal as _sig, glob as _glob
3936
+ for _f in _glob.glob("/tmp/vssh_*.pid") + _glob.glob("/var/run/vssh.pid"):
3937
+ try:
3938
+ _pid = int(open(_f).read().strip())
3939
+ _os.kill(_pid, _sig.SIGTERM)
3940
+ _os.unlink(_f)
3941
+ except (ValueError, OSError):
3942
+ pass
3943
+ print("Restarting vssh daemon...")
3944
+ server()
3863
3945
  elif cmd == 'server':
3864
3946
  server()
3865
3947
  elif cmd == 'install':
@@ -3873,15 +3955,39 @@ Env: VSSH_SECRET
3873
3955
  import socket as _sock
3874
3956
  import concurrent.futures
3875
3957
  full_mode = '--full' in args or '-f' in args
3876
- # Use vssh standalone config (no mpop dependency)
3958
+ # Build server list: coordinator first (source of truth), config as supplement
3877
3959
  _servers = {}
3960
+ import urllib.request as _ureq
3961
+ for _srv_ip in WIRE_SERVERS:
3962
+ try:
3963
+ with _ureq.urlopen(f"http://{_srv_ip}:8790/peers", timeout=3) as _r:
3964
+ _peers = json.loads(_r.read()).get("peers", [])
3965
+ for _p in _peers:
3966
+ _nn = _p.get("node_name", "")
3967
+ _vip = _p.get("vpn_ip", "")
3968
+ if _nn and _vip:
3969
+ _servers[_nn] = _vip
3970
+ break
3971
+ except (OSError, ValueError, TimeoutError):
3972
+ continue
3973
+ # Detect local node by trying to bind to each VPN IP
3974
+ def _is_local_ip(ip):
3975
+ try:
3976
+ _s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM)
3977
+ _s.bind((ip, 0)); _s.close(); return True
3978
+ except OSError:
3979
+ return False
3980
+ _my_vpn_ip = next((ip for ip in _servers.values() if _is_local_ip(ip)), "")
3981
+ # Also include any config entries not in coordinator (offline/static nodes)
3878
3982
  for k, v in VSSH_CONFIG.items():
3879
- if k in ('SECRET','SSH_PORT','SCP_PORT'): continue
3880
- if v and v[0].isdigit():
3983
+ if k in ('SECRET', 'SSH_PORT', 'SCP_PORT'): continue
3984
+ if v and v[0].isdigit() and k not in _servers:
3881
3985
  _servers[k] = v
3882
3986
 
3883
3987
  def _check_node(name, ip):
3884
3988
  """Check a single node — connect + optional INFO query (Wire VPN → Tailscale fallback)"""
3989
+ if ip == _my_vpn_ip:
3990
+ return {'name': name, 'ip': ip, 'online': True, 'latency': 0, 'local': True}
3885
3991
  result = {'name': name, 'ip': ip, 'online': False, 'latency': 0}
3886
3992
  try:
3887
3993
  t0 = time.time()
@@ -3993,6 +4099,8 @@ Env: VSSH_SECRET
3993
4099
  cores = d.get('cores', '')
3994
4100
  cores_str = f"{cores}c" if cores else ""
3995
4101
  print(f" {r['name']:6s} ● {r['latency']:4.0f}ms mem={mem_str:>12s} load={load_str:>5s} up={up_str:>7s} {cores_str}")
4102
+ elif r.get('local'):
4103
+ print(f" {r['name']:6s} {r['ip']:18s} ● online (local)")
3996
4104
  else:
3997
4105
  print(f" {r['name']:6s} {r['ip']:18s} ● online ({r['latency']:.0f}ms)")
3998
4106
  else:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes