Add public tunnels and fix protocol connection bugs

Add public tunnel management and fix connection stability issues

- Add Tunnels block to settings with Local Server, Cloudflare Quick Tunnel, and ngrok support
- Implement one-click tunnel install, enable, stop, and delete actions
- Persist tunnel PID and public URL state across panel restarts
- Improve tunnel process detection and cleanup on Windows, Linux, and Docker
- Add tunnel translations and README documentation

- Fix settings save 422 when server list is empty and Remnawave auto-create is disabled
- Fix AWG Legacy config path resolution by detecting actual wg0.conf/awg0.conf inside container
- Fix Xray client add/toggle/remove flow to use runtime API without restarting the container
- Fix WireGuard Show config calls by routing get_client_config through the unified manager adapter
This commit is contained in:
Public relations & Virtual technology committed 2026-06-19 19:28:51 +03:00
1 parent fbe5a2b6c4
commit a62f9587ef
12 files changed
+1085 -36

No files matched your search

+6
View File
@@ -96,6 +96,12 @@ Configuration panel for system parameters and preferences:
* **Simple Backup**: Effortless JSON-based export and restore of all panel data.
* **🔗 Public Sharing**:
* Generate password-protected links for users to download their configurations without panel access.
* **🌍 One-click Public Tunnels** *(new)*:
* Open the local panel to the internet from `/settings` using **Cloudflare Quick Tunnel** or **ngrok**.
* Shows the local server URL, installation state, running state, and issued public HTTPS URLs directly in the UI.
* Supports one-click install, enable, stop, and delete for panel-managed tunnel binaries.
* Persists tunnel PID/public URL state across panel restarts and can detect already running tunnel processes.
* Works on Windows, Linux, and Docker-friendly environments; `TUNNEL_BIN_DIR` and `TUNNEL_STATE_FILE` can override binary/state locations.
* **🔑 API Tokens for External Integrations** *(new)*:
* Issue bearer tokens from `/settings` for CI bots, monitoring, or any third-party service.
* Panel never stores the raw token — only its SHA-256 hash. The full value is shown **once** at creation; lose it and you must rotate.
+632 -7
View File
@@ -7,6 +7,16 @@ import hashlib
import secrets
import uuid
import asyncio
import platform
import re
import shutil
import subprocess
import tarfile
import threading
import time
import urllib.request
import zipfile
import signal
from datetime import datetime
import io
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
@@ -83,7 +93,27 @@ else:
application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json')
CURRENT_VERSION = "v1.4.3"
CURRENT_VERSION = "v1.4.4"
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application_path, 'tunnels_state.json'))
class TunnelRuntime:
def __init__(self):
self.process = None
self.pid = None
self.public_url = ''
self.last_error = ''
self.started_at = None
self.output = []
TUNNEL_RUNTIMES = {
'cloudflare': TunnelRuntime(),
'ngrok': TunnelRuntime(),
}
TUNNEL_LOCK = threading.Lock()
TUNNEL_URL_RE = re.compile(r'https://[^\s"\']+')
# ======================== Translations ========================
@@ -166,6 +196,529 @@ def get_ssh(server):
)
def get_panel_local_url(request: Optional[Request] = None):
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {})
scheme = 'https' if ssl_conf.get('enabled') else 'http'
host = '127.0.0.1'
port = ssl_conf.get('panel_port', 5000) or 5000
if request:
scheme = request.url.scheme or scheme
host = request.url.hostname or host
port = request.url.port or port
return f"{scheme}://{host}:{port}"
def get_panel_tunnel_target_url():
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {})
scheme = 'https' if ssl_conf.get('enabled') else 'http'
port = ssl_conf.get('panel_port', 5000) or 5000
return f"{scheme}://127.0.0.1:{port}"
def get_tunnel_command_name(provider: str):
if provider == 'cloudflare':
return 'cloudflared.exe' if os.name == 'nt' else 'cloudflared'
if provider == 'ngrok':
return 'ngrok.exe' if os.name == 'nt' else 'ngrok'
raise ValueError('Unsupported tunnel provider')
def get_tunnel_binary_path(provider: str):
return os.path.join(BIN_DIR, get_tunnel_command_name(provider))
def find_tunnel_binary(provider: str):
bundled = get_tunnel_binary_path(provider)
if os.path.exists(bundled):
return bundled
return shutil.which(get_tunnel_command_name(provider))
def is_tunnel_installed(provider: str):
return bool(find_tunnel_binary(provider))
def load_tunnel_state():
if not os.path.exists(TUNNEL_STATE_FILE):
return {}
try:
with open(TUNNEL_STATE_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Failed to load tunnel state: {e}")
return {}
def save_tunnel_state(state):
with open(TUNNEL_STATE_FILE, 'w', encoding='utf-8') as f:
json.dump(state, f, indent=2, ensure_ascii=False)
def update_tunnel_state(provider: str, **updates):
state = load_tunnel_state()
provider_state = state.get(provider, {})
provider_state.update(updates)
state[provider] = provider_state
save_tunnel_state(state)
def clear_tunnel_state(provider: str):
state = load_tunnel_state()
if provider in state:
state.pop(provider)
save_tunnel_state(state)
def pid_is_running(pid):
if not pid:
return False
try:
os.kill(int(pid), 0)
return True
except OSError:
return False
except Exception:
return False
def kill_pid(pid):
if not pid:
return
if os.name == 'nt':
subprocess.run(['taskkill', '/PID', str(pid), '/T', '/F'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if not pid_is_running(pid):
return
time.sleep(0.2)
else:
try:
os.kill(int(pid), signal.SIGTERM)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if not pid_is_running(pid):
return
time.sleep(0.2)
if pid_is_running(pid):
os.kill(int(pid), signal.SIGKILL)
except ProcessLookupError:
return
def wait_for_path_release(path, seconds: int = 5):
if os.name != 'nt':
return
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
try:
if not os.path.exists(path):
return
with open(path, 'ab'):
return
except PermissionError:
time.sleep(0.25)
def find_running_tunnel_pids_in_proc(provider: str, binary_path: str = ''):
proc_dir = '/proc'
if os.name == 'nt' or not os.path.isdir(proc_dir):
return []
command_name = get_tunnel_command_name(provider).lower()
command_base = command_name.replace('.exe', '')
markers = ['tunnel', '--url'] if provider == 'cloudflare' else ['http']
expected_binary = os.path.abspath(binary_path).lower() if binary_path else ''
pids = []
try:
for entry in os.listdir(proc_dir):
if not entry.isdigit():
continue
pid = int(entry)
if pid == os.getpid():
continue
cmdline_path = os.path.join(proc_dir, entry, 'cmdline')
try:
with open(cmdline_path, 'rb') as f:
cmdline = f.read().replace(b'\x00', b' ').decode('utf-8', errors='replace').strip()
except Exception:
continue
lowered = cmdline.lower()
exe_path = ''
try:
exe_path = os.path.abspath(os.readlink(os.path.join(proc_dir, entry, 'exe'))).lower()
except Exception:
pass
path_matches = bool(expected_binary and exe_path == expected_binary)
command_matches = (command_name in lowered or command_base in lowered) and all(marker in lowered for marker in markers)
if path_matches or command_matches:
if pid_is_running(pid):
pids.append(pid)
except Exception as e:
logger.debug(f"Failed to scan /proc for running {provider} tunnel process: {e}")
return pids
def find_running_tunnel_pids(provider: str, binary_path: str = ''):
command_name = get_tunnel_command_name(provider).lower()
process_name = command_name[:-4] if command_name.endswith('.exe') else command_name
markers = ['tunnel', '--url'] if provider == 'cloudflare' else ['http']
expected_binary = os.path.normcase(os.path.abspath(binary_path or get_tunnel_binary_path(provider)))
pids = []
try:
if os.name == 'nt':
ps_script = f"Get-CimInstance Win32_Process -Filter \"name='{command_name}'\" | Select-Object ProcessId,CommandLine,ExecutablePath | ConvertTo-Json -Compress"
result = subprocess.run(['powershell', '-NoProfile', '-Command', ps_script], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5)
rows = []
if result.returncode == 0 and result.stdout.strip():
payload = json.loads(result.stdout)
if payload:
rows = payload if isinstance(payload, list) else [payload]
if not rows:
ps_script = f"Get-Process -Name '{process_name}' -ErrorAction SilentlyContinue | Select-Object Id,Path | ConvertTo-Json -Compress"
result = subprocess.run(['powershell', '-NoProfile', '-Command', ps_script], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5)
if result.returncode == 0 and result.stdout.strip():
payload = json.loads(result.stdout)
proc_rows = payload if isinstance(payload, list) else ([payload] if payload else [])
rows = [{'ProcessId': r.get('Id'), 'ExecutablePath': r.get('Path'), 'CommandLine': ''} for r in proc_rows]
for row in rows:
if not row:
continue
pid = int(row.get('ProcessId') or 0)
command_line = str(row.get('CommandLine') or '').lower()
executable = os.path.normcase(os.path.abspath(str(row.get('ExecutablePath') or ''))) if row.get('ExecutablePath') else ''
path_matches = bool(expected_binary and executable == expected_binary)
command_matches = command_name in command_line and all(marker in command_line for marker in markers)
if pid and pid != os.getpid() and (path_matches or command_matches) and pid_is_running(pid):
pids.append(pid)
if not pids and os.path.exists(expected_binary):
result = subprocess.run(['tasklist', '/FI', f'IMAGENAME eq {command_name}', '/FO', 'CSV', '/NH'], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5)
for line in result.stdout.splitlines():
match = re.match(r'"[^"]+","(\d+)"', line.strip())
if match:
pid = int(match.group(1))
if pid != os.getpid() and pid_is_running(pid):
pids.append(pid)
return list(dict.fromkeys(pids))
else:
result = subprocess.run(
['ps', '-eo', 'pid=,args='],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
)
lines = result.stdout.splitlines()
for line in lines:
text = line.strip()
lowered = text.lower()
if command_name not in lowered or not all(marker in lowered for marker in markers):
continue
pid_match = re.match(r'\s*(\d+)\s+', text)
if pid_match:
pid = int(pid_match.group(1))
if pid != os.getpid() and pid_is_running(pid):
pids.append(pid)
except Exception as e:
logger.debug(f"Failed to scan running {provider} tunnel process: {e}")
pids.extend(find_running_tunnel_pids_in_proc(provider, expected_binary))
return list(dict.fromkeys(pids))
def find_running_tunnel_pid(provider: str):
pids = find_running_tunnel_pids(provider)
return pids[0] if pids else None
def kill_tunnel_processes(provider: str, binary_path: str = '', include_all_by_name: bool = False):
pids = find_running_tunnel_pids(provider, binary_path)
for pid in pids:
kill_pid(pid)
if os.name == 'nt' and include_all_by_name:
subprocess.run(['taskkill', '/IM', get_tunnel_command_name(provider), '/T', '/F'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
return pids
def get_tunnel_download(provider: str):
system = platform.system().lower()
machine = platform.machine().lower()
if provider == 'cloudflare':
if system == 'windows':
arch = 'amd64' if machine in ('amd64', 'x86_64') else '386'
return f'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-{arch}.exe', 'exe'
if system == 'darwin':
arch = 'arm64' if 'arm' in machine else 'amd64'
return f'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-{arch}.tgz', 'tgz'
arch = 'arm64' if 'aarch64' in machine or 'arm64' in machine else 'amd64'
return f'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}', 'bin'
if provider == 'ngrok':
if system == 'windows':
os_part = 'windows'
ext = 'zip'
elif system == 'darwin':
os_part = 'darwin'
ext = 'zip'
else:
os_part = 'linux'
ext = 'tgz'
arch = 'arm64' if 'aarch64' in machine or 'arm64' in machine else 'amd64'
return f'https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-{os_part}-{arch}.{ext}', ext
raise ValueError('Unsupported tunnel provider')
def install_tunnel_binary(provider: str):
if is_tunnel_installed(provider):
return find_tunnel_binary(provider)
os.makedirs(BIN_DIR, exist_ok=True)
url, archive_type = get_tunnel_download(provider)
target = get_tunnel_binary_path(provider)
tmp_path = os.path.join(BIN_DIR, f'{provider}.download')
logger.info(f"Downloading {provider} tunnel binary from {url}")
urllib.request.urlretrieve(url, tmp_path)
if archive_type in ('bin', 'exe'):
shutil.move(tmp_path, target)
elif archive_type == 'zip':
with zipfile.ZipFile(tmp_path) as zf:
member = next((m for m in zf.namelist() if os.path.basename(m).lower() == get_tunnel_command_name(provider).lower()), None)
if not member:
raise RuntimeError(f'{provider} binary not found in downloaded archive')
with zf.open(member) as src, open(target, 'wb') as dst:
shutil.copyfileobj(src, dst)
os.remove(tmp_path)
elif archive_type == 'tgz':
with tarfile.open(tmp_path, 'r:gz') as tf:
member = next((m for m in tf.getmembers() if os.path.basename(m.name).lower() == get_tunnel_command_name(provider).lower()), None)
if not member:
raise RuntimeError(f'{provider} binary not found in downloaded archive')
extracted = tf.extractfile(member)
if not extracted:
raise RuntimeError(f'Cannot extract {provider} binary')
with extracted as src, open(target, 'wb') as dst:
shutil.copyfileobj(src, dst)
os.remove(tmp_path)
else:
raise RuntimeError(f'Unsupported download format: {archive_type}')
if os.name != 'nt':
os.chmod(target, 0o755)
return target
def get_tunnel_public_urls(provider: str, runtime: TunnelRuntime):
urls = []
if runtime.public_url:
urls.append(runtime.public_url)
state_url = load_tunnel_state().get(provider, {}).get('public_url')
if state_url:
urls.append(state_url)
if provider == 'ngrok' and runtime.process and runtime.process.poll() is None:
try:
with urllib.request.urlopen('http://127.0.0.1:4040/api/tunnels', timeout=1) as resp:
payload = json.loads(resp.read().decode('utf-8'))
for item in payload.get('tunnels', []):
public_url = item.get('public_url')
if public_url and public_url.startswith('https://'):
urls.append(public_url)
except Exception:
pass
return list(dict.fromkeys(urls))
def watch_tunnel_output(provider: str):
runtime = TUNNEL_RUNTIMES[provider]
process = runtime.process
if not process or not process.stdout:
return
try:
for line in iter(process.stdout.readline, ''):
if not line:
break
text = line.strip()
with TUNNEL_LOCK:
runtime.output.append(text)
runtime.output = runtime.output[-60:]
match = TUNNEL_URL_RE.search(text)
if match:
runtime.public_url = match.group(0).rstrip('.,')
update_tunnel_state(provider, public_url=runtime.public_url)
except Exception as e:
with TUNNEL_LOCK:
runtime.last_error = str(e)
def build_tunnel_command(provider: str, binary: str, local_url: str, authtoken: str = ''):
if provider == 'cloudflare':
return [binary, 'tunnel', '--url', local_url, '--no-autoupdate']
if provider == 'ngrok':
cmd = [binary, 'http', local_url, '--log=stdout']
return cmd
raise ValueError('Unsupported tunnel provider')
def start_tunnel(provider: str, local_url: str, authtoken: str = ''):
binary = find_tunnel_binary(provider)
if not binary:
raise RuntimeError(f'{provider} is not installed')
runtime = TUNNEL_RUNTIMES[provider]
with TUNNEL_LOCK:
if runtime.process and runtime.process.poll() is None:
return runtime
runtime.public_url = ''
runtime.last_error = ''
runtime.output = []
runtime.started_at = datetime.now().isoformat()
creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
env = os.environ.copy()
if provider == 'ngrok' and authtoken:
env['NGROK_AUTHTOKEN'] = authtoken
runtime.process = subprocess.Popen(
build_tunnel_command(provider, binary, local_url, authtoken),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace',
cwd=application_path,
env=env,
creationflags=creationflags,
)
runtime.pid = runtime.process.pid
update_tunnel_state(
provider,
pid=runtime.pid,
public_url='',
started_at=runtime.started_at,
binary=os.path.abspath(binary),
)
threading.Thread(target=watch_tunnel_output, args=(provider,), daemon=True).start()
return runtime
def stop_tunnel(provider: str):
runtime = TUNNEL_RUNTIMES[provider]
process = runtime.process
state = load_tunnel_state().get(provider, {})
pids_to_stop = []
if process and process.poll() is None:
pids_to_stop.append(process.pid)
try:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
except Exception as e:
with TUNNEL_LOCK:
runtime.last_error = str(e)
raise
if state.get('pid') and pid_is_running(state.get('pid')):
pids_to_stop.append(state.get('pid'))
pids_to_stop.extend(find_running_tunnel_pids(provider, state.get('binary') or get_tunnel_binary_path(provider)))
for pid in dict.fromkeys(pids_to_stop):
if pid_is_running(pid):
kill_pid(pid)
with TUNNEL_LOCK:
runtime.process = None
runtime.pid = None
runtime.public_url = ''
runtime.started_at = None
runtime.last_error = ''
clear_tunnel_state(provider)
return runtime
def delete_tunnel_binary(provider: str):
stop_tunnel(provider)
bundled = get_tunnel_binary_path(provider)
if os.path.exists(bundled):
wait_for_path_release(bundled, seconds=8)
try:
os.remove(bundled)
except PermissionError:
killed = kill_tunnel_processes(provider, bundled, include_all_by_name=True)
if killed:
wait_for_path_release(bundled, seconds=8)
try:
os.remove(bundled)
except PermissionError:
raise RuntimeError('Cannot delete tunnel binary because Windows still keeps it locked. All detected cloudflared/ngrok processes were stopped; close any antivirus scan, terminal, or external process using the file and try again.')
elif shutil.which(get_tunnel_command_name(provider)):
raise RuntimeError('This tunnel binary is installed system-wide. Remove it from PATH manually or use the panel-managed installation.')
tmp_path = os.path.join(BIN_DIR, f'{provider}.download')
if os.path.exists(tmp_path):
os.remove(tmp_path)
runtime = TUNNEL_RUNTIMES[provider]
with TUNNEL_LOCK:
runtime.output = []
clear_tunnel_state(provider)
return runtime
def get_tunnel_status(provider: str):
runtime = TUNNEL_RUNTIMES[provider]
state = load_tunnel_state().get(provider, {})
running = bool(runtime.process and runtime.process.poll() is None)
if not running and state.get('pid'):
running = pid_is_running(state.get('pid'))
if running:
runtime.pid = state.get('pid')
runtime.started_at = runtime.started_at or state.get('started_at')
runtime.public_url = runtime.public_url or state.get('public_url', '')
else:
clear_tunnel_state(provider)
state = {}
if not running and is_tunnel_installed(provider):
found_pid = find_running_tunnel_pid(provider)
if found_pid:
running = True
runtime.pid = found_pid
runtime.started_at = runtime.started_at or datetime.now().isoformat()
update_tunnel_state(
provider,
pid=found_pid,
public_url=runtime.public_url,
started_at=runtime.started_at,
binary=os.path.abspath(find_tunnel_binary(provider) or ''),
)
state = load_tunnel_state().get(provider, {})
if runtime.process and not running and not runtime.last_error and runtime.output:
runtime.last_error = runtime.output[-1]
urls = get_tunnel_public_urls(provider, runtime)
return {
'installed': is_tunnel_installed(provider),
'running': running,
'public_url': urls[0] if urls else '',
'public_urls': urls,
'last_error': runtime.last_error,
'started_at': runtime.started_at or state.get('started_at'),
}
async def wait_for_tunnel_url(provider: str, seconds: int = 20):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
status = get_tunnel_status(provider)
if status.get('public_url') or not status.get('running'):
return status
await asyncio.sleep(0.5)
return get_tunnel_status(provider)
def get_protocol_manager(ssh, protocol: str):
if protocol == 'xray':
from managers.xray_manager import XrayManager
@@ -805,6 +1358,10 @@ class ShareAuthRequest(BaseModel):
password: str
class TunnelStartRequest(BaseModel):
authtoken: Optional[str] = None
# ======================== Startup ========================
@app.on_event("startup")
@@ -1985,10 +2542,7 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, req.protocol)
if req.protocol == 'wireguard':
config = manager.get_client_config(req.client_id, server['host'])
else:
config = manager.get_client_config(req.protocol, req.client_id, server['host'], port)
config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
@@ -2440,7 +2994,7 @@ async def api_share_config(token: str, connection_id: str, request: Request):
ssh.connect()
# Use appropriate manager for the protocol
manager = get_protocol_manager(ssh, conn['protocol'])
config = manager.get_client_config(conn['protocol'], conn['client_id'], server['host'], port)
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
@@ -2472,7 +3026,7 @@ async def api_my_connection_config(request: Request, connection_id: str):
ssh.connect()
# Use appropriate manager for the protocol (fixes Telemt/Xray not working for users)
manager = get_protocol_manager(ssh, conn['protocol'])
config = manager.get_client_config(conn['protocol'], conn['client_id'], server['host'], port)
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
@@ -2498,6 +3052,77 @@ async def api_get_settings(request: Request):
return data.get('settings', {})
@app.get('/api/settings/tunnels/status', tags=["Settings"])
async def api_tunnels_status(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
return {
'local_server': get_panel_local_url(request),
'cloudflare': get_tunnel_status('cloudflare'),
'ngrok': get_tunnel_status('ngrok'),
}
@app.post('/api/settings/tunnels/{provider}/install', tags=["Settings"])
async def api_tunnel_install(request: Request, provider: str):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if provider not in TUNNEL_RUNTIMES:
return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400)
try:
await asyncio.to_thread(install_tunnel_binary, provider)
return get_tunnel_status(provider)
except Exception as e:
logger.exception(f"Error installing {provider} tunnel")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/settings/tunnels/{provider}/start', tags=["Settings"])
async def api_tunnel_start(request: Request, provider: str, payload: TunnelStartRequest = TunnelStartRequest()):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if provider not in TUNNEL_RUNTIMES:
return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400)
try:
local_url = get_panel_tunnel_target_url()
await asyncio.to_thread(start_tunnel, provider, local_url, payload.authtoken or '')
status = await wait_for_tunnel_url(provider)
if not status.get('running'):
return JSONResponse({'error': status.get('last_error') or 'Tunnel process stopped'}, status_code=500)
return status
except Exception as e:
logger.exception(f"Error starting {provider} tunnel")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/settings/tunnels/{provider}/stop', tags=["Settings"])
async def api_tunnel_stop(request: Request, provider: str):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if provider not in TUNNEL_RUNTIMES:
return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400)
try:
await asyncio.to_thread(stop_tunnel, provider)
return get_tunnel_status(provider)
except Exception as e:
logger.exception(f"Error stopping {provider} tunnel")
return JSONResponse({'error': str(e)}, status_code=500)
@app.delete('/api/settings/tunnels/{provider}', tags=["Settings"])
async def api_tunnel_delete(request: Request, provider: str):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if provider not in TUNNEL_RUNTIMES:
return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400)
try:
await asyncio.to_thread(delete_tunnel_binary, provider)
return get_tunnel_status(provider)
except Exception as e:
logger.exception(f"Error deleting {provider} tunnel")
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)
+1 -1
View File
@@ -2,7 +2,7 @@ version: '3.8'
services:
amnezia_panel:
image: prvtpro/amnezia-panel:1.4.3
image: prvtpro/amnezia-panel:1.4.4
container_name: amnezia_panel
ports:
+35 -9
View File
@@ -129,6 +129,30 @@ class AWGManager:
# Both AWG and AWG2 use awg0.conf
return '/opt/amnezia/awg/awg0.conf'
def _config_path_candidates(self, protocol_type):
"""Return possible config paths, ordered by the expected path first."""
expected = self._config_path(protocol_type)
fallback = '/opt/amnezia/awg/awg0.conf' if protocol_type == self.AWG_LEGACY else '/opt/amnezia/awg/wg0.conf'
return [expected, fallback]
def _resolve_config_path(self, protocol_type):
"""Resolve the real config path in existing containers.
AWG Legacy should use wg0.conf, but some older or manually modified
installations may have a different file name. Resolve the existing file
instead of requiring users to create symlinks inside the container.
"""
container_name = self._container_name(protocol_type)
candidates = self._config_path_candidates(protocol_type)
paths = ' '.join(candidates)
script = f'for p in {paths}; do if [ -f "$p" ]; then echo "$p"; exit 0; fi; done; exit 1'
out, _, code = self.ssh.run_sudo_command(
f"docker exec -i {container_name} sh -c '{script}'"
)
if code == 0 and out.strip():
return out.strip().splitlines()[0]
return self._config_path(protocol_type)
def _wg_binary(self, protocol_type):
"""Get the wireguard binary name."""
if protocol_type == self.AWG_LEGACY:
@@ -145,8 +169,10 @@ class AWGManager:
return 'awg-quick'
def _interface_name(self, protocol_type):
def _interface_name(self, protocol_type, config_path=None):
"""Get the interface name."""
if config_path:
return os.path.splitext(os.path.basename(config_path))[0]
if protocol_type == self.AWG_LEGACY:
return 'wg0'
# AWG and AWG2 both use 'awg0' interface
@@ -571,7 +597,7 @@ tail -f /dev/null
def _get_server_config(self, protocol_type):
"""Get the server WireGuard config."""
container_name = self._container_name(protocol_type)
config_path = self._config_path(protocol_type)
config_path = self._resolve_config_path(protocol_type)
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {container_name} cat {config_path}"
@@ -583,7 +609,7 @@ tail -f /dev/null
def save_server_config(self, protocol_type, config_content):
"""Save the server WireGuard config and restart container."""
container_name = self._container_name(protocol_type)
config_path = self._config_path(protocol_type)
config_path = self._resolve_config_path(protocol_type)
# Upload new config into container via SFTP + docker cp
self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_amnz_edit_config.conf")
@@ -829,9 +855,9 @@ tail -f /dev/null
Returns the client config as a string for the .conf file.
"""
container_name = self._container_name(protocol_type)
config_path = self._config_path(protocol_type)
wg_bin = self._wg_binary(protocol_type)
iface = self._interface_name(protocol_type)
config_path = self._resolve_config_path(protocol_type)
iface = self._interface_name(protocol_type, config_path)
# Generate client keys
client_priv_key, client_pub_key = generate_wg_keypair()
@@ -1039,9 +1065,9 @@ PersistentKeepalive = 25
def toggle_client(self, protocol_type, client_id, enable):
"""Enable or disable a client by adding/removing their [Peer] from server config."""
container_name = self._container_name(protocol_type)
config_path = self._config_path(protocol_type)
wg_bin = self._wg_binary(protocol_type)
iface = self._interface_name(protocol_type)
config_path = self._resolve_config_path(protocol_type)
iface = self._interface_name(protocol_type, config_path)
if enable:
# Re-add peer to server config
@@ -1107,9 +1133,9 @@ AllowedIPs = {client_ip}/32
def remove_client(self, protocol_type, client_id):
"""Remove a client from AWG config (mirrors revokeWireGuard)."""
container_name = self._container_name(protocol_type)
config_path = self._config_path(protocol_type)
wg_bin = self._wg_binary(protocol_type)
iface = self._interface_name(protocol_type)
config_path = self._resolve_config_path(protocol_type)
iface = self._interface_name(protocol_type, config_path)
# Get current config
config = self._get_server_config(protocol_type)
+123 -17
View File
@@ -3,6 +3,7 @@ import os
import uuid
import logging
import base64
import shlex
from datetime import datetime
import urllib.parse
@@ -220,7 +221,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
"log": {"loglevel": "error"},
"stats": {},
"api": {
"services": ["StatsService", "LoggerService"],
"services": ["StatsService", "LoggerService", "HandlerService"],
"tag": "api"
},
"policy": {
@@ -236,6 +237,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
{
"port": int(port),
"protocol": "vless",
"tag": "proxy",
"settings": {
"clients": [],
"decryption": "none"
@@ -323,6 +325,9 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
return json.loads(out)
def _save_server_json(self, data):
return self._write_server_json(data, restart=True)
def _write_server_json(self, data, restart=True):
"""Write server.json into container via docker cp AND sync to host path."""
tmp_file = "/tmp/_xray_server.json"
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
@@ -333,8 +338,73 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
self.ssh.run_sudo_command(
f"cp {tmp_file} {self._config_path()} 2>/dev/null || true"
)
if restart:
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
def _get_vless_inbound(self, config):
for inbound in config.get('inbounds', []):
if inbound.get('protocol') == 'vless':
return inbound
return None
def _get_vless_inbound_tag(self, config):
inbound = self._get_vless_inbound(config)
return inbound.get('tag') if inbound else None
def _run_xray_api_json(self, subcommand, payload):
tmp_name = f"/tmp/_xray_api_{uuid.uuid4().hex}.json"
container_tmp = tmp_name
try:
self.ssh.upload_file_sudo(json.dumps(payload, indent=2), tmp_name)
_, err, code = self.ssh.run_sudo_command(
f"docker cp {tmp_name} {self.CONTAINER_NAME}:{container_tmp}"
)
if code != 0:
return False, err
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api {subcommand} "
f"-server=127.0.0.1:10085 {container_tmp}"
)
return code == 0, err or out
finally:
self.ssh.run_sudo_command(f"rm -f {tmp_name}")
self.ssh.run_sudo_command(f"docker exec -i {self.CONTAINER_NAME} rm -f {container_tmp} 2>/dev/null || true")
def _xray_api_add_user(self, config, client):
tag = self._get_vless_inbound_tag(config)
if not tag:
return False
payload = {
"inbounds": [{
"tag": tag,
"protocol": "vless",
"settings": {
"clients": [client],
"decryption": "none",
}
}]
}
ok, message = self._run_xray_api_json('adu', payload)
if not ok:
logger.warning(f"Xray API add user failed: {message}")
return False
return True
def _xray_api_remove_user(self, config, client_id):
tag = self._get_vless_inbound_tag(config)
if not tag:
return False
cmd = (
f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api rmu "
f"-server=127.0.0.1:10085 "
f"-tag={shlex.quote(tag)} {shlex.quote(client_id)}"
)
out, err, code = self.ssh.run_sudo_command(cmd)
if code != 0:
logger.warning(f"Xray API remove user failed: {err or out}")
return False
return True
def _get_meta_json(self):
"""Read protocol metadata. Supports both layouts.
@@ -428,14 +498,19 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
f"cp {tmp_file} {path} 2>/dev/null || true"
)
def _upgrade_config_for_stats(self, config):
def _upgrade_config_for_stats(self, config, restart=True):
"""Injects API and Stats blocks into older Xray configs transparently."""
dirty = False
if 'stats' not in config:
config['stats'] = {}
dirty = True
if 'api' not in config:
config['api'] = {"services": ["StatsService", "LoggerService"], "tag": "api"}
config['api'] = {"services": ["StatsService", "LoggerService", "HandlerService"], "tag": "api"}
dirty = True
else:
services = config['api'].setdefault('services', [])
if 'HandlerService' not in services:
services.append('HandlerService')
dirty = True
if 'policy' not in config:
config['policy'] = {
@@ -460,13 +535,16 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
for ib in config.get('inbounds', []):
if ib.get('protocol') == 'vless':
if not ib.get('tag'):
ib['tag'] = 'proxy'
dirty = True
for c in ib.get('settings', {}).get('clients', []):
if 'email' not in c:
c['email'] = c['id']
dirty = True
if dirty:
self._save_server_json(config)
self._write_server_json(config, restart=restart)
return dirty
def _query_xray_stats(self):
@@ -519,7 +597,7 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
if not config:
return []
self._upgrade_config_for_stats(config)
self._upgrade_config_for_stats(config, restart=False)
# Collect all client IDs currently registered in the Xray server config
xray_clients = []
@@ -607,14 +685,27 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
config = self._get_server_json()
if not config: raise RuntimeError("Xray server config not found.")
self._upgrade_config_for_stats(config, restart=False)
inbound = self._get_vless_inbound(config)
if not inbound:
raise RuntimeError("Xray VLESS inbound not found.")
# Ensure clients structure exists
clients_node = config['inbounds'][0]['settings'].setdefault('clients', [])
clients_node.append({
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
client = {
"id": client_id,
"flow": "xtls-rprx-vision",
"email": client_id
})
self._save_server_json(config)
}
if not self._xray_api_add_user(config, client):
raise RuntimeError(
"Xray runtime API is not available for hot user updates. "
"The server config was upgraded, but the container must be restarted once to enable HandlerService. "
"Restart the Xray container manually and try again."
)
clients_node.append(client)
self._write_server_json(config, restart=False)
# Update table
clients_table = self._get_clients_table()
@@ -635,20 +726,29 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
def toggle_client(self, protocol, client_id, enable):
config = self._get_server_json()
clients_node = config['inbounds'][0]['settings'].get('clients', [])
self._upgrade_config_for_stats(config, restart=False)
inbound = self._get_vless_inbound(config)
if not inbound:
raise RuntimeError("Xray VLESS inbound not found.")
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
# If toggling on and not present, we can re-add it from clientsTable
if enable:
if not any(c['id'] == client_id for c in clients_node):
clients_node.append({
client = {
"id": client_id,
"flow": "xtls-rprx-vision",
"email": client_id
})
}
if not self._xray_api_add_user(config, client):
raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.")
clients_node.append(client)
else:
config['inbounds'][0]['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
if not self._xray_api_remove_user(config, client_id):
raise RuntimeError("Xray runtime API failed to disable the client without restarting the container.")
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
self._save_server_json(config)
self._write_server_json(config, restart=False)
clients_table = self._get_clients_table()
for c in clients_table:
@@ -658,9 +758,15 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
def remove_client(self, protocol, client_id):
config = self._get_server_json()
clients_node = config['inbounds'][0]['settings'].get('clients', [])
config['inbounds'][0]['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
self._save_server_json(config)
self._upgrade_config_for_stats(config, restart=False)
inbound = self._get_vless_inbound(config)
if not inbound:
raise RuntimeError("Xray VLESS inbound not found.")
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
if not self._xray_api_remove_user(config, client_id):
raise RuntimeError("Xray runtime API failed to remove the client without restarting the container.")
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
self._write_server_json(config, restart=False)
clients_table = self._get_clients_table()
clients_table = [c for c in clients_table if c['clientId'] != client_id]
+197 -2
View File
@@ -99,6 +99,71 @@
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('api_docs_hint') }}</p>
</div>
<!-- BLOCK Tunnels -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span>
</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;">
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div>
</div>
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>Cloudflare Quick Tunnel</strong>
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>ngrok Tunnel + Authtoken</strong>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="form-group" style="margin-bottom: var(--space-sm);">
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
</div>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
</div>
<!-- BLOCK SSL/HTTPS -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🔒 {{ _('ssl_title') }}</h3>
@@ -430,6 +495,132 @@
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
});
let tunnelStatusTimer = null;
function copyElementText(id) {
const el = document.getElementById(id);
if (el) copyToClipboard(el.textContent.trim());
}
function renderTunnelUrls(provider, urls) {
const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return;
if (!urls || !urls.length) {
el.textContent = _('tunnel_no_public_url');
return;
}
el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div>
`).join('');
}
function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
badge.textContent = status.installed ? _('installed') : _('not_installed');
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = !!status.running;
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
}
async function loadTunnelStatus() {
try {
const data = await apiCall('/api/settings/tunnels/status');
if (data.local_server) document.getElementById('localServerUrl').textContent = data.local_server;
updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {});
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
}
async function enableTunnel(provider) {
const btn = document.getElementById(`${provider}TunnelBtn`);
const spinner = document.getElementById(`${provider}TunnelSpinner`);
const text = document.getElementById(`${provider}TunnelBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const current = await apiCall('/api/settings/tunnels/status');
const providerStatus = current[provider] || {};
if (!providerStatus.installed) {
text.textContent = _('installing');
await apiCall(`/api/settings/tunnels/${provider}/install`, 'POST');
}
text.textContent = _('tunnel_starting');
const body = provider === 'ngrok' ? { authtoken: document.getElementById('ngrokAuthtoken').value.trim() } : {};
const status = await apiCall(`/api/settings/tunnels/${provider}/start`, 'POST', body);
updateTunnelProvider(provider, status);
showToast(status.public_url ? _('tunnel_started') : _('tunnel_waiting_url'), 'success');
if (!tunnelStatusTimer) tunnelStatusTimer = setInterval(loadTunnelStatus, 5000);
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
}
}
async function stopTunnel(provider) {
const btn = document.getElementById(`${provider}StopBtn`);
const spinner = document.getElementById(`${provider}StopSpinner`);
const text = document.getElementById(`${provider}StopBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('tunnel_stopping');
try {
const status = await apiCall(`/api/settings/tunnels/${provider}/stop`, 'POST');
updateTunnelProvider(provider, status);
showToast(_('tunnel_stopped'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('tunnel_stop');
spinner.classList.add('hidden');
}
}
async function deleteTunnel(provider) {
if (!confirm(_('tunnel_delete_confirm'))) return;
const btn = document.getElementById(`${provider}DeleteBtn`);
const spinner = document.getElementById(`${provider}DeleteSpinner`);
const text = document.getElementById(`${provider}DeleteBtnText`);
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('deleting');
try {
const status = await apiCall(`/api/settings/tunnels/${provider}`, 'DELETE');
updateTunnelProvider(provider, status);
showToast(_('tunnel_deleted'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('tunnel_delete');
spinner.classList.add('hidden');
}
}
async function syncRemnawaveNow() {
const btn = document.getElementById('syncNowBtn');
const text = document.getElementById('syncNowBtnText');
@@ -505,7 +696,7 @@
remnawave_sync: syncForm.remnawave_sync.checked,
remnawave_sync_users: syncForm.remnawave_sync_users.checked,
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value),
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
remnawave_protocol: syncForm.remnawave_protocol.value
};
@@ -750,6 +941,10 @@
}
}
document.addEventListener('DOMContentLoaded', loadTokens);
document.addEventListener('DOMContentLoaded', () => {
loadTokens();
loadTunnelStatus();
tunnelStatusTimer = setInterval(loadTunnelStatus, 10000);
});
</script>
{% endblock %}
+18
View File
@@ -15,6 +15,7 @@
"run": "Running",
"stop": "Stopped",
"not_installed": "Not installed",
"installed": "Installed",
"add_server": "Add Server",
"no_servers": "No servers added",
"name": "Name",
@@ -188,6 +189,23 @@
"bot_hint": "After changing the token, save the settings — the bot will restart automatically. Users must have a Telegram ID in their profile.",
"api_docs_title": "🔌 API Documentation",
"api_docs_hint": "Use these interfaces to explore the panel's API capabilities",
"tunnels_title": "Tunnels",
"local_server": "Local Server",
"tunnel_install_enable": "Install & Enable",
"tunnel_enable": "Enable Tunnel",
"tunnel_running": "Tunnel running",
"tunnel_stop": "Stop",
"tunnel_stopping": "Stopping...",
"tunnel_stopped": "Tunnel stopped",
"tunnel_delete": "Delete",
"tunnel_deleted": "Tunnel deleted",
"tunnel_delete_confirm": "Stop and delete this panel-managed tunnel binary?",
"tunnel_starting": "Starting tunnel...",
"tunnel_started": "Tunnel started",
"tunnel_waiting_url": "Tunnel started. Waiting for public URL...",
"tunnel_no_public_url": "Public URL will appear here after the tunnel starts.",
"tunnels_hint": "Quick tunnels expose this local panel to the internet. Keep admin credentials strong and share public URLs only with trusted users.",
"ngrok_authtoken_placeholder": "ngrok authtoken (optional for some accounts)",
"import_users_title": "📤 Import Users",
"import_source_label": "Import / Sync Source",
"remnawave_url_label": "Remnawave URL",
+18
View File
@@ -15,6 +15,7 @@
"run": "در حال اجرا",
"stop": "متوقف شده",
"not_installed": "نصب نشده",
"installed": "نصب شده",
"add_server": "افزودن سرور",
"no_servers": "هیچ سروری اضافه نشده است",
"name": "نام",
@@ -186,6 +187,23 @@
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.",
"api_docs_title": "🔌 مستندات API",
"api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید",
"tunnels_title": "Tunnels",
"local_server": "سرور محلی",
"tunnel_install_enable": "نصب و فعال‌سازی",
"tunnel_enable": "فعال‌سازی تونل",
"tunnel_running": "تونل در حال اجرا است",
"tunnel_stop": "توقف",
"tunnel_stopping": "در حال توقف...",
"tunnel_stopped": "تونل متوقف شد",
"tunnel_delete": "حذف",
"tunnel_deleted": "تونل حذف شد",
"tunnel_delete_confirm": "این فایل اجرایی تونل مدیریت‌شده توسط پنل متوقف و حذف شود؟",
"tunnel_starting": "در حال شروع تونل...",
"tunnel_started": "تونل شروع شد",
"tunnel_waiting_url": "تونل شروع شد. در انتظار آدرس عمومی...",
"tunnel_no_public_url": "آدرس عمومی پس از شروع تونل اینجا نمایش داده می‌شود.",
"tunnels_hint": "تونل‌های سریع این پنل محلی را در اینترنت منتشر می‌کنند. رمز مدیر را قوی نگه دارید و آدرس‌های عمومی را فقط با کاربران مورد اعتماد به اشتراک بگذارید.",
"ngrok_authtoken_placeholder": "ngrok authtoken (برای برخی حساب‌ها اختیاری است)",
"import_users_title": "📤 وارد کردن کاربران",
"import_source_label": "منبع وارد کردن / همگام‌سازی",
"remnawave_url_label": "آدرس Remnawave",
+18
View File
@@ -15,6 +15,7 @@
"run": "En cours",
"stop": "Arrêté",
"not_installed": "Pas installé",
"installed": "Installé",
"add_server": "Ajouter un serveur",
"no_servers": "Aucun serveur ajouté",
"name": "Nom",
@@ -186,6 +187,23 @@
"bot_hint": "Sauvegardez après modification du token. Le bot redémarrera.",
"api_docs_title": "🔌 Documentation API",
"api_docs_hint": "Utilisez ces interfaces pour explorer l'API",
"tunnels_title": "Tunnels",
"local_server": "Serveur local",
"tunnel_install_enable": "Installer et activer",
"tunnel_enable": "Activer le tunnel",
"tunnel_running": "Tunnel en cours",
"tunnel_stop": "Arrêter",
"tunnel_stopping": "Arrêt...",
"tunnel_stopped": "Tunnel arrêté",
"tunnel_delete": "Supprimer",
"tunnel_deleted": "Tunnel supprimé",
"tunnel_delete_confirm": "Arrêter et supprimer ce binaire de tunnel géré par le panneau ?",
"tunnel_starting": "Démarrage du tunnel...",
"tunnel_started": "Tunnel démarré",
"tunnel_waiting_url": "Tunnel démarré. En attente de l'URL publique...",
"tunnel_no_public_url": "L'URL publique apparaîtra ici après le démarrage du tunnel.",
"tunnels_hint": "Les tunnels rapides exposent ce panneau local sur Internet. Gardez des identifiants administrateur robustes et partagez les URL publiques uniquement avec des utilisateurs de confiance.",
"ngrok_authtoken_placeholder": "ngrok authtoken (optionnel pour certains comptes)",
"import_users_title": "📤 Import Utilisateurs",
"import_source_label": "Source Import / Sync",
"remnawave_url_label": "URL Remnawave",
+18
View File
@@ -15,6 +15,7 @@
"run": "Работает",
"stop": "Остановлен",
"not_installed": "Не установлен",
"installed": "Установлен",
"add_server": "Добавить сервер",
"no_servers": "Серверы не добавлены",
"name": "Название",
@@ -188,6 +189,23 @@
"bot_hint": "После изменения токена сохраните настройки — бот перезапустится автоматически. Пользователи должны иметь Telegram ID в профиле.",
"api_docs_title": "🔌 API Документация",
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
"tunnels_title": "Tunnels",
"local_server": "Local Server",
"tunnel_install_enable": "Install & Enable",
"tunnel_enable": "Enable Tunnel",
"tunnel_running": "Туннель запущен",
"tunnel_stop": "Остановить",
"tunnel_stopping": "Остановка...",
"tunnel_stopped": "Туннель остановлен",
"tunnel_delete": "Удалить",
"tunnel_deleted": "Туннель удалён",
"tunnel_delete_confirm": "Остановить и удалить бинарный файл туннеля, установленный панелью?",
"tunnel_starting": "Запуск туннеля...",
"tunnel_started": "Туннель запущен",
"tunnel_waiting_url": "Туннель запущен. Ждем публичный адрес...",
"tunnel_no_public_url": "Публичный адрес появится здесь после запуска туннеля.",
"tunnels_hint": "Быстрые туннели публикуют локальную панель в интернете. Используйте надежный пароль администратора и передавайте публичные адреса только доверенным пользователям.",
"ngrok_authtoken_placeholder": "ngrok authtoken (для некоторых аккаунтов необязательно)",
"import_users_title": "📤 Импорт пользователей",
"import_source_label": "Источник импорта / Синхронизации",
"remnawave_url_label": "Remnawave URL",
+18
View File
@@ -15,6 +15,7 @@
"run": "运行中",
"stop": "已停止",
"not_installed": "未安装",
"installed": "已安装",
"add_server": "添加服务器",
"no_servers": "尚未添加服务器",
"name": "名称",
@@ -186,6 +187,23 @@
"bot_hint": "修改 Token 后请保存,机器人将自动重启。用户需在个人资料中填写 Telegram ID。",
"api_docs_title": "🔌 API 文档",
"api_docs_hint": "使用这些接口了解面板功能",
"tunnels_title": "Tunnels",
"local_server": "本地服务器",
"tunnel_install_enable": "安装并启用",
"tunnel_enable": "启用隧道",
"tunnel_running": "隧道运行中",
"tunnel_stop": "停止",
"tunnel_stopping": "正在停止...",
"tunnel_stopped": "隧道已停止",
"tunnel_delete": "删除",
"tunnel_deleted": "隧道已删除",
"tunnel_delete_confirm": "停止并删除由面板管理的隧道二进制文件?",
"tunnel_starting": "正在启动隧道...",
"tunnel_started": "隧道已启动",
"tunnel_waiting_url": "隧道已启动,正在等待公网地址...",
"tunnel_no_public_url": "隧道启动后公网地址会显示在这里。",
"tunnels_hint": "快速隧道会将本地面板暴露到互联网。请使用强管理员密码,并仅与可信用户共享公网地址。",
"ngrok_authtoken_placeholder": "ngrok authtoken(某些账号可选)",
"import_users_title": "📤 导入用户",
"import_source_label": "导入 / 同步源",
"remnawave_url_label": "Remnawave 地址",
+1
View File
@@ -0,0 +1 @@
{}