Add Amnezia Web Panel source with PostgreSQL 17 storage.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import re
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from .ssh_manager import SSHManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TelemtManager:
|
||||
CONTAINER_NAME = "telemt"
|
||||
API_URL = "http://127.0.0.1:9091"
|
||||
|
||||
def __init__(self, ssh_manager: SSHManager, protocol='telemt'):
|
||||
self.ssh = ssh_manager
|
||||
self.protocol = protocol or 'telemt'
|
||||
self.instance = self._instance_index(self.protocol)
|
||||
self.container_name = self._container_name(self.protocol)
|
||||
self.remote_dir = self._remote_dir(self.protocol)
|
||||
|
||||
def _instance_index(self, protocol):
|
||||
parts = str(protocol or '').split('__', 1)
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
return max(1, int(parts[1]))
|
||||
except ValueError:
|
||||
return 1
|
||||
return 1
|
||||
|
||||
def _container_name(self, protocol=None):
|
||||
idx = self._instance_index(protocol or self.protocol)
|
||||
base_name = self.CONTAINER_NAME
|
||||
return base_name if idx <= 1 else f'{base_name}-{idx}'
|
||||
|
||||
def _remote_dir(self, protocol=None):
|
||||
idx = self._instance_index(protocol or self.protocol)
|
||||
return '/opt/amnezia/telemt' if idx <= 1 else f'/opt/amnezia/telemt-{idx}'
|
||||
|
||||
def _config_path(self):
|
||||
return f'{self.remote_dir}/config.toml'
|
||||
|
||||
def _api_host_ports(self):
|
||||
# Host API mappings are only for diagnostics; panel talks via docker exec.
|
||||
# Keep first instance backward-compatible, avoid 9090/9091 collisions later.
|
||||
if self.instance <= 1:
|
||||
return 9090, 9091
|
||||
base = 9090 + (self.instance * 10)
|
||||
return base, base + 1
|
||||
|
||||
def _api_request(self, method, path, data=None):
|
||||
"""Execute a curl request inside the docker container."""
|
||||
cmd = f"docker exec {self.container_name} curl -s -X {method} {self.API_URL}{path}"
|
||||
if data:
|
||||
js_data = json.dumps(data).replace('"', '\\"')
|
||||
cmd += f" -H 'Content-Type: application/json' -d \"{js_data}\""
|
||||
|
||||
out, err, code = self.ssh.run_sudo_command(cmd)
|
||||
if code != 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def check_docker_installed(self):
|
||||
out, _, _ = self.ssh.run_command("docker --version 2>/dev/null")
|
||||
return bool(out.strip())
|
||||
|
||||
def check_protocol_installed(self):
|
||||
out, _, _ = self.ssh.run_command(f"docker ps -a --filter name=^{self.container_name}$ --format '{{{{.Names}}}}'")
|
||||
return out.strip() == self.container_name
|
||||
|
||||
def get_server_status(self, protocol_type):
|
||||
exists = self.check_protocol_installed()
|
||||
out, _, _ = self.ssh.run_command(f"docker inspect -f '{{{{.State.Running}}}}' {self.container_name} 2>/dev/null")
|
||||
is_running = out.strip().lower() == 'true'
|
||||
|
||||
status = {
|
||||
'container_exists': exists,
|
||||
'container_running': is_running,
|
||||
}
|
||||
|
||||
if is_running:
|
||||
# get external docker port mapping for 443
|
||||
out, _, _ = self.ssh.run_command(f"docker port {self.container_name} 443 2>/dev/null")
|
||||
if out:
|
||||
port = out.split(':')[-1].strip()
|
||||
status['port'] = port
|
||||
else:
|
||||
status['port'] = None
|
||||
|
||||
config = self._get_server_config()
|
||||
status['awg_params'] = self._parse_telemt_params(config)
|
||||
|
||||
# Count connections from API
|
||||
clients = self.get_clients(protocol_type)
|
||||
status['clients_count'] = len(clients)
|
||||
|
||||
return status
|
||||
|
||||
def _ensure_docker_compose(self):
|
||||
"""Make sure `docker compose` is available, installing the plugin if needed.
|
||||
|
||||
Why: `docker-buildx-plugin` and `docker-compose-plugin` only ship in Docker's
|
||||
official apt/yum repo. When Docker was installed from distro packages
|
||||
(e.g. `docker.io` on Ubuntu), that repo is not configured and a plain
|
||||
`apt-get install docker-compose-plugin` fails. So we add the repo,
|
||||
refresh package lists, then install.
|
||||
"""
|
||||
out, _, code = self.ssh.run_command("docker compose version 2>/dev/null")
|
||||
if code == 0 and out.strip():
|
||||
return
|
||||
|
||||
script = r"""
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y || true
|
||||
apt-get install -y ca-certificates curl gnupg || exit 1
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
. /etc/os-release
|
||||
DOCKER_DISTRO="$ID"
|
||||
case "$ID" in
|
||||
linuxmint|pop|elementary|zorin) DOCKER_DISTRO="ubuntu" ;;
|
||||
kali|parrot) DOCKER_DISTRO="debian" ;;
|
||||
esac
|
||||
if [ ! -s /etc/apt/keyrings/docker.asc ]; then
|
||||
curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" -o /etc/apt/keyrings/docker.asc || exit 1
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}"
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${DOCKER_DISTRO} ${CODENAME} stable" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -y || exit 1
|
||||
apt-get install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y dnf-plugins-core || exit 1
|
||||
. /etc/os-release
|
||||
dnf config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|
||||
|| dnf config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|
||||
|| exit 1
|
||||
dnf makecache || true
|
||||
dnf install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y yum-utils || exit 1
|
||||
. /etc/os-release
|
||||
yum-config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|
||||
|| yum-config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|
||||
|| exit 1
|
||||
yum makecache || true
|
||||
yum install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||
else
|
||||
echo "Unsupported package manager" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker compose version
|
||||
"""
|
||||
out, err, code = self.ssh.run_sudo_script(script, timeout=300)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to install docker compose plugin: {err or out}")
|
||||
|
||||
def install_protocol(self, protocol_type='telemt', port='443', tls_emulation=True, tls_domain="", max_connections=0):
|
||||
results = []
|
||||
if not self.check_docker_installed():
|
||||
results.append("Installing Docker...")
|
||||
self.ssh.run_sudo_command("curl -fsSL https://get.docker.com | sh", timeout=300)
|
||||
|
||||
if self.check_protocol_installed():
|
||||
self.ssh.run_sudo_command(f"docker rm -f {self.container_name}")
|
||||
|
||||
results.append("Ensuring docker compose plugin...")
|
||||
self._ensure_docker_compose()
|
||||
|
||||
results.append("Uploading Telemt files...")
|
||||
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_telemt')
|
||||
remote_dir = self.remote_dir
|
||||
self.ssh.run_sudo_command(f"mkdir -p {remote_dir}")
|
||||
self.ssh.run_sudo_command(f"chmod 755 {remote_dir}")
|
||||
|
||||
# Read and patch config.toml
|
||||
with open(os.path.join(local_dir, 'config.toml'), 'r', encoding='utf-8') as f:
|
||||
config_content = f.read()
|
||||
|
||||
tls_emul_str = "true" if tls_emulation else "false"
|
||||
config_content = re.sub(r'tls_emulation\s*=\s*(true|false|True|False)', f'tls_emulation = {tls_emul_str}', config_content)
|
||||
|
||||
if tls_emulation and tls_domain:
|
||||
config_content = re.sub(r'tls_domain\s*=\s*".*?"', f'tls_domain = "{tls_domain}"', config_content)
|
||||
|
||||
if max_connections is not None and max_connections > 0:
|
||||
config_content = re.sub(r'max_connections\s*=\s*\d+', f'max_connections = {max_connections}', config_content)
|
||||
|
||||
# Patch public_host and public_port for links
|
||||
if "public_host =" in config_content or "# public_host =" in config_content:
|
||||
config_content = re.sub(r'#?\s*public_host\s*=\s*".*?"', f'public_host = "{self.ssh.host}"', config_content)
|
||||
else:
|
||||
config_content = config_content.replace('[general.links]', f'[general.links]\npublic_host = "{self.ssh.host}"')
|
||||
|
||||
config_content = re.sub(r'public_port\s*=\s*\d+', f'public_port = {port}', config_content)
|
||||
|
||||
# Remove default hello user
|
||||
config_content = re.sub(r'^hello\s*=\s*".*?"', '', config_content, flags=re.MULTILINE)
|
||||
|
||||
self.ssh.upload_file_sudo(config_content, f"{remote_dir}/config.toml")
|
||||
|
||||
# Patch docker-compose.yml with proper port
|
||||
with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f:
|
||||
compose_content = f.read()
|
||||
|
||||
compose_content = re.sub(r'"443:443"', f'"{port}:443"', compose_content)
|
||||
compose_content = re.sub(r'container_name:\s*telemt', f'container_name: {self.container_name}', compose_content)
|
||||
if self.instance > 1:
|
||||
api_port_9090, api_port_9091 = self._api_host_ports()
|
||||
compose_content = re.sub(r'"127\.0\.0\.1:9090:9090"', f'"127.0.0.1:{api_port_9090}:9090"', compose_content)
|
||||
compose_content = re.sub(r'"127\.0\.0\.1:9091:9091"', f'"127.0.0.1:{api_port_9091}:9091"', compose_content)
|
||||
self.ssh.upload_file_sudo(compose_content, f"{remote_dir}/docker-compose.yml")
|
||||
|
||||
# Upload Dockerfile
|
||||
with open(os.path.join(local_dir, 'Dockerfile'), 'r', encoding='utf-8') as f:
|
||||
dockerfile = f.read()
|
||||
self.ssh.upload_file_sudo(dockerfile, f"{remote_dir}/Dockerfile")
|
||||
|
||||
results.append("Starting Telemt container...")
|
||||
out, err, code = self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker compose up -d --build'", timeout=600)
|
||||
if code != 0:
|
||||
self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker-compose up -d --build'", timeout=600)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"protocol": self.protocol,
|
||||
"host": "",
|
||||
"port": port,
|
||||
"log": results
|
||||
}
|
||||
|
||||
def _get_server_config(self):
|
||||
out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}")
|
||||
if code != 0: return ""
|
||||
return out
|
||||
|
||||
def save_server_config(self, protocol_type, config_content):
|
||||
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), f"{self._config_path()}")
|
||||
# Use SIGHUP (HUP) to reload MTProxy config without restarting the process/container.
|
||||
# This keeps the traffic statistics (octets) in memory.
|
||||
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
|
||||
|
||||
def _parse_telemt_params(self, config_text):
|
||||
params = {}
|
||||
m = re.search(r'tls_emulation\s*=\s*(true|false)', config_text, re.IGNORECASE)
|
||||
if m: params['tls_emulation'] = m.group(1).lower() == 'true'
|
||||
|
||||
m = re.search(r'tls_domain\s*=\s*"([^"]+)"', config_text)
|
||||
if m: params['tls_domain'] = m.group(1)
|
||||
|
||||
m = re.search(r'max_connections\s*=\s*(\d+)', config_text)
|
||||
if m: params['max_connections'] = int(m.group(1))
|
||||
|
||||
return params
|
||||
|
||||
def remove_container(self, protocol_type=None):
|
||||
self.ssh.run_sudo_command(f"docker rm -f {self.container_name}")
|
||||
self.ssh.run_sudo_command(f"rm -rf {self.remote_dir}")
|
||||
|
||||
def get_clients(self, protocol_type):
|
||||
api_data = {}
|
||||
resp = self._api_request("GET", "/v1/users")
|
||||
if resp and resp.get('ok'):
|
||||
for u in resp.get('data', []):
|
||||
api_data[u.get('username')] = u
|
||||
|
||||
config_text = self._get_server_config()
|
||||
users = self._parse_users_from_config(config_text)
|
||||
|
||||
clients = []
|
||||
needs_update = False
|
||||
for username, secret in users.items():
|
||||
user_stats = api_data.get(username.lstrip('#').strip(), {})
|
||||
links = user_stats.get('links', {})
|
||||
tg_link = ""
|
||||
if links.get('tls'): tg_link = links['tls'][0]
|
||||
elif links.get('secure'): tg_link = links['secure'][0]
|
||||
elif links.get('classic'): tg_link = links['classic'][0]
|
||||
|
||||
enabled = not username.startswith('#')
|
||||
clean_name = username.lstrip('#').strip()
|
||||
|
||||
total_octets = user_stats.get('total_octets', 0)
|
||||
quota = user_stats.get('data_quota_bytes')
|
||||
|
||||
# AUTO-DISABLE IF QUOTA REACHED
|
||||
if enabled and quota and total_octets >= quota:
|
||||
logger.info(f"Auto-disabling client {clean_name} - quota reached: {total_octets} >= {quota}")
|
||||
# We will trigger a toggle after we finish this loop to avoid re-reading config inside loop
|
||||
enabled = False
|
||||
needs_update = True
|
||||
|
||||
clients.append({
|
||||
"clientId": clean_name,
|
||||
"clientName": clean_name,
|
||||
"enabled": enabled,
|
||||
"creationDate": "",
|
||||
"userData": {
|
||||
"clientName": clean_name,
|
||||
"token": secret,
|
||||
"tg_link": tg_link,
|
||||
"total_octets": total_octets,
|
||||
"current_connections": user_stats.get('current_connections', 0),
|
||||
"active_ips": user_stats.get('active_unique_ips', 0),
|
||||
"quota": quota,
|
||||
"expiry": user_stats.get('expiration_rfc3339')
|
||||
}
|
||||
})
|
||||
|
||||
if needs_update:
|
||||
# Re-read and update config strictly at the end
|
||||
for c in clients:
|
||||
if not c['enabled']:
|
||||
self.toggle_client(protocol_type, c['clientId'], False, restart=False)
|
||||
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
|
||||
|
||||
return clients
|
||||
|
||||
def _parse_users_from_config(self, config_text):
|
||||
users = {}
|
||||
lines = config_text.split('\n')
|
||||
in_section = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped == '[access.users]':
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and stripped.startswith('['):
|
||||
break
|
||||
if in_section and stripped:
|
||||
commented = stripped.startswith('#')
|
||||
content = stripped.lstrip('#').strip()
|
||||
if '=' in content:
|
||||
if content.lower().startswith('format:'): continue
|
||||
name, secret = content.split('=', 1)
|
||||
name = name.strip().strip('"').strip()
|
||||
secret = secret.strip().strip('"').strip()
|
||||
full_name = ("# " + name) if commented else name
|
||||
users[full_name] = secret
|
||||
return users
|
||||
|
||||
def add_client(self, protocol_type, name, host='', port='', **kwargs):
|
||||
username = re.sub(r'[^a-zA-Z0-9_.-]', '', name.replace(' ', '_'))
|
||||
if not username: username = "user_" + uuid.uuid4().hex[:8]
|
||||
|
||||
config_text = self._get_server_config()
|
||||
current_users = self._parse_users_from_config(config_text)
|
||||
idx = 1
|
||||
base_username = username
|
||||
while any(u.lstrip('#').strip() == username for u in current_users):
|
||||
username = f"{base_username}_{idx}"
|
||||
idx += 1
|
||||
|
||||
secret = kwargs.get('secret') or secrets.token_hex(16)
|
||||
|
||||
# 1. Update config file for persistence (but don't restart yet)
|
||||
config_text = self._insert_into_section(config_text, "access.users", f'{username} = "{secret}"')
|
||||
|
||||
api_payload = {
|
||||
"username": username,
|
||||
"secret": secret
|
||||
}
|
||||
|
||||
if kwargs.get('telemt_quota'):
|
||||
val = int(kwargs['telemt_quota'])
|
||||
config_text = self._insert_into_section(config_text, "access.user_data_quota", f'{username} = {val}')
|
||||
api_payload['data_quota_bytes'] = val
|
||||
|
||||
if kwargs.get('telemt_max_ips'):
|
||||
val = int(kwargs['telemt_max_ips'])
|
||||
config_text = self._insert_into_section(config_text, "access.user_max_unique_ips", f'{username} = {val}')
|
||||
api_payload['max_unique_ips'] = val
|
||||
|
||||
if kwargs.get('telemt_expiry'):
|
||||
val = kwargs['telemt_expiry']
|
||||
config_text = self._insert_into_section(config_text, "access.user_expirations", f'{username} = "{val}"')
|
||||
api_payload['expiration_rfc3339'] = val
|
||||
|
||||
if kwargs.get('user_ad_tag'):
|
||||
val = kwargs['user_ad_tag']
|
||||
config_text = self._insert_into_section(config_text, "access.user_ad_tags", f'{username} = "{val}"')
|
||||
api_payload['user_ad_tag'] = val
|
||||
|
||||
if kwargs.get('max_tcp_conns'):
|
||||
val = int(kwargs['max_tcp_conns'])
|
||||
config_text = self._insert_into_section(config_text, "access.user_max_tcp_conns", f'{username} = {val}')
|
||||
api_payload['max_tcp_conns'] = val
|
||||
|
||||
# Save config to host
|
||||
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
|
||||
|
||||
# 2. Call API for immediate effect
|
||||
self._api_request("POST", "/v1/users", data=api_payload)
|
||||
|
||||
# Fetch the official link from API (it includes TLS emulation padding like 'ee...' if enabled)
|
||||
link = self.get_client_config(protocol_type, username, host, port)
|
||||
|
||||
# Extreme fallback if API is slow or 404
|
||||
if link == "Not found":
|
||||
link = f"tg://proxy?server={host}&port={port}&secret={secret}"
|
||||
|
||||
return {
|
||||
"client_id": username,
|
||||
"config": link,
|
||||
"vpn_link": link
|
||||
}
|
||||
|
||||
def edit_client(self, protocol_type, client_id, new_params):
|
||||
"""Update existing client parameters via API and in config."""
|
||||
config_text = self._get_server_config()
|
||||
api_payload = {}
|
||||
|
||||
if 'telemt_quota' in new_params:
|
||||
val = int(new_params['telemt_quota']) if new_params['telemt_quota'] else None
|
||||
config_text = self._update_line_in_section(config_text, "access.user_data_quota", client_id, val)
|
||||
api_payload['data_quota_bytes'] = val
|
||||
|
||||
if 'telemt_max_ips' in new_params:
|
||||
val = int(new_params['telemt_max_ips']) if new_params['telemt_max_ips'] else None
|
||||
config_text = self._update_line_in_section(config_text, "access.user_max_unique_ips", client_id, val)
|
||||
api_payload['max_unique_ips'] = val
|
||||
|
||||
if 'telemt_expiry' in new_params:
|
||||
val = new_params['telemt_expiry']
|
||||
quoted_val = f'"{val}"' if val else None
|
||||
config_text = self._update_line_in_section(config_text, "access.user_expirations", client_id, quoted_val)
|
||||
api_payload['expiration_rfc3339'] = val
|
||||
|
||||
if 'secret' in new_params:
|
||||
val = new_params['secret']
|
||||
quoted_val = f'"{val}"' if val else None
|
||||
config_text = self._update_line_in_section(config_text, "access.users", client_id, quoted_val)
|
||||
api_payload['secret'] = val
|
||||
|
||||
if 'user_ad_tag' in new_params:
|
||||
val = new_params['user_ad_tag']
|
||||
quoted_val = f'"{val}"' if val else None
|
||||
config_text = self._update_line_in_section(config_text, "access.user_ad_tags", client_id, quoted_val)
|
||||
api_payload['user_ad_tag'] = val
|
||||
|
||||
if 'max_tcp_conns' in new_params:
|
||||
val = int(new_params['max_tcp_conns']) if new_params['max_tcp_conns'] else None
|
||||
config_text = self._update_line_in_section(config_text, "access.user_max_tcp_conns", client_id, val)
|
||||
api_payload['max_tcp_conns'] = val
|
||||
|
||||
# Save config to host
|
||||
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
|
||||
|
||||
# API call
|
||||
self._api_request("PATCH", f"/v1/users/{client_id}", data=api_payload)
|
||||
return {"status": "success"}
|
||||
|
||||
def _update_line_in_section(self, config_text, section_name, client_id, value):
|
||||
lines = config_text.split('\n')
|
||||
section_start = -1
|
||||
section_end = -1
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == f"[{section_name}]":
|
||||
section_start = i
|
||||
elif section_start != -1 and line.strip().startswith('['):
|
||||
section_end = i
|
||||
break
|
||||
|
||||
if section_end == -1: section_end = len(lines)
|
||||
if section_start == -1:
|
||||
if value is not None:
|
||||
lines.append(f"[{section_name}]")
|
||||
lines.append(f'{client_id} = {value}')
|
||||
lines.append("")
|
||||
return '\n'.join(lines)
|
||||
|
||||
found = False
|
||||
for i in range(section_start + 1, section_end):
|
||||
line = lines[i].strip().lstrip('#').strip()
|
||||
if line.startswith(f"{client_id} ") or line.startswith(f"{client_id}="):
|
||||
if value is None: lines.pop(i)
|
||||
else: lines[i] = f'{client_id} = {value}'
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found and value is not None:
|
||||
lines.insert(section_start + 1, f'{client_id} = {value}')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _insert_into_section(self, config_text, section_name, line_to_insert):
|
||||
lines = config_text.split('\n')
|
||||
section_start = -1
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == f"[{section_name}]":
|
||||
section_start = i
|
||||
break
|
||||
if section_start == -1:
|
||||
lines.append(f"[{section_name}]")
|
||||
lines.append(line_to_insert)
|
||||
lines.append("")
|
||||
else:
|
||||
lines.insert(section_start + 1, line_to_insert)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def remove_client(self, protocol_type, client_id):
|
||||
# 1. API
|
||||
self._api_request("DELETE", f"/v1/users/{client_id}")
|
||||
|
||||
# 2. Config
|
||||
config_text = self._get_server_config()
|
||||
lines = config_text.split('\n')
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip().lstrip('#').strip()
|
||||
if stripped.startswith(f"{client_id} ") or stripped.startswith(f"{client_id}="):
|
||||
continue
|
||||
new_lines.append(line)
|
||||
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), f"{self._config_path()}")
|
||||
|
||||
def toggle_client(self, protocol_type, client_id, enable, restart=True):
|
||||
# API doesn't have a direct "toggle", so we either set a huge quota or remove/re-add
|
||||
# But for Telemt, commenting out in config is the persistent way.
|
||||
# We'll use HUP after toggling in config.
|
||||
|
||||
config_text = self._get_server_config()
|
||||
lines = config_text.split('\n')
|
||||
new_lines = []
|
||||
in_access_section = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('[access.'): in_access_section = True
|
||||
elif stripped.startswith('['): in_access_section = False
|
||||
|
||||
if in_access_section:
|
||||
base_line = line.lstrip('#').strip()
|
||||
if base_line.startswith(f"{client_id} ") or base_line.startswith(f"{client_id}="):
|
||||
line = base_line if enable else f"# {base_line}"
|
||||
new_lines.append(line)
|
||||
|
||||
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), f"{self._config_path()}")
|
||||
|
||||
if enable:
|
||||
# If enabling, we re-add via API since it might have been deleted from memory
|
||||
secret = ""
|
||||
users = self._parse_users_from_config('\n'.join(new_lines))
|
||||
secret = users.get(client_id, "")
|
||||
if secret:
|
||||
self._api_request("POST", "/v1/users", data={"username": client_id, "secret": secret})
|
||||
else:
|
||||
# If disabling, we just delete from memory
|
||||
self._api_request("DELETE", f"/v1/users/{client_id}")
|
||||
|
||||
if restart:
|
||||
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
|
||||
|
||||
def get_client_config(self, protocol_type, client_id, host='', port=''):
|
||||
resp = self._api_request("GET", f"/v1/users/{client_id}")
|
||||
if resp and resp.get('ok'):
|
||||
user = resp.get('data', {})
|
||||
links = user.get('links', {})
|
||||
if links.get('tls'): return links['tls'][0]
|
||||
if links.get('secure'): return links['secure'][0]
|
||||
if links.get('classic'): return links['classic'][0]
|
||||
|
||||
clients = self.get_clients(protocol_type)
|
||||
c = next((c for c in clients if c['clientId'] == client_id), None)
|
||||
if c:
|
||||
secret = c.get('userData', {}).get('token', '')
|
||||
if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}"
|
||||
return "Not found"
|
||||
Reference in New Issue
Block a user