Files
Amnezia-Web-Panel-main/managers/mieru_manager.py
T

439 lines
16 KiB
Python

"""
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