diff --git a/README.md b/README.md index 358700f..1b11d95 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,9 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### v2.6.4 +* **Mieru (mita v3.28.0)** — optional per-server install from [enfein/mieru](https://github.com/enfein/mieru): native Debian/RPM package, no Docker. Marketplace + server card, TCP port at install, user connections with `mierus://` share links. Pinned release **v3.28.0** (GitHub has no v2.8.0 tag). + ### v2.6.3 * **Client connection domain (AWG / WireGuard)** — set a DNS name per server (or per AWG/WG protocol) instead of IP in client configs (`Endpoint = domain:port`). Survives server IP changes — update the A-record only. UI on server list, server page, and when adding a server. * **Legacy `data.json` → PostgreSQL** — import normalizes old backups (UUIDs, duplicate usernames/tokens, string `server_info`); failed auto-import no longer bricks panel startup; JSON restore returns clear errors. diff --git a/app.py b/app.py index 2c26e36..bae13df 100644 --- a/app.py +++ b/app.py @@ -103,7 +103,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.6.3" +CURRENT_VERSION = "v2.6.4" RELEASES_REPO_URL = repo_url() RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -1111,8 +1111,9 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20): return get_tunnel_status(provider) -BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] +BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'} +NON_DOCKER_PROTOCOLS = frozenset({'mieru'}) def protocol_base(protocol: str) -> str: @@ -1154,6 +1155,7 @@ def protocol_display_name(protocol: str) -> str: 'telemt': 'Telemt', 'hysteria': 'Hysteria 2', 'naiveproxy': 'NaiveProxy', + 'mieru': 'Mieru', 'dns': 'AmneziaDNS', 'wireguard': 'WireGuard', 'socks5': 'SOCKS5', @@ -1175,6 +1177,7 @@ def protocol_container_name(protocol: str) -> Optional[str]: 'telemt': 'telemt', 'hysteria': 'amnezia-hysteria', 'naiveproxy': 'amnezia-naiveproxy', + 'mieru': 'mita', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -1220,6 +1223,9 @@ def get_protocol_manager(ssh, protocol: str): elif base == 'naiveproxy': from managers.naiveproxy_manager import NaiveProxyManager return NaiveProxyManager(ssh, protocol) + elif base == 'mieru': + from managers.mieru_manager import MieruManager + return MieruManager(ssh, protocol) from managers.awg_manager import AWGManager return AWGManager(ssh) @@ -1281,7 +1287,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs): # Protocols that own VPN clients (can list/link connections) -CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'naiveproxy', 'xui'} +CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'naiveproxy', 'mieru', 'xui'} def generate_vpn_link(config_text): @@ -3466,6 +3472,12 @@ async def api_check_server(request: Request, server_id: int): merged[key] = db_proto[key] if db_proto.get('port') and not merged.get('port'): merged['port'] = db_proto['port'] + if protocol_base(proto) == 'mieru': + for key in ('release',): + if db_proto.get(key) not in (None, '') and not merged.get(key): + merged[key] = db_proto[key] + if db_proto.get('port') and not merged.get('port'): + merged['port'] = db_proto['port'] return merged def should_preserve_saved_protocol(proto, result=None, err=None): @@ -3480,8 +3492,9 @@ async def api_check_server(request: Request, server_id: int): def check_proto(proto): cname = protocol_container_name(proto) + base = protocol_base(proto) # Fast path: skip deep manager probes when container is absent - if cname and cname not in inventory: + if cname and cname not in inventory and base not in NON_DOCKER_PROTOCOLS: return proto, merge_saved_protocol_status(proto, { 'container_exists': False, 'container_running': False, @@ -3574,7 +3587,9 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro ssh = get_ssh(server) ssh.connect() - docker_install_log = ensure_docker_installed(ssh) + docker_install_log = '' + if install_base not in NON_DOCKER_PROTOCOLS: + docker_install_log = ensure_docker_installed(ssh) manager = get_protocol_manager(ssh, install_protocol) # Pass parameters to installer @@ -3631,6 +3646,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro domain=req.naiveproxy_domain, email=req.naiveproxy_email, ) + elif install_base == 'mieru': + result = manager.install_protocol( + protocol_type=install_protocol, + port=req.port, + ) else: result = manager.install_protocol(install_protocol, port=req.port) @@ -3680,6 +3700,11 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro proto_record['email'] = result.get('email') if result.get('port'): proto_record['port'] = str(result['port']) + if install_base == 'mieru': + if result.get('port'): + proto_record['port'] = str(result['port']) + if result.get('release'): + proto_record['release'] = result.get('release') proto_record['base_protocol'] = install_base proto_record['instance'] = protocol_instance(install_protocol) proto_record['display_name'] = protocol_display_name(install_protocol) @@ -3867,6 +3892,7 @@ CONTAINER_NAMES = { 'telemt': 'telemt', 'hysteria': 'amnezia-hysteria', 'naiveproxy': 'amnezia-naiveproxy', + 'mieru': 'mita', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -4164,6 +4190,35 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe server = data['servers'][server_id] ssh = get_ssh(server) ssh.connect() + base = protocol_base(req.protocol) + if base in NON_DOCKER_PROTOCOLS: + manager = get_protocol_manager(ssh, req.protocol) + is_running = manager.check_container_running(req.protocol) + if is_running: + manager.stop_service() + action = 'stopped' + else: + manager.start_service() + action = 'started' + diag = manager.get_container_diagnostics(req.protocol) + error = diag.get('error_summary') or '' + recent_logs = diag.get('recent_logs') or '' + if action == 'started' and not diag.get('running'): + ssh.disconnect() + return JSONResponse({ + 'error': error or 'Mieru failed to start', + 'action': action, + 'container': container, + 'recent_logs': recent_logs, + }, status_code=400) + ssh.disconnect() + return { + 'status': 'success', + 'action': action, + 'container': container, + 'error': error, + 'recent_logs': recent_logs, + } # Check current state out, _, _ = ssh.run_sudo_command( f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null" @@ -4227,6 +4282,20 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog container = protocol_container_name(req.protocol) ssh = get_ssh(server) ssh.connect() + if protocol_base(req.protocol) in NON_DOCKER_PROTOCOLS: + mgr = get_protocol_manager(ssh, req.protocol) + logs = mgr.get_logs(req.protocol, tail=req.tail or 200) + diag = mgr.get_container_diagnostics(req.protocol) + ssh.disconnect() + return { + 'status': 'success', + 'protocol': req.protocol, + 'container': container, + 'logs': logs, + 'running': bool(diag.get('running')), + 'error': diag.get('error_summary') or '', + 'container_status': diag.get('status') or '', + } if protocol_base(req.protocol) == 'hysteria': from managers.hysteria_manager import HysteriaManager mgr = HysteriaManager(ssh, req.protocol) @@ -4292,6 +4361,17 @@ async def api_container_logs_stream(request: Request, server_id: int, protocol: ssh = get_ssh(server) ssh.connect() try: + if protocol_base(protocol) in NON_DOCKER_PROTOCOLS: + mgr = get_protocol_manager(ssh, protocol) + logs = mgr.get_logs(protocol, tail=tail) + diag = mgr.get_container_diagnostics(protocol) + state = '|'.join([ + diag.get('status') or '', + 'true' if diag.get('running') else 'false', + '', + diag.get('error_summary') or '', + ]) + return logs, state out, err, _ = ssh.run_sudo_command( f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", timeout=25, diff --git a/managers/backup_manager.py b/managers/backup_manager.py index f2c0959..c837e43 100644 --- a/managers/backup_manager.py +++ b/managers/backup_manager.py @@ -70,6 +70,9 @@ class BackupManager: remote_dir = inst_path('/opt/amnezia/naiveproxy') paths['host'] = [remote_dir] paths['container'] = ['/etc/caddy'] + elif base == 'mieru': + remote_dir = inst_path('/opt/amnezia/mieru') + paths['host'] = [remote_dir] elif base == 'dns': paths['host'] = ['/opt/amnezia/dns'] paths['container'] = ['/opt/amnezia/dns'] diff --git a/managers/mieru_manager.py b/managers/mieru_manager.py new file mode 100644 index 0000000..0adf1c9 --- /dev/null +++ b/managers/mieru_manager.py @@ -0,0 +1,438 @@ +""" +Mieru protocol manager — native mita server package (systemd). + +Installs pinned release from https://github.com/enfein/mieru +Server binary: mita (systemd service), client: mieru. +Share links: mierus://user:pass@host?port=...&protocol=TCP&profile=default +""" + +from __future__ import annotations + +import json +import logging +import re +import secrets +import shlex +import string +from urllib.parse import quote + +logger = logging.getLogger(__name__) + +MIERU_RELEASE = '3.28.0' +GITHUB_RELEASE = f'https://github.com/enfein/mieru/releases/download/v{MIERU_RELEASE}' + + +def _q(value): + return shlex.quote(str(value)) + + +def _rand_token(length=16): + alphabet = string.ascii_lowercase + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def _sanitize_username(name): + base = re.sub(r'[^a-zA-Z0-9_-]', '', (name or 'user').strip())[:24] or 'user' + return f'{base}_{_rand_token(4)}' + + +class MieruManager: + PROTOCOL = 'mieru' + SERVICE_NAME = 'mita' + BASE_DIR = '/opt/amnezia/mieru' + DEFAULT_PORT = 2999 + + def __init__(self, ssh, protocol='mieru'): + self.ssh = ssh + self.protocol = protocol or self.PROTOCOL + self.base_dir = self.BASE_DIR + self.clients_path = f'{self.base_dir}/clients.json' + self.meta_path = f'{self.base_dir}/metadata.json' + self.config_path = f'{self.base_dir}/server_config.json' + + # ===================== STATUS ===================== + + def check_docker_installed(self): + return True + + def _mita_installed(self): + out, _, code = self.ssh.run_command('command -v mita 2>/dev/null') + return code == 0 and bool(out.strip()) + + def check_protocol_installed(self, protocol_type=None): + return self._mita_installed() and self._panel_installed() + + def _panel_installed(self): + out, _, code = self.ssh.run_sudo_command(f"test -f {_q(self.meta_path)} && echo yes") + return code == 0 and 'yes' in out + + def _proxy_running(self): + out, _, code = self.ssh.run_sudo_command('mita status 2>/dev/null') + if code != 0: + return False + return 'RUNNING' in (out or '').upper() + + def check_container_running(self, protocol_type=None): + return self._proxy_running() + + def get_logs(self, protocol_type=None, tail=200): + tail = max(20, min(int(tail or 200), 2000)) + out, err, code = self.ssh.run_sudo_command( + f"journalctl -u {self.SERVICE_NAME} -n {tail} --no-pager 2>&1", + timeout=30, + ) + text = (out or err or '').strip() + if not text: + status, _, _ = self.ssh.run_sudo_command('mita status 2>&1') + text = (status or '').strip() + if not text: + text = f'(no logs: exit {code})' + return text + + def get_container_diagnostics(self, protocol_type=None): + running = self._proxy_running() + out, _, _ = self.ssh.run_sudo_command( + f"systemctl is-active {self.SERVICE_NAME} 2>/dev/null" + ) + daemon_active = (out or '').strip() == 'active' + diag = { + 'status': 'running' if running else ('idle' if daemon_active else 'stopped'), + 'running': running, + 'error_summary': '', + 'recent_logs': self.get_logs(protocol_type, tail=40), + } + if not self._mita_installed(): + diag['status'] = 'missing' + diag['error_summary'] = 'mita package not installed' + elif not running and daemon_active: + status_out, _, _ = self.ssh.run_sudo_command('mita status 2>&1') + if 'IDLE' in (status_out or '').upper(): + diag['error_summary'] = 'Proxy stopped (mita status IDLE)' + elif status_out.strip(): + diag['error_summary'] = status_out.strip().splitlines()[-1][:160] + elif not daemon_active: + diag['error_summary'] = f'{self.SERVICE_NAME} systemd service is not active' + return diag + + def get_server_status(self, protocol_type=None): + protocol_type = protocol_type or self.protocol + exists = self.check_protocol_installed(protocol_type) + running = self.check_container_running(protocol_type) if exists else False + meta = self._read_metadata() if exists else {} + clients = self._read_clients() if exists else [] + port = int(meta.get('port') or self.DEFAULT_PORT) + return { + 'container_exists': exists, + 'container_running': running, + 'port': port, + 'release': meta.get('release') or MIERU_RELEASE, + 'clients_count': len(clients), + 'protocol': protocol_type, + 'base_protocol': self.PROTOCOL, + 'instance': 1, + 'container_name': self.SERVICE_NAME, + } + + # ===================== IO HELPERS ===================== + + def _read_file(self, path): + out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null") + return out if code == 0 else '' + + def _write_file(self, path, content): + import base64 + b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii') + script = ( + f"mkdir -p $(dirname {_q(path)}) && " + f"echo {_q(b64)} | base64 -d > {_q(path)}" + ) + out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) + if code != 0: + raise RuntimeError(f'Failed to write {path}: {err or out}') + + def _read_metadata(self): + raw = self._read_file(self.meta_path).strip() + if not raw: + return {} + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _write_metadata(self, meta): + self._write_file(self.meta_path, json.dumps(meta, indent=2)) + + def _read_clients(self): + raw = self._read_file(self.clients_path).strip() + if not raw: + return [] + try: + data = json.loads(raw) + return data if isinstance(data, list) else [] + except Exception: + return [] + + def _write_clients(self, clients): + self._write_file(self.clients_path, json.dumps(clients, indent=2)) + + def _build_server_config(self, port, clients): + users = [] + for c in clients: + if c.get('enabled', True): + users.append({ + 'name': c.get('username') or c.get('name') or c.get('id'), + 'password': c.get('password') or '', + }) + return { + 'portBindings': [{'port': int(port), 'protocol': 'TCP'}], + 'users': users, + 'loggingLevel': 'INFO', + 'mtu': 1400, + } + + def _apply_config(self, config, reload_only=False): + self._write_file(self.config_path, json.dumps(config, indent=2)) + out, err, code = self.ssh.run_sudo_command( + f"mita apply config {_q(self.config_path)} 2>&1", + timeout=60, + ) + if code != 0: + raise RuntimeError((err or out or 'mita apply config failed').strip()) + if reload_only: + self.ssh.run_sudo_command('mita reload 2>/dev/null || true', timeout=30) + else: + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + out2, err2, code2 = self.ssh.run_sudo_command('mita start 2>&1', timeout=60) + if code2 != 0: + raise RuntimeError((err2 or out2 or 'mita start failed').strip()) + + def _sync_server(self, reload_only=True): + meta = self._read_metadata() + port = int(meta.get('port') or self.DEFAULT_PORT) + clients = self._read_clients() + config = self._build_server_config(port, clients) + self._apply_config(config, reload_only=reload_only) + + def _open_firewall_port(self, port): + script = f""" +PORT={int(port)} +if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi active; then + ufw allow "$PORT"/tcp || true +fi +if command -v firewall-cmd >/dev/null 2>&1; then + firewall-cmd --permanent --add-port="$PORT"/tcp 2>/dev/null || true + firewall-cmd --reload 2>/dev/null || true +fi +""" + self.ssh.run_sudo_script(script, timeout=60) + + def _package_url(self): + arch_out, _, _ = self.ssh.run_command('uname -m') + arch = (arch_out or '').strip().lower() + _, _, deb_code = self.ssh.run_command('command -v dpkg 2>/dev/null') + _, _, rpm_code = self.ssh.run_command('command -v rpm 2>/dev/null') + use_deb = deb_code == 0 or rpm_code != 0 + if use_deb: + if arch in ('aarch64', 'arm64'): + return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_arm64.deb', 'deb' + return f'{GITHUB_RELEASE}/mita_{MIERU_RELEASE}_amd64.deb', 'deb' + if arch in ('aarch64', 'arm64'): + return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.aarch64.rpm', 'rpm' + return f'{GITHUB_RELEASE}/mita-{MIERU_RELEASE}-1.x86_64.rpm', 'rpm' + + def _install_package(self, log): + url, pkg_type = self._package_url() + tmp = f'/tmp/mita_{MIERU_RELEASE}' + if pkg_type == 'deb': + tmp += '.deb' + install_cmd = f"dpkg -i {_q(tmp)} || apt-get install -f -y" + else: + tmp += '.rpm' + install_cmd = f"rpm -Uvh --force {_q(tmp)}" + out, err, code = self.ssh.run_sudo_command( + f"curl -fL {_q(url)} -o {_q(tmp)} 2>&1", + timeout=300, + ) + if code != 0: + raise RuntimeError(f'Failed to download mita package: {err or out}') + log.append(f'Downloaded mita v{MIERU_RELEASE}') + out, err, code = self.ssh.run_sudo_command(install_cmd, timeout=180) + if code != 0: + raise RuntimeError(f'Failed to install mita package: {err or out}') + log.append('Installed mita package') + self.ssh.run_sudo_command( + f"systemctl enable --now {self.SERVICE_NAME} 2>/dev/null || true", + timeout=30, + ) + + def _build_share_uri(self, host, port, username, password, name=''): + user = quote(username or '', safe='') + pw = quote(password or '', safe='') + params = f"port={int(port)}&protocol=TCP&profile=default" + link = f"mierus://{user}:{pw}@{host}?{params}" + if name: + link += f"#{quote(name, safe='')}" + return link + + def _build_client_json(self, host, port, username, password): + return json.dumps({ + 'profiles': [{ + 'profileName': 'default', + 'user': {'name': username, 'password': password}, + 'servers': [{ + 'ipAddress': host, + 'domainName': '', + 'portBindings': [{'port': int(port), 'protocol': 'TCP'}], + }], + 'mtu': 1400, + 'multiplexing': {'level': 'MULTIPLEXING_LOW'}, + 'handshakeMode': 'HANDSHAKE_STANDARD', + }], + 'activeProfile': 'default', + 'rpcPort': 8964, + 'socks5Port': 1080, + 'loggingLevel': 'INFO', + 'socks5ListenLAN': False, + }, indent=2) + + # ===================== INSTALL / REMOVE ===================== + + def install_protocol(self, protocol_type=None, port=None): + protocol_type = protocol_type or self.protocol + port = int(port or self.DEFAULT_PORT) + if port < 1025 or port > 65535: + return {'status': 'error', 'message': 'Port must be between 1025 and 65535'} + + log = [] + if not self._mita_installed(): + self._install_package(log) + else: + log.append(f'mita already installed, configuring panel (v{MIERU_RELEASE})') + + self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") + meta = {'port': port, 'release': MIERU_RELEASE} + self._write_metadata(meta) + self._write_clients([]) + log.append(f'Prepared {self.base_dir}') + + try: + config = self._build_server_config(port, []) + self._apply_config(config, reload_only=False) + except Exception as e: + return {'status': 'error', 'message': str(e), 'log': log} + + self._open_firewall_port(port) + log.append(f'Started mita proxy on TCP {port}') + return { + 'status': 'success', + 'message': f'Mieru v{MIERU_RELEASE} installed', + 'log': log, + 'port': str(port), + 'release': MIERU_RELEASE, + } + + def remove_container(self, protocol_type=None): + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}") + return True + + def start_service(self): + out, err, code = self.ssh.run_sudo_command('mita start 2>&1', timeout=60) + if code != 0: + raise RuntimeError((err or out or 'mita start failed').strip()) + + def stop_service(self): + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + + def get_server_config(self, protocol_type=None): + out, _, code = self.ssh.run_sudo_command('mita describe config 2>/dev/null') + if code == 0 and (out or '').strip(): + return out + return self._read_file(self.config_path) + + def save_server_config(self, protocol_type=None, config_text=''): + self._write_file(self.config_path, config_text or '') + out, err, code = self.ssh.run_sudo_command( + f"mita apply config {_q(self.config_path)} 2>&1", + timeout=60, + ) + if code != 0: + raise RuntimeError((err or out or 'mita apply config failed').strip()) + self.ssh.run_sudo_command('mita stop 2>/dev/null || true', timeout=30) + self.ssh.run_sudo_command('mita start 2>&1', timeout=60) + return True + + # ===================== CLIENTS ===================== + + def get_clients(self, protocol_type=None): + clients = self._read_clients() + result = [] + for c in clients: + cname = c.get('name') or c.get('id') + result.append({ + 'clientId': c.get('id'), + 'client_id': c.get('id'), + 'id': c.get('id'), + 'name': cname, + 'email': cname, + 'enabled': c.get('enabled', True), + 'userData': {'clientName': cname, 'enabled': c.get('enabled', True)}, + }) + return result + + def add_client(self, protocol_type, name, host, port=None): + meta = self._read_metadata() + port = int(port or meta.get('port') or self.DEFAULT_PORT) + client_id = secrets.token_hex(8) + username = _sanitize_username(name) + password = _rand_token(20) + clients = self._read_clients() + clients.append({ + 'id': client_id, + 'name': name or username, + 'username': username, + 'password': password, + 'enabled': True, + }) + self._write_clients(clients) + self._sync_server(reload_only=True) + config = self._build_share_uri(host, port, username, password, name or username) + json_config = self._build_client_json(host, port, username, password) + return { + 'clientId': client_id, + 'client_id': client_id, + 'id': client_id, + 'name': name or username, + 'config': config, + 'json_config': json_config, + } + + def get_client_config(self, protocol_type, client_id, host, port=None): + meta = self._read_metadata() + port = int(port or meta.get('port') or self.DEFAULT_PORT) + clients = self._read_clients() + client = next((c for c in clients if c.get('id') == client_id), None) + if not client: + return '' + if not client.get('enabled', True): + return '' + username = client.get('username') or client.get('name') or client_id + password = client.get('password') or '' + name = client.get('name') or username + return self._build_share_uri(host, port, username, password, name) + + def remove_client(self, protocol_type, client_id): + clients = [c for c in self._read_clients() if c.get('id') != client_id] + self._write_clients(clients) + self._sync_server(reload_only=True) + return True + + def toggle_client(self, protocol_type, client_id, enabled): + clients = self._read_clients() + for c in clients: + if c.get('id') == client_id: + c['enabled'] = bool(enabled) + self._write_clients(clients) + self._sync_server(reload_only=True) + return True diff --git a/static/css/style.css b/static/css/style.css index b999b08..52fa2dd 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -949,6 +949,11 @@ a:hover { color: #38bdf8; } +.protocol-mieru .protocol-icon { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.1)); + color: #818cf8; +} + .protocol-dns .protocol-icon { background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); color: var(--success); diff --git a/telegram_bot.py b/telegram_bot.py index af66add..281278a 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None _callback_refs = {} _pending_inputs = {} -CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "wireguard"} +CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "mieru", "wireguard"} SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"} @@ -139,6 +139,7 @@ def _protocol_display_name(protocol: str) -> str: "telemt": "Telemt", "hysteria": "Hysteria 2", "naiveproxy": "NaiveProxy", + "mieru": "Mieru", "dns": "AmneziaDNS", "wireguard": "WireGuard", "socks5": "SOCKS5", @@ -342,6 +343,7 @@ def _get_ssh_and_manager(server: dict, proto: str): from managers.telemt_manager import TelemtManager from managers.hysteria_manager import HysteriaManager from managers.naiveproxy_manager import NaiveProxyManager + from managers.mieru_manager import MieruManager from managers.wireguard_manager import WireGuardManager from managers.dns_manager import DNSManager from managers.socks5_manager import Socks5Manager @@ -364,6 +366,8 @@ def _get_ssh_and_manager(server: dict, proto: str): manager = HysteriaManager(ssh, proto) elif base == "naiveproxy": manager = NaiveProxyManager(ssh, proto) + elif base == "mieru": + manager = MieruManager(ssh, proto) elif base == "wireguard": manager = WireGuardManager(ssh) elif base == "dns": @@ -569,7 +573,7 @@ async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, p server_name = server.get("name") or server.get("host", "Unknown") await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server_name)}\n🔌 Protocol: {_e(proto.upper())}") - is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy") + is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru") if is_link_proto: await api.send_message(chat_id, f"🔗 Connection link (tap to copy):\n{_e(config)}") else: @@ -926,7 +930,7 @@ async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int, async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable): await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server.get('name') or server.get('host'))}\n🔌 Protocol: {_e(proto.upper())}") - if _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy"): + if _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy", "mieru"): await api.send_message(chat_id, f"🔗 Connection link:\n{_e(config)}") else: await api.send_message(chat_id, f"📄 Configuration:\n
{_e(config)}
") diff --git a/templates/guest.html b/templates/guest.html index ed7d1ef..3e95e00 100644 --- a/templates/guest.html +++ b/templates/guest.html @@ -107,6 +107,7 @@ if (p === 'xui') return '3x-ui VLESS'; if (p === 'hysteria') return 'Hysteria 2'; if (p === 'naiveproxy') return 'NaiveProxy'; + if (p === 'mieru') return 'Mieru'; if (p === 'telemt') return 'Telemt'; return (p || '').toUpperCase(); } diff --git a/templates/invites.html b/templates/invites.html index 367d5f1..154acee 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -196,7 +196,7 @@ const xuiConfigured = {{ 'true' if xui_configured else 'false' }}; const xuiDefaultInbound = {{ xui_default_inbound | int }}; const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }}; - const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy']; + const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru']; const PROTO_TITLES = { awg2: 'AmneziaWG 2.0', awg: 'AmneziaWG', @@ -206,6 +206,7 @@ telemt: 'Telemt', hysteria: 'Hysteria 2', naiveproxy: 'NaiveProxy', + mieru: 'Mieru', xui: '3x-ui VLESS', }; diff --git a/templates/my_connections.html b/templates/my_connections.html index 0d65968..50cc208 100644 --- a/templates/my_connections.html +++ b/templates/my_connections.html @@ -122,7 +122,7 @@ const vpnTab = document.querySelectorAll('.config-tab')[1]; const vpnPanel = document.getElementById('panel-vpn'); const base = String(proto || '').split('__')[0]; - if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy') { + if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy' || base === 'mieru') { vpnTab.style.display = 'none'; } else { vpnTab.style.display = ''; diff --git a/templates/server.html b/templates/server.html index a7b1387..e46a33e 100644 --- a/templates/server.html +++ b/templates/server.html @@ -363,6 +363,31 @@ + +
+
+
{{ icon('zap') }}
+
+
+
Mieru v3.28.0
+
+ {{ _('mieru_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+
@@ -777,6 +802,20 @@
{{ _('naiveproxy_client_hint') }}
+ +