Add NordVPN outbound control in Tunnels (v2.5.0).
Connect and disconnect via official nordvpn CLI from Settings with optional country/city, alongside Cloudflare WARP.
This commit is contained in:
@@ -102,7 +102,7 @@ else:
|
||||
application_path = os.path.dirname(__file__)
|
||||
|
||||
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
||||
CURRENT_VERSION = "v2.4.0"
|
||||
CURRENT_VERSION = "v2.5.0"
|
||||
RELEASES_REPO_URL = repo_url()
|
||||
RELEASES_API_LATEST = api_latest_url()
|
||||
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
||||
@@ -138,6 +138,7 @@ TUNNEL_RUNTIMES = {
|
||||
TUNNEL_LOCK = threading.Lock()
|
||||
TUNNEL_URL_RE = re.compile(r'https://[^\s"\']+')
|
||||
WARP_CLI_COMMAND = 'warp-cli.exe' if os.name == 'nt' else 'warp-cli'
|
||||
NORDVPN_CLI_COMMAND = 'nordvpn.exe' if os.name == 'nt' else 'nordvpn'
|
||||
|
||||
|
||||
|
||||
@@ -373,6 +374,152 @@ def disable_warp():
|
||||
return get_warp_status()
|
||||
|
||||
|
||||
def get_nordvpn_cli_binary():
|
||||
found = shutil.which(NORDVPN_CLI_COMMAND)
|
||||
if found:
|
||||
return found
|
||||
if os.name == 'nt':
|
||||
for base in (os.environ.get('ProgramFiles'), os.environ.get('ProgramFiles(x86)')):
|
||||
if not base:
|
||||
continue
|
||||
for sub in ('NordVPN', 'NordVPN NordSec'):
|
||||
candidate = os.path.join(base, sub, 'nordvpn.exe')
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def is_nordvpn_cli_installed():
|
||||
return bool(get_nordvpn_cli_binary())
|
||||
|
||||
|
||||
def _nordvpn_install_hint():
|
||||
system = platform.system().lower()
|
||||
if system == 'windows':
|
||||
return (
|
||||
'Install NordVPN from https://nordvpn.com/download/ or Microsoft Store, '
|
||||
'then restart this panel.'
|
||||
)
|
||||
if system == 'darwin':
|
||||
return 'Install NordVPN for macOS from https://nordvpn.com/download/, then restart this panel.'
|
||||
return (
|
||||
'Install NordVPN CLI: sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh), '
|
||||
'add your user to the nordvpn group, run nordvpn login, then restart this panel.'
|
||||
)
|
||||
|
||||
|
||||
def run_nordvpn_cli(*args, timeout: int = 30):
|
||||
binary = get_nordvpn_cli_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(_nordvpn_install_hint())
|
||||
creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
|
||||
result = subprocess.run(
|
||||
[binary, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=timeout,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
output = '\n'.join(part.strip() for part in (result.stdout, result.stderr) if part and part.strip())
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(output or f'nordvpn exited with code {result.returncode}')
|
||||
return output
|
||||
|
||||
|
||||
def _parse_nordvpn_status(output: str):
|
||||
lowered = (output or '').lower()
|
||||
if 'not logged in' in lowered or 'login required' in lowered or 'please log in' in lowered:
|
||||
return 'not_logged_in'
|
||||
if 'disconnected' in lowered:
|
||||
return 'disconnected'
|
||||
if 'connecting' in lowered:
|
||||
return 'connecting'
|
||||
if 'connected' in lowered:
|
||||
return 'connected'
|
||||
if 'daemon' in lowered or 'nordvpnd' in lowered:
|
||||
return 'service_unavailable'
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _nordvpn_server_line(output: str) -> str:
|
||||
for line in (output or '').splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
low = stripped.lower()
|
||||
if low.startswith('server:') or low.startswith('country:') or low.startswith('your new ip:'):
|
||||
return stripped
|
||||
return ''
|
||||
|
||||
|
||||
def get_nordvpn_status():
|
||||
installed = is_nordvpn_cli_installed()
|
||||
status = {
|
||||
'installed': installed,
|
||||
'running': False,
|
||||
'connected': False,
|
||||
'status': 'not_installed' if not installed else 'unknown',
|
||||
'server': '',
|
||||
'raw': '',
|
||||
'last_error': '' if installed else _nordvpn_install_hint(),
|
||||
'install_hint': _nordvpn_install_hint(),
|
||||
}
|
||||
if not installed:
|
||||
return status
|
||||
try:
|
||||
output = run_nordvpn_cli('status', timeout=15)
|
||||
parsed = _parse_nordvpn_status(output)
|
||||
status.update({
|
||||
'running': parsed in ('connected', 'connecting'),
|
||||
'connected': parsed == 'connected',
|
||||
'status': parsed,
|
||||
'server': _nordvpn_server_line(output),
|
||||
'raw': output,
|
||||
'last_error': '',
|
||||
})
|
||||
except Exception as e:
|
||||
status['last_error'] = str(e)
|
||||
lowered = str(e).lower()
|
||||
if 'not logged in' in lowered or 'login' in lowered:
|
||||
status['status'] = 'not_logged_in'
|
||||
elif 'daemon' in lowered or 'nordvpnd' in lowered or 'service' in lowered or 'permission' in lowered:
|
||||
status['status'] = 'service_unavailable'
|
||||
else:
|
||||
status['status'] = 'error'
|
||||
return status
|
||||
|
||||
|
||||
def enable_nordvpn(country: str = '', city: str = ''):
|
||||
current = get_nordvpn_status()
|
||||
if not current.get('installed'):
|
||||
raise RuntimeError(current.get('install_hint') or _nordvpn_install_hint())
|
||||
if current.get('status') == 'not_logged_in':
|
||||
raise RuntimeError(
|
||||
'NordVPN is not logged in. Run `nordvpn login` on this host (browser flow), then retry.'
|
||||
)
|
||||
args = ['connect']
|
||||
country = (country or '').strip()
|
||||
city = (city or '').strip()
|
||||
if country and city:
|
||||
args.extend([country, city])
|
||||
elif country:
|
||||
args.append(country)
|
||||
run_nordvpn_cli(*args, timeout=45)
|
||||
time.sleep(1)
|
||||
return get_nordvpn_status()
|
||||
|
||||
|
||||
def disable_nordvpn():
|
||||
current = get_nordvpn_status()
|
||||
if not current.get('installed'):
|
||||
raise RuntimeError(current.get('install_hint') or _nordvpn_install_hint())
|
||||
run_nordvpn_cli('disconnect', timeout=30)
|
||||
time.sleep(0.5)
|
||||
return get_nordvpn_status()
|
||||
|
||||
|
||||
def get_tunnel_command_name(provider: str):
|
||||
if provider == 'cloudflare':
|
||||
return 'cloudflared.exe' if os.name == 'nt' else 'cloudflared'
|
||||
@@ -1998,6 +2145,11 @@ class ApplyUpdateRequest(BaseModel):
|
||||
allow_dirty: bool = False
|
||||
|
||||
|
||||
class NordvpnConnectRequest(BaseModel):
|
||||
country: Optional[str] = ''
|
||||
city: Optional[str] = ''
|
||||
|
||||
|
||||
# ======================== Startup ========================
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -5583,6 +5735,7 @@ async def api_tunnels_status(request: Request):
|
||||
'cloudflare': get_tunnel_status('cloudflare'),
|
||||
'ngrok': get_tunnel_status('ngrok'),
|
||||
'warp': get_warp_status(),
|
||||
'nordvpn': get_nordvpn_status(),
|
||||
}
|
||||
|
||||
|
||||
@@ -5668,6 +5821,28 @@ async def api_warp_disconnect(request: Request):
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/settings/nordvpn/connect', tags=["Settings"])
|
||||
async def api_nordvpn_connect(request: Request, req: NordvpnConnectRequest = NordvpnConnectRequest()):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await asyncio.to_thread(enable_nordvpn, req.country or '', req.city or '')
|
||||
except Exception as e:
|
||||
logger.exception("Error connecting NordVPN")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post('/api/settings/nordvpn/disconnect', tags=["Settings"])
|
||||
async def api_nordvpn_disconnect(request: Request):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
return await asyncio.to_thread(disable_nordvpn)
|
||||
except Exception as e:
|
||||
logger.exception("Error disconnecting NordVPN")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
# @app.post('/api/settings/save')
|
||||
# async def api_save_settings(request: Request, body: SaveSettingsRequest):
|
||||
# _check_admin(request)
|
||||
|
||||
Reference in New Issue
Block a user