Template
439 lines
16 KiB
Python
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
|