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.
- {vssh-3.3.10/vssh.egg-info → vssh-3.6.2}/PKG-INFO +1 -1
- {vssh-3.3.10 → vssh-3.6.2}/pyproject.toml +1 -1
- {vssh-3.3.10 → vssh-3.6.2/vssh.egg-info}/PKG-INFO +1 -1
- {vssh-3.3.10 → vssh-3.6.2}/vssh.py +117 -49
- {vssh-3.3.10 → vssh-3.6.2}/LICENSE +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/README.md +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/setup.cfg +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/vssh.egg-info/SOURCES.txt +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/vssh.egg-info/dependency_links.txt +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/vssh.egg-info/entry_points.txt +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/vssh.egg-info/top_level.txt +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/vssh_mcp_server.py +0 -0
- {vssh-3.3.10 → vssh-3.6.2}/vssh_p2p.py +0 -0
|
@@ -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)
|
|
79
|
-
pass
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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)
|
|
114
|
-
pass #
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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 (
|
|
3367
|
-
|
|
3410
|
+
# --- Step 2: Find installed vssh.py from pip (not the running script) ---
|
|
3411
|
+
installed_py = None
|
|
3368
3412
|
try:
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
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
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|