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 @@
+
+