mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-10-23 03:37:36 +09:00
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:
1 parent
fbe5a2b6c4
commit
a62f9587ef
12 files changed
+1093
-44
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+125
-19
@@ -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,7 +338,72 @@ ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||
self.ssh.run_sudo_command(
|
||||
f"cp {tmp_file} {self._config_path()} 2>/dev/null || true"
|
||||
)
|
||||
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
||||
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,15 +498,20 @@ 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'] = {
|
||||
"levels": {"0": {"statsUserUplink": True, "statsUserDownlink": True}},
|
||||
@@ -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]
|
||||
|
||||
+198
-3
@@ -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 %}
|
||||
{% endblock %}
|
||||
+19
-1
@@ -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",
|
||||
@@ -346,4 +364,4 @@
|
||||
"update_available": "New version available",
|
||||
"download_update": "Download update",
|
||||
"up_to_date": "You have the latest version"
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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",
|
||||
@@ -314,4 +332,4 @@
|
||||
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint."
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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",
|
||||
@@ -314,4 +332,4 @@
|
||||
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint."
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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",
|
||||
@@ -346,4 +364,4 @@
|
||||
"update_available": "Доступна новая версия",
|
||||
"download_update": "Скачать обновление",
|
||||
"up_to_date": "У вас установлена последняя версия"
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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 地址",
|
||||
@@ -314,4 +332,4 @@
|
||||
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
Reference in new issue
Block a user