mirror of
https://github.com/andrey271192/PCAtelegram_web.git
synced 2026-09-21 12:01:56 +00:00
feat: add Mieru management
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
- Stopped putting web-admin password into systemd environment; admin now reads root-only auth file.
|
||||
- Added public site manager for port 80 with install, remove, and custom HTML upload.
|
||||
- Added README docs for custom domain and public site flow.
|
||||
- Added Mieru / mita management in web-admin with install, port validation, client JSON, mihomo YAML, logs, services, and backup support.
|
||||
|
||||
## 2.5.0
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -71,6 +71,8 @@ ssh root@SERVER 'chmod +x /opt/pcatelegram_web/install.sh /opt/pcatelegram_web/i
|
||||
| `PCATELEGRAM_WEB_ADMIN_USER` | `admin` | Basic Auth login |
|
||||
| `PCATELEGRAM_WEB_ADMIN_PASSWORD` | `admin` | web-admin password |
|
||||
| `PCATELEGRAM_WEB_WARP_CONFIG` | `/opt/pcatelegram_web/warp.json` | WARP / WARP+ settings |
|
||||
| `PCATELEGRAM_WEB_MIERU_CONFIG` | `/opt/pcatelegram_web/mieru.json` | Mieru server settings |
|
||||
| `PCATELEGRAM_WEB_MIERU_SERVER_CONFIG` | `/opt/pcatelegram_web/mieru_server_config.json` | JSON applied by `mita apply config` |
|
||||
| `PCATELEGRAM_WEB_SITE_ROOT` | `/var/www/pcatelegram_web-site` | root публичного HTML-сайта на 80 |
|
||||
| `PCATELEGRAM_WEB_NGINX_MASK_CONF` | `/etc/nginx/sites-available/pcatelegram_web-mask` | nginx config сайта на 80 |
|
||||
|
||||
@@ -124,6 +126,19 @@ WARP+ key не отдается в API целиком: web показывает
|
||||
|
||||
Per-client runtime routing в текущем telemt не включается автоматически: публичные параметры telemt дают users/limits/quotas/ad tags, но не документируют привязку upstream к конкретному user. Для настоящего WARP только одному клиенту нужен отдельный telemt route/service или upstream-схема.
|
||||
|
||||
## Mieru
|
||||
|
||||
В web-admin Settings есть блок `Mieru`:
|
||||
|
||||
- `Mieru port` — отдельный публичный порт `mita`, по умолчанию `2999`. Диапазон Mieru: `1025-65535`.
|
||||
- `Transport` — `TCP` или `UDP`.
|
||||
- `User` / `Password` — учётка Mieru. Если пароль пустой, web-admin генерирует новый.
|
||||
- `Install / save Mieru` — если `mita` ещё нет, web-admin скачивает свежий пакет `mita` из GitHub release `enfein/mieru`, ставит его, применяет JSON через `mita apply config`, затем запускает `mita start`.
|
||||
- `Client JSON` — готовый конфиг для официального `mieru` client.
|
||||
- `mihomo YAML` — proxy block для клиентов с поддержкой `type: mieru`.
|
||||
|
||||
Mieru не меняет `telemt`, nginx, WARP и сайт на 80. Перед сохранением web-admin проверяет выбранный порт через `ss`; чужой listener блокирует сохранение. Файлы `mieru.json` и `mieru_server_config.json` хранятся с правами `0600` и входят в backup.
|
||||
|
||||
## Порт и маскировка
|
||||
|
||||
В web-admin Settings есть блок `Port and mask site`:
|
||||
|
||||
@@ -48,6 +48,8 @@ SHARED_443_CONFIG = Path(os.getenv("PCATELEGRAM_WEB_SHARED_443", "/opt/pcatelegr
|
||||
BACKUP_SCHEDULE_FILE = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_SCHEDULE", "/opt/pcatelegram_web/backup_schedule.json"))
|
||||
BACKUP_RESTORE_LOG = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_RESTORE_LOG", "/var/log/pcatelegram_web-restore.log"))
|
||||
WARP_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_WARP_CONFIG", "/opt/pcatelegram_web/warp.json"))
|
||||
MIERU_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_MIERU_CONFIG", "/opt/pcatelegram_web/mieru.json"))
|
||||
MIERU_SERVER_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_MIERU_SERVER_CONFIG", "/opt/pcatelegram_web/mieru_server_config.json"))
|
||||
WEBSITE_ROOT = Path(os.getenv("PCATELEGRAM_WEB_SITE_ROOT", "/var/www/pcatelegram_web-site"))
|
||||
NGINX_MASK_CONF = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_CONF", "/etc/nginx/sites-available/pcatelegram_web-mask"))
|
||||
NGINX_MASK_LINK = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_LINK", "/etc/nginx/sites-enabled/pcatelegram_web-mask"))
|
||||
@@ -751,6 +753,328 @@ def apply_warp_runtime(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def normalize_mieru_port(value: Any) -> int:
|
||||
port = normalize_port(value)
|
||||
if port < 1025:
|
||||
raise ValueError("Mieru port must be between 1025 and 65535")
|
||||
return port
|
||||
|
||||
|
||||
def normalize_mieru_protocol(value: Any) -> str:
|
||||
proto = str(value or "TCP").strip().upper()
|
||||
if proto not in {"TCP", "UDP"}:
|
||||
raise ValueError("Mieru protocol must be TCP or UDP")
|
||||
return proto
|
||||
|
||||
|
||||
def read_mieru_config() -> dict[str, Any]:
|
||||
raw = load_json(MIERU_CONFIG_FILE, {}) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
user = str(raw.get("user") or "main").strip()
|
||||
if not USER_RE.match(user):
|
||||
user = "main"
|
||||
try:
|
||||
port = normalize_mieru_port(raw.get("port") or 2999)
|
||||
except ValueError:
|
||||
port = 2999
|
||||
try:
|
||||
protocol = normalize_mieru_protocol(raw.get("protocol") or "TCP")
|
||||
except ValueError:
|
||||
protocol = "TCP"
|
||||
return {
|
||||
"version": 1,
|
||||
"enabled": bool(raw.get("enabled")),
|
||||
"port": port,
|
||||
"protocol": protocol,
|
||||
"user": user,
|
||||
"password": str(raw.get("password") or "").strip(),
|
||||
"updated_at": str(raw.get("updated_at") or ""),
|
||||
}
|
||||
|
||||
|
||||
def write_mieru_config(config: dict[str, Any]) -> None:
|
||||
cfg = dict(config)
|
||||
cfg["version"] = 1
|
||||
cfg["updated_at"] = utc_now()
|
||||
save_json(MIERU_CONFIG_FILE, cfg, mode=0o600)
|
||||
|
||||
|
||||
def mieru_installed() -> bool:
|
||||
return bool(shutil.which("mita")) or service_status("mita") != "not_installed"
|
||||
|
||||
|
||||
def mieru_status_text() -> str:
|
||||
mita = shutil.which("mita")
|
||||
if not mita:
|
||||
return "mita not installed"
|
||||
code, out, err = run([mita, "status"], timeout=8)
|
||||
text = (out or err).strip()
|
||||
return text or f"mita status exit {code}"
|
||||
|
||||
|
||||
def mieru_port_conflicts(port: int, protocol: str) -> list[dict[str, Any]]:
|
||||
listeners, _ = collect_port_listeners(port)
|
||||
proto = protocol.upper()
|
||||
conflicts = []
|
||||
for item in listeners:
|
||||
if str(item.get("proto") or "").upper() != proto:
|
||||
continue
|
||||
process = str(item.get("process") or "").lower()
|
||||
if "mita" in process or "mieru" in process:
|
||||
continue
|
||||
conflicts.append(item)
|
||||
return conflicts
|
||||
|
||||
|
||||
def mieru_server_config(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"portBindings": [
|
||||
{
|
||||
"port": int(cfg["port"]),
|
||||
"protocol": cfg["protocol"],
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"name": cfg["user"],
|
||||
"password": cfg["password"],
|
||||
}
|
||||
],
|
||||
"loggingLevel": "INFO",
|
||||
"mtu": 1400,
|
||||
}
|
||||
|
||||
|
||||
def mieru_client_config(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
host = public_host_for_notes()
|
||||
ip_address = host if re.match(r"^[0-9a-fA-F:.]+$", host) else ""
|
||||
domain_name = "" if ip_address else host
|
||||
return {
|
||||
"profiles": [
|
||||
{
|
||||
"profileName": "PCAtelegram_web",
|
||||
"user": {
|
||||
"name": cfg["user"],
|
||||
"password": cfg["password"],
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"ipAddress": ip_address,
|
||||
"domainName": domain_name,
|
||||
"portBindings": [
|
||||
{
|
||||
"port": int(cfg["port"]),
|
||||
"protocol": cfg["protocol"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"mtu": 1400,
|
||||
"multiplexing": {
|
||||
"level": "MULTIPLEXING_LOW",
|
||||
},
|
||||
}
|
||||
],
|
||||
"activeProfile": "PCAtelegram_web",
|
||||
}
|
||||
|
||||
|
||||
def mieru_mihomo_proxy(cfg: dict[str, Any]) -> str:
|
||||
host = public_host_for_notes()
|
||||
return "\n".join([
|
||||
"proxies:",
|
||||
" - name: PCAtelegram_web Mieru",
|
||||
" type: mieru",
|
||||
f" server: {host}",
|
||||
f" port: {int(cfg['port'])}",
|
||||
f" transport: {cfg['protocol']}",
|
||||
f" username: {cfg['user']}",
|
||||
f" password: {cfg['password']}",
|
||||
" multiplexing: MULTIPLEXING_LOW",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def public_mieru_config() -> dict[str, Any]:
|
||||
cfg = read_mieru_config()
|
||||
listeners, errors = collect_port_listeners(cfg["port"])
|
||||
conflicts = mieru_port_conflicts(cfg["port"], cfg["protocol"])
|
||||
status_text = mieru_status_text()
|
||||
installed = mieru_installed()
|
||||
running = installed and ("RUNNING" in status_text.upper() or any(
|
||||
str(item.get("proto") or "").upper() == cfg["protocol"]
|
||||
and ("mita" in str(item.get("process") or "").lower() or "mieru" in str(item.get("process") or "").lower())
|
||||
for item in listeners
|
||||
))
|
||||
return {
|
||||
"enabled": cfg["enabled"],
|
||||
"installed": installed,
|
||||
"running": running,
|
||||
"service": service_status("mita"),
|
||||
"status_text": status_text,
|
||||
"port": cfg["port"],
|
||||
"protocol": cfg["protocol"],
|
||||
"user": cfg["user"],
|
||||
"password": cfg["password"],
|
||||
"password_mask": mask_secret(cfg["password"]),
|
||||
"updated_at": cfg["updated_at"],
|
||||
"listeners": listeners,
|
||||
"conflicts": conflicts,
|
||||
"ok": not errors,
|
||||
"error": "; ".join(errors[:2]),
|
||||
"client_config": mieru_client_config(cfg) if cfg["password"] else {},
|
||||
"mihomo_yaml": mieru_mihomo_proxy(cfg) if cfg["password"] else "",
|
||||
}
|
||||
|
||||
|
||||
def latest_mita_asset_url() -> tuple[str, str]:
|
||||
req = urllib.request.Request(
|
||||
"https://api.github.com/repos/enfein/mieru/releases/latest",
|
||||
headers={"User-Agent": "PCAtelegram_web-admin"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
release = json.loads(resp.read(1024 * 1024).decode("utf-8"))
|
||||
assets = release.get("assets") if isinstance(release, dict) else []
|
||||
if not isinstance(assets, list):
|
||||
raise RuntimeError("invalid Mieru release metadata")
|
||||
machine = os.uname().machine.lower()
|
||||
arch_aliases = ["amd64", "x86_64"] if machine in {"x86_64", "amd64"} else ["arm64", "aarch64"] if machine in {"aarch64", "arm64"} else [machine]
|
||||
if shutil.which("apt-get") or shutil.which("dpkg"):
|
||||
extensions = [".deb"]
|
||||
elif shutil.which("dnf") or shutil.which("yum") or shutil.which("rpm"):
|
||||
extensions = [".rpm"]
|
||||
else:
|
||||
raise RuntimeError("supported package manager not found")
|
||||
for asset in assets:
|
||||
name = str(asset.get("name") or "").lower()
|
||||
url = str(asset.get("browser_download_url") or "")
|
||||
if not url or ".sha256" in name:
|
||||
continue
|
||||
if not any(name.endswith(ext) for ext in extensions):
|
||||
continue
|
||||
if "mita" not in name:
|
||||
continue
|
||||
if any(alias in name for alias in arch_aliases):
|
||||
return url, name
|
||||
raise RuntimeError(f"mita package for {machine} not found in latest Mieru release")
|
||||
|
||||
|
||||
def download_file(url: str, target: Path) -> None:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PCAtelegram_web-admin"})
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
target.write_bytes(resp.read())
|
||||
|
||||
|
||||
def install_mita_package() -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"attempted": False, "installed": mieru_installed(), "asset": "", "warnings": []}
|
||||
if result["installed"] and shutil.which("mita"):
|
||||
return result
|
||||
if os.geteuid() != 0:
|
||||
result["warnings"].append("root required to install mita")
|
||||
return result
|
||||
url, name = latest_mita_asset_url()
|
||||
target = Path("/tmp") / name
|
||||
download_file(url, target)
|
||||
result.update({"attempted": True, "asset": name})
|
||||
if name.endswith(".deb"):
|
||||
run(["apt-get", "update"], timeout=180)
|
||||
code, _, err = run(["dpkg", "-i", str(target)], timeout=120)
|
||||
if code != 0:
|
||||
run(["apt-get", "install", "-f", "-y"], timeout=240)
|
||||
elif name.endswith(".rpm"):
|
||||
rpm = shutil.which("rpm")
|
||||
if not rpm:
|
||||
raise RuntimeError("rpm not found")
|
||||
code, _, err = run([rpm, "-Uvh", "--force", str(target)], timeout=180)
|
||||
if code != 0:
|
||||
raise RuntimeError(err.strip() or "rpm install failed")
|
||||
run(["systemctl", "enable", "--now", "mita"], timeout=30)
|
||||
result["installed"] = bool(shutil.which("mita")) or service_status("mita") != "not_installed"
|
||||
if not result["installed"]:
|
||||
result["warnings"].append("mita still not available after install")
|
||||
return result
|
||||
|
||||
|
||||
def apply_mieru_config(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
server_cfg = mieru_server_config(cfg)
|
||||
MIERU_SERVER_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = MIERU_SERVER_CONFIG_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(server_cfg, ensure_ascii=False, indent=4) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(MIERU_SERVER_CONFIG_FILE)
|
||||
mita = shutil.which("mita")
|
||||
if not mita:
|
||||
raise RuntimeError("mita is not installed")
|
||||
code, out, err = run([mita, "apply", "config", str(MIERU_SERVER_CONFIG_FILE)], timeout=30)
|
||||
if code != 0:
|
||||
raise RuntimeError(err.strip() or out.strip() or "mita apply config failed")
|
||||
run([mita, "stop"], timeout=20)
|
||||
code, out, err = run([mita, "start"], timeout=30)
|
||||
if code != 0:
|
||||
raise RuntimeError(err.strip() or out.strip() or "mita start failed")
|
||||
return {"applied": True, "status": mieru_status_text()}
|
||||
|
||||
|
||||
def save_mieru_settings(body: dict[str, Any]) -> dict[str, Any]:
|
||||
current = read_mieru_config()
|
||||
port = normalize_mieru_port(body.get("port") or current["port"])
|
||||
protocol = normalize_mieru_protocol(body.get("protocol") or current["protocol"])
|
||||
user = str(body.get("user") or current["user"] or "main").strip()
|
||||
if not USER_RE.match(user):
|
||||
raise ValueError("invalid Mieru user")
|
||||
password = str(body.get("password") or "").strip() or current.get("password") or secrets.token_urlsafe(18)
|
||||
conflicts = mieru_port_conflicts(port, protocol)
|
||||
if conflicts:
|
||||
names = ", ".join(f"{item.get('process')} {item.get('address')}" for item in conflicts[:3])
|
||||
raise RuntimeError(f"Mieru port is busy: {names}")
|
||||
install_result = install_mita_package()
|
||||
if not install_result.get("installed"):
|
||||
raise RuntimeError("; ".join(install_result.get("warnings") or ["mita install failed"]))
|
||||
cfg = {
|
||||
"enabled": True,
|
||||
"port": port,
|
||||
"protocol": protocol,
|
||||
"user": user,
|
||||
"password": password,
|
||||
}
|
||||
apply_result = apply_mieru_config(cfg)
|
||||
write_mieru_config(cfg)
|
||||
payload = public_mieru_config()
|
||||
payload["install"] = install_result
|
||||
payload["apply"] = apply_result
|
||||
return payload
|
||||
|
||||
|
||||
def control_mieru(action: str) -> dict[str, Any]:
|
||||
if not mieru_installed():
|
||||
raise RuntimeError("mita is not installed")
|
||||
mita = shutil.which("mita")
|
||||
if not mita:
|
||||
raise RuntimeError("mita command not found")
|
||||
cfg = read_mieru_config()
|
||||
if action == "stop":
|
||||
code, out, err = run([mita, "stop"], timeout=20)
|
||||
cfg["enabled"] = False
|
||||
write_mieru_config(cfg)
|
||||
elif action == "start":
|
||||
if not cfg.get("password"):
|
||||
raise RuntimeError("Mieru config is empty")
|
||||
code, out, err = run([mita, "start"], timeout=30)
|
||||
cfg["enabled"] = code == 0
|
||||
write_mieru_config(cfg)
|
||||
elif action == "restart":
|
||||
if cfg.get("password"):
|
||||
apply_mieru_config(cfg)
|
||||
return public_mieru_config()
|
||||
code, out, err = run(["systemctl", "restart", "mita"], timeout=30)
|
||||
else:
|
||||
raise ValueError("unsupported Mieru action")
|
||||
if code != 0:
|
||||
raise RuntimeError(err.strip() or out.strip() or f"mita {action} failed")
|
||||
return public_mieru_config()
|
||||
|
||||
|
||||
def read_disabled_users() -> dict[str, str]:
|
||||
raw = load_json(DISABLED_USERS_FILE, {}) or {}
|
||||
if not isinstance(raw, dict):
|
||||
@@ -982,6 +1306,8 @@ def _process_role(process: str) -> str:
|
||||
lowered = process.lower()
|
||||
if "telemt" in lowered or "mtproto" in lowered:
|
||||
return "mtproxy"
|
||||
if "mita" in lowered or "mieru" in lowered:
|
||||
return "mieru"
|
||||
if "nginx" in lowered or "apache" in lowered or "caddy" in lowered:
|
||||
return "site"
|
||||
if "xray" in lowered or "x-ui" in lowered or "3x-ui" in lowered or "xui" in lowered:
|
||||
@@ -2082,7 +2408,7 @@ def user_qr_png(name: str) -> tuple[bytes, str]:
|
||||
|
||||
|
||||
def read_log_payload(service: str) -> dict[str, Any]:
|
||||
allowed = {"telemt", "nginx", "pcatelegram_web-bot", "pcatelegram_web-stats", "pcatelegram_web-admin"}
|
||||
allowed = {"telemt", "nginx", "mita", "pcatelegram_web-bot", "pcatelegram_web-stats", "pcatelegram_web-admin"}
|
||||
if service not in allowed:
|
||||
raise ValueError("unsupported service")
|
||||
code, stdout, stderr = run(["journalctl", "-u", service, "-n", "180", "--no-pager", "-o", "short-iso"], timeout=10)
|
||||
@@ -2139,6 +2465,7 @@ def overview_payload() -> dict[str, Any]:
|
||||
summary = telemt_api("/v1/stats/summary")
|
||||
services = {
|
||||
"telemt": service_status("telemt"),
|
||||
"mieru": service_status("mita"),
|
||||
"nginx": service_status("nginx"),
|
||||
"bot": service_status("pcatelegram_web-bot"),
|
||||
"stats": service_status("pcatelegram_web-stats"),
|
||||
@@ -2163,6 +2490,7 @@ def overview_payload() -> dict[str, Any]:
|
||||
"backups": list_backups(),
|
||||
"backup_schedule": backup_schedule_status(),
|
||||
"warp": public_warp_config(),
|
||||
"mieru": public_mieru_config(),
|
||||
"routing": routing_payload(),
|
||||
}
|
||||
|
||||
@@ -2326,6 +2654,8 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_json({"ok": True, "data": routing_payload(port)})
|
||||
elif path == "/api/warp":
|
||||
self.send_json({"ok": True, "data": public_warp_config()})
|
||||
elif path == "/api/mieru":
|
||||
self.send_json({"ok": True, "data": public_mieru_config()})
|
||||
elif path == "/api/users":
|
||||
users = read_user_records()
|
||||
latest = latest_user_stats()
|
||||
@@ -2678,11 +3008,28 @@ class AdminHandler(BaseHTTPRequestHandler):
|
||||
self.send_error_json(500, f"failed to save WARP config: {exc}")
|
||||
return
|
||||
self.send_json({"ok": True, "data": {"config": public_warp_config(), "apply": apply_result}})
|
||||
elif path == "/api/mieru":
|
||||
action = str(body.get("action") or "install").strip().lower()
|
||||
try:
|
||||
if action in {"install", "save"}:
|
||||
payload = save_mieru_settings(body)
|
||||
else:
|
||||
payload = control_mieru(action)
|
||||
except ValueError as exc:
|
||||
self.send_error_json(400, str(exc))
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
self.send_error_json(409, str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
self.send_error_json(500, f"failed to manage Mieru: {exc}")
|
||||
return
|
||||
self.send_json({"ok": True, "data": payload})
|
||||
elif path == "/api/auth/logout":
|
||||
self.handle_logout()
|
||||
elif path.startswith("/api/services/") and path.endswith("/restart"):
|
||||
service = path[len("/api/services/"):-len("/restart")]
|
||||
allowed = {"telemt", "nginx", "pcatelegram_web-bot", "pcatelegram_web-stats"}
|
||||
allowed = {"telemt", "nginx", "mita", "pcatelegram_web-bot", "pcatelegram_web-stats"}
|
||||
if service not in allowed:
|
||||
self.send_error_json(400, "unsupported service")
|
||||
return
|
||||
|
||||
@@ -132,6 +132,24 @@ const i18n = {
|
||||
warpNotInstalled: "will install on save",
|
||||
warpPerUserNote: "One-client WARP profile is stored here. If warp-cli is missing, it will be installed on save. Runtime per-client routing needs a dedicated telemt route; global WARP applies to all clients.",
|
||||
warpAllNote: "Global WARP applies to all proxy traffic. If warp-cli is missing, it will be installed on save, then connected.",
|
||||
mieruEyebrow: "Protocol",
|
||||
mieruTitle: "Mieru",
|
||||
mieruPort: "Mieru port",
|
||||
mieruProtocol: "Transport",
|
||||
mieruUser: "User",
|
||||
mieruPassword: "Password",
|
||||
mieruInstall: "Install / save Mieru",
|
||||
mieruStart: "Start",
|
||||
mieruStop: "Stop",
|
||||
mieruInstalled: "Mieru installed",
|
||||
mieruNotInstalled: "will install on save",
|
||||
mieruRunning: "Mieru running",
|
||||
mieruStopped: "Mieru stopped",
|
||||
mieruSaved: "Mieru settings saved",
|
||||
mieruClientConfig: "Client JSON",
|
||||
mieruMihomoYaml: "mihomo YAML",
|
||||
mieruNote: "Mieru server uses mita. Port must be free and in range 1025-65535. Client JSON is for mieru app; YAML is for clients with mieru support.",
|
||||
copyConfig: "Copy config",
|
||||
savedKey: "saved key",
|
||||
siteMaskEyebrow: "Public site",
|
||||
siteMaskTitle: "Domain and mask site",
|
||||
@@ -239,6 +257,7 @@ const i18n = {
|
||||
roleSite: "Website",
|
||||
roleXray: "Xray / 3x-ui",
|
||||
roleAmneziawg: "AmneziaWG",
|
||||
roleMieru: "Mieru",
|
||||
roleOther: "Other",
|
||||
range15m: "15 min",
|
||||
range1h: "1 hour",
|
||||
@@ -402,6 +421,24 @@ const i18n = {
|
||||
warpNotInstalled: "установится при сохранении",
|
||||
warpPerUserNote: "Профиль WARP для одного клиента сохраняется здесь. Если warp-cli нет, он установится при сохранении. Runtime-маршрут на одного клиента требует отдельный маршрут telemt; global WARP действует на всех клиентов.",
|
||||
warpAllNote: "Global WARP применится ко всему proxy-трафику. Если warp-cli нет, он установится при сохранении и затем подключится.",
|
||||
mieruEyebrow: "Протокол",
|
||||
mieruTitle: "Mieru",
|
||||
mieruPort: "Порт Mieru",
|
||||
mieruProtocol: "Транспорт",
|
||||
mieruUser: "Пользователь",
|
||||
mieruPassword: "Пароль",
|
||||
mieruInstall: "Установить / сохранить Mieru",
|
||||
mieruStart: "Запустить",
|
||||
mieruStop: "Остановить",
|
||||
mieruInstalled: "Mieru установлен",
|
||||
mieruNotInstalled: "установится при сохранении",
|
||||
mieruRunning: "Mieru работает",
|
||||
mieruStopped: "Mieru остановлен",
|
||||
mieruSaved: "Настройки Mieru сохранены",
|
||||
mieruClientConfig: "Client JSON",
|
||||
mieruMihomoYaml: "mihomo YAML",
|
||||
mieruNote: "Mieru server работает через mita. Порт должен быть свободен и в диапазоне 1025-65535. Client JSON для mieru app; YAML для клиентов с поддержкой mieru.",
|
||||
copyConfig: "Копировать конфиг",
|
||||
savedKey: "ключ сохранён",
|
||||
siteMaskEyebrow: "Публичный сайт",
|
||||
siteMaskTitle: "Домен и сайт маскировки",
|
||||
@@ -509,6 +546,7 @@ const i18n = {
|
||||
roleSite: "Сайт",
|
||||
roleXray: "Xray / 3x-ui",
|
||||
roleAmneziawg: "AmneziaWG",
|
||||
roleMieru: "Mieru",
|
||||
roleOther: "Другое",
|
||||
range15m: "15 мин",
|
||||
range1h: "1 час",
|
||||
@@ -565,6 +603,7 @@ const state = {
|
||||
routingDirty: false,
|
||||
routingPreviewMap: null,
|
||||
warp: null,
|
||||
mieru: null,
|
||||
siteMask: null,
|
||||
qrLink: "",
|
||||
pendingUsers: new Set(),
|
||||
@@ -833,6 +872,7 @@ function healthLabel(health) {
|
||||
function renderServices(services = {}) {
|
||||
const items = [
|
||||
{ key: "telemt", label: "telemt", api: "telemt" },
|
||||
{ key: "mieru", label: "mieru", api: "mita" },
|
||||
{ key: "nginx", label: "nginx", api: "nginx" },
|
||||
{ key: "bot", label: "bot", api: "pcatelegram_web-bot" },
|
||||
{ key: "stats", label: "stats", api: "pcatelegram_web-stats" },
|
||||
@@ -1422,6 +1462,52 @@ function renderWarpSettings() {
|
||||
: `${t("warpAllNote")}${statusLine ? ` ${statusLine}` : ""}`;
|
||||
}
|
||||
|
||||
function mieruSummary(cfg = {}) {
|
||||
const conflicts = cfg.conflicts || [];
|
||||
if (conflicts.length) {
|
||||
return `${t("routingBusy")}: ${conflicts.map((item) => `${item.process} ${item.address}`).join(", ")}`;
|
||||
}
|
||||
const listeners = cfg.listeners || [];
|
||||
const listenerText = listeners.length
|
||||
? listeners.map((item) => `${item.process} ${item.address}`).join(", ")
|
||||
: `${cfg.protocol || "TCP"} :${cfg.port || 2999}`;
|
||||
const status = cfg.status_text ? ` ${cfg.status_text}` : "";
|
||||
return `${listenerText}.${status} ${t("mieruNote")}`;
|
||||
}
|
||||
|
||||
function renderMieruSettings(payload = null) {
|
||||
const cfg = payload || state.mieru || state.overview?.mieru || {};
|
||||
const portEl = $("#mieruPort");
|
||||
const protocolEl = $("#mieruProtocol");
|
||||
const userEl = $("#mieruUser");
|
||||
const passwordEl = $("#mieruPassword");
|
||||
const statusEl = $("#mieruStatus");
|
||||
const noteEl = $("#mieruRuntimeNote");
|
||||
const clientEl = $("#mieruClientConfig");
|
||||
const yamlEl = $("#mieruMihomoYaml");
|
||||
if (!portEl || !protocolEl || !userEl || !passwordEl || !statusEl || !noteEl) return;
|
||||
if (document.activeElement !== portEl) portEl.value = cfg.port || 2999;
|
||||
if (document.activeElement !== protocolEl) protocolEl.value = cfg.protocol || "TCP";
|
||||
if (document.activeElement !== userEl) userEl.value = cfg.user || "main";
|
||||
if (document.activeElement !== passwordEl) passwordEl.value = "";
|
||||
passwordEl.placeholder = cfg.password_mask ? `${t("savedKey")}: ${cfg.password_mask}` : "auto";
|
||||
const conflicts = cfg.conflicts || [];
|
||||
statusEl.textContent = conflicts.length
|
||||
? t("routingBusy")
|
||||
: (cfg.running ? t("mieruRunning") : (cfg.installed ? t("mieruStopped") : t("mieruNotInstalled")));
|
||||
statusEl.className = `status-pill ${conflicts.length ? "health-error" : (cfg.running ? "health-ok" : "")}`;
|
||||
noteEl.textContent = mieruSummary(cfg);
|
||||
noteEl.classList.toggle("error", Boolean(conflicts.length));
|
||||
if (clientEl && document.activeElement !== clientEl) {
|
||||
clientEl.value = cfg.client_config && Object.keys(cfg.client_config).length
|
||||
? JSON.stringify(cfg.client_config, null, 2)
|
||||
: "";
|
||||
}
|
||||
if (yamlEl && document.activeElement !== yamlEl) {
|
||||
yamlEl.value = cfg.mihomo_yaml || "";
|
||||
}
|
||||
}
|
||||
|
||||
function siteMaskSummary(payload = {}) {
|
||||
const conflicts = payload.conflicts || [];
|
||||
if (conflicts.length) {
|
||||
@@ -1612,6 +1698,7 @@ async function refreshAll(options = {}) {
|
||||
state.backupSchedule = state.overview.backup_schedule || state.backupSchedule;
|
||||
state.routing = state.overview.routing || state.routing;
|
||||
state.warp = state.overview.warp || state.warp;
|
||||
state.mieru = state.overview.mieru || state.mieru;
|
||||
state.siteMask = state.overview.site_mask || state.siteMask;
|
||||
updateLanguageFromOverview(state.overview);
|
||||
state.users = await api("/api/users");
|
||||
@@ -1638,6 +1725,7 @@ async function refreshAll(options = {}) {
|
||||
renderUsers();
|
||||
renderRoutingSettings();
|
||||
renderWarpSettings();
|
||||
renderMieruSettings();
|
||||
renderSiteMaskSettings();
|
||||
if (state.page === "traffic") {
|
||||
await refreshStats();
|
||||
@@ -1884,6 +1972,56 @@ async function saveWarpSettings(eventObj) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMieruSettings(eventObj) {
|
||||
eventObj.preventDefault();
|
||||
const form = eventObj.currentTarget;
|
||||
const controls = Array.from(form.querySelectorAll("input, select, button"));
|
||||
controls.forEach((control) => { control.disabled = true; });
|
||||
try {
|
||||
const payload = {
|
||||
action: "install",
|
||||
port: Number.parseInt($("#mieruPort").value, 10),
|
||||
protocol: $("#mieruProtocol").value,
|
||||
user: $("#mieruUser").value.trim(),
|
||||
password: $("#mieruPassword").value.trim(),
|
||||
};
|
||||
const data = await api("/api/mieru", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.mieru = data;
|
||||
$("#mieruPassword").value = "";
|
||||
renderMieruSettings(data);
|
||||
addEvent(t("mieruSaved"), `${data.protocol}:${data.port}`);
|
||||
toast(t("mieruSaved"));
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
controls.forEach((control) => { control.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
async function controlMieru(action) {
|
||||
const buttons = [$("#mieruStartBtn"), $("#mieruRestartBtn"), $("#mieruStopBtn")].filter(Boolean);
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
try {
|
||||
const data = await api("/api/mieru", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
state.mieru = data;
|
||||
renderMieruSettings(data);
|
||||
addEvent(t("serviceRestarted"), `mieru ${action}`);
|
||||
toast(action === "stop" ? t("mieruStopped") : t("mieruRunning"));
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSiteMask(eventObj) {
|
||||
eventObj.preventDefault();
|
||||
const form = eventObj.currentTarget;
|
||||
@@ -2211,6 +2349,11 @@ $("#routingPort").addEventListener("change", (eventObj) => checkRoutingPort(even
|
||||
$("#warpSettingsForm").addEventListener("submit", saveWarpSettings);
|
||||
$("#warpScope").addEventListener("change", renderWarpSettings);
|
||||
$("#warpUserSelect").addEventListener("change", renderWarpSettings);
|
||||
$("#mieruSettingsForm").addEventListener("submit", saveMieruSettings);
|
||||
$("#mieruStartBtn").addEventListener("click", () => controlMieru("start"));
|
||||
$("#mieruRestartBtn").addEventListener("click", () => controlMieru("restart"));
|
||||
$("#mieruStopBtn").addEventListener("click", () => controlMieru("stop"));
|
||||
$("#mieruCopyBtn").addEventListener("click", () => copyText($("#mieruClientConfig").value || $("#mieruMihomoYaml").value || ""));
|
||||
$("#siteMaskForm").addEventListener("submit", saveSiteMask);
|
||||
$("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask);
|
||||
$("#siteMaskUploadBtn").addEventListener("click", uploadSiteMaskHtml);
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
<div class="inline-form">
|
||||
<select id="logService">
|
||||
<option value="telemt">telemt</option>
|
||||
<option value="mita">mieru / mita</option>
|
||||
<option value="nginx">nginx</option>
|
||||
<option value="pcatelegram_web-bot">bot</option>
|
||||
<option value="pcatelegram_web-stats">stats</option>
|
||||
@@ -465,6 +466,53 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n="mieruEyebrow">Protocol</p>
|
||||
<h2 data-i18n="mieruTitle">Mieru</h2>
|
||||
</div>
|
||||
<span id="mieruStatus" class="status-pill">--</span>
|
||||
</div>
|
||||
<form id="mieruSettingsForm" class="settings-form">
|
||||
<label>
|
||||
<span data-i18n="mieruPort">Mieru port</span>
|
||||
<input name="port" id="mieruPort" type="number" min="1025" max="65535" step="1" inputmode="numeric" placeholder="2999">
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="mieruProtocol">Transport</span>
|
||||
<select name="protocol" id="mieruProtocol">
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="mieruUser">User</span>
|
||||
<input name="user" id="mieruUser" autocomplete="off" spellcheck="false" placeholder="main">
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="mieruPassword">Password</span>
|
||||
<input name="password" id="mieruPassword" autocomplete="off" spellcheck="false" placeholder="auto">
|
||||
</label>
|
||||
<p id="mieruRuntimeNote" class="modal-note"></p>
|
||||
<div class="inline-form">
|
||||
<button type="submit" data-i18n="mieruInstall">Install / save Mieru</button>
|
||||
<button id="mieruStartBtn" class="soft" type="button" data-i18n="mieruStart">Start</button>
|
||||
<button id="mieruRestartBtn" class="soft" type="button" data-i18n="restart">Restart</button>
|
||||
<button id="mieruStopBtn" class="danger" type="button" data-i18n="mieruStop">Stop</button>
|
||||
</div>
|
||||
<label>
|
||||
<span data-i18n="mieruClientConfig">Client JSON</span>
|
||||
<textarea id="mieruClientConfig" rows="8" readonly spellcheck="false"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="mieruMihomoYaml">mihomo YAML</span>
|
||||
<textarea id="mieruMihomoYaml" rows="8" readonly spellcheck="false"></textarea>
|
||||
</label>
|
||||
<button id="mieruCopyBtn" class="soft" type="button" data-i18n="copyConfig">Copy config</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
@@ -508,6 +556,6 @@
|
||||
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js?v=2.5.0-admin29" type="module"></script>
|
||||
<script src="/app.js?v=2.5.0-admin30" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -54,7 +54,7 @@ body {
|
||||
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button, input, select { font: inherit; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
button {
|
||||
min-height: 40px;
|
||||
@@ -74,7 +74,7 @@ button.soft { background: color-mix(in srgb, var(--blue) 12%, transparent); colo
|
||||
button.danger { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
|
||||
button.attention { background: var(--amber); color: #111827; }
|
||||
|
||||
input, select {
|
||||
input, select, textarea {
|
||||
min-height: 42px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
@@ -85,7 +85,13 @@ input, select {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
textarea {
|
||||
padding-block: 10px;
|
||||
resize: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--blue);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 16%, transparent);
|
||||
}
|
||||
@@ -1360,6 +1366,14 @@ td small {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.settings-form textarea {
|
||||
width: 100%;
|
||||
min-height: 132px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.backup-item span,
|
||||
.event small {
|
||||
display: block;
|
||||
|
||||
@@ -47,6 +47,12 @@ create_backup() {
|
||||
if [ -f "$PCATELEGRAM_WEB_DIR/warp.json" ]; then
|
||||
cp "$PCATELEGRAM_WEB_DIR/warp.json" "$tmp_dir/warp.json" 2>/dev/null
|
||||
fi
|
||||
if [ -f "$PCATELEGRAM_WEB_DIR/mieru.json" ]; then
|
||||
cp "$PCATELEGRAM_WEB_DIR/mieru.json" "$tmp_dir/mieru.json" 2>/dev/null
|
||||
fi
|
||||
if [ -f "$PCATELEGRAM_WEB_DIR/mieru_server_config.json" ]; then
|
||||
cp "$PCATELEGRAM_WEB_DIR/mieru_server_config.json" "$tmp_dir/mieru_server_config.json" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Language marker (i18n)
|
||||
if [ -f "$PCATELEGRAM_WEB_DIR/.language" ]; then
|
||||
@@ -253,6 +259,12 @@ restore_backup() {
|
||||
if [ ! -f "$backup_dir/warp.json" ] && [ -f "$tmp_dir/opt/pcatelegram_web/warp.json" ]; then
|
||||
cp "$tmp_dir/opt/pcatelegram_web/warp.json" "$backup_dir/warp.json" 2>/dev/null || true
|
||||
fi
|
||||
if [ ! -f "$backup_dir/mieru.json" ] && [ -f "$tmp_dir/opt/pcatelegram_web/mieru.json" ]; then
|
||||
cp "$tmp_dir/opt/pcatelegram_web/mieru.json" "$backup_dir/mieru.json" 2>/dev/null || true
|
||||
fi
|
||||
if [ ! -f "$backup_dir/mieru_server_config.json" ] && [ -f "$tmp_dir/opt/pcatelegram_web/mieru_server_config.json" ]; then
|
||||
cp "$tmp_dir/opt/pcatelegram_web/mieru_server_config.json" "$backup_dir/mieru_server_config.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Проверяем метаданные
|
||||
if [ -f "$backup_dir/metadata.json" ]; then
|
||||
@@ -314,6 +326,16 @@ restore_backup() {
|
||||
cp "$backup_dir/warp.json" "$PCATELEGRAM_WEB_DIR/warp.json" 2>/dev/null
|
||||
chmod 600 "$PCATELEGRAM_WEB_DIR/warp.json" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f "$backup_dir/mieru.json" ]; then
|
||||
mkdir -p "$PCATELEGRAM_WEB_DIR"
|
||||
cp "$backup_dir/mieru.json" "$PCATELEGRAM_WEB_DIR/mieru.json" 2>/dev/null
|
||||
chmod 600 "$PCATELEGRAM_WEB_DIR/mieru.json" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f "$backup_dir/mieru_server_config.json" ]; then
|
||||
mkdir -p "$PCATELEGRAM_WEB_DIR"
|
||||
cp "$backup_dir/mieru_server_config.json" "$PCATELEGRAM_WEB_DIR/mieru_server_config.json" 2>/dev/null
|
||||
chmod 600 "$PCATELEGRAM_WEB_DIR/mieru_server_config.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Восстанавливаем language marker (i18n)
|
||||
if [ -f "$backup_dir/.language" ]; then
|
||||
|
||||
Reference in New Issue
Block a user