mirror of
https://github.com/l-ptrol/mihomo_studio.git
synced 2026-04-26 10:31:32 +00:00
1879 lines
88 KiB
Python
1879 lines
88 KiB
Python
# !/opt/bin/python3
|
||
# -*- coding: utf-8 -*-
|
||
import http.server
|
||
import socketserver
|
||
import os
|
||
import subprocess
|
||
import urllib.parse
|
||
import urllib.request
|
||
import urllib.error
|
||
import re
|
||
import time
|
||
import shutil
|
||
import glob
|
||
import json
|
||
from datetime import datetime
|
||
|
||
# --- НАСТРОЙКИ ---
|
||
PORT = 8888
|
||
CONFIG_DIR = "/opt/etc/mihomo"
|
||
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.yaml")
|
||
PROFILES_DIR = os.path.join(CONFIG_DIR, "profiles")
|
||
BACKUP_DIR = os.path.join(CONFIG_DIR, "backup")
|
||
LOG_FILE = "/tmp/mihomo_last_restart.log"
|
||
RESTART_CMD = "xkeen -restart > " + LOG_FILE + " 2>&1"
|
||
UPDATE_CMD = "/opt/bin/mhstudio -update"
|
||
|
||
# --- ИНИЦИАЛИЗАЦИЯ ---
|
||
if not os.path.exists(BACKUP_DIR): os.makedirs(BACKUP_DIR)
|
||
if not os.path.exists(PROFILES_DIR): os.makedirs(PROFILES_DIR)
|
||
|
||
if os.path.exists(CONFIG_PATH) and not os.path.islink(CONFIG_PATH):
|
||
shutil.move(CONFIG_PATH, os.path.join(PROFILES_DIR, "default.yaml"))
|
||
os.symlink(os.path.join(PROFILES_DIR, "default.yaml"), CONFIG_PATH)
|
||
elif not os.path.exists(CONFIG_PATH):
|
||
def_prof = os.path.join(PROFILES_DIR, "default.yaml")
|
||
with open(def_prof, 'w') as f:
|
||
f.write("proxies: []\n")
|
||
os.symlink(def_prof, CONFIG_PATH)
|
||
|
||
|
||
# --- ПАРСЕРЫ ---
|
||
def parse_vless(link, custom_name=None):
|
||
try:
|
||
if not link.startswith("vless://"): return None, "Link error"
|
||
main = link[8:]
|
||
name = "VLESS"
|
||
if custom_name:
|
||
name = custom_name
|
||
elif '#' in main:
|
||
main, n = main.split('#', 1)
|
||
name = urllib.parse.unquote(n).strip()
|
||
|
||
name = re.sub(r'[\[\]\{\}\"\']', '', name)
|
||
user_srv = main.split('?')[0]
|
||
params = urllib.parse.parse_qs(main.split('?')[1]) if '?' in main else {}
|
||
if '@' in user_srv:
|
||
uuid, srv_port = user_srv.split('@', 1)
|
||
else:
|
||
return None, "No UUID"
|
||
if ':' in srv_port:
|
||
if ']' in srv_port:
|
||
srv, port = srv_port.rsplit(':', 1);
|
||
srv = srv.replace('[', '').replace(']', '')
|
||
else:
|
||
srv, port = srv_port.split(':')
|
||
else:
|
||
return None, "No Port"
|
||
|
||
def get(k):
|
||
return params.get(k, [''])[0]
|
||
|
||
y = ['- name: "' + name + '"', ' type: vless', ' server: ' + srv, ' port: ' + port, ' uuid: ' + uuid,
|
||
' udp: true']
|
||
y.append(' network: ' + (get('type') or 'tcp'))
|
||
if get('flow'): y.append(' flow: ' + get('flow'))
|
||
if get('security'):
|
||
y.append(' tls: true')
|
||
if get('security') == 'reality':
|
||
y.extend([' servername: ' + get('sni'), ' client-fingerprint: ' + (get('fp') or 'chrome'),
|
||
' reality-opts:', ' public-key: ' + get('pbk')])
|
||
if get('sid'): y.append(' short-id: ' + get('sid'))
|
||
else:
|
||
if get('sni'): y.append(' servername: ' + get('sni'))
|
||
if get('fp'): y.append(' client-fingerprint: ' + get('fp'))
|
||
if get('alpn'):
|
||
av = get("alpn").replace(",", '", "')
|
||
y.append(' alpn: ["' + av + '"]')
|
||
if get('type') == 'ws':
|
||
y.append(' ws-opts:')
|
||
if get('path'): y.append(' path: ' + get('path'))
|
||
if get('host'): y.extend([' headers:', ' Host: ' + get('host')])
|
||
elif get('type') == 'grpc' and get('serviceName'):
|
||
y.extend([' grpc-opts:', ' grpc-service-name: ' + get('serviceName')])
|
||
return {"yaml": "\n".join(y), "name": name}, None
|
||
except Exception as e:
|
||
return None, str(e)
|
||
|
||
|
||
def parse_wireguard(config_text, custom_name=None):
|
||
try:
|
||
conf = {"interface": {}, "peer": {}}
|
||
section = None
|
||
|
||
for line in config_text.splitlines():
|
||
line = line.split('#')[0].split(';')[0].strip()
|
||
if not line: continue
|
||
|
||
if line.startswith('[') and line.endswith(']'):
|
||
s_name = line[1:-1].lower()
|
||
if s_name == 'interface' or s_name == 'peer':
|
||
section = s_name
|
||
else:
|
||
section = None
|
||
continue
|
||
|
||
if section and '=' in line:
|
||
key, val = line.split('=', 1)
|
||
conf[section][key.strip().lower()] = val.strip()
|
||
|
||
iface = conf['interface']
|
||
peer = conf['peer']
|
||
|
||
if not iface or not peer:
|
||
return None, "Invalid WireGuard config: missing Interface or Peer"
|
||
|
||
endpoint = peer.get('endpoint', '')
|
||
if not endpoint: return None, "No Endpoint found"
|
||
|
||
if ']:' in endpoint:
|
||
server = endpoint.split(']:')[0][1:]
|
||
port = endpoint.split(']:')[1]
|
||
elif ':' in endpoint:
|
||
server, port = endpoint.rsplit(':', 1)
|
||
else:
|
||
return None, "Invalid Endpoint format"
|
||
|
||
name = "WireGuard"
|
||
if custom_name:
|
||
name = custom_name
|
||
else:
|
||
first_line = config_text.splitlines()[0].strip()
|
||
if first_line.startswith('#') and len(first_line) > 2:
|
||
name = first_line[1:].strip()
|
||
else:
|
||
name = f"WG_{server}"
|
||
|
||
address_raw = iface.get('address', '')
|
||
if not address_raw: return None, "No Address found"
|
||
|
||
ips = [x.strip() for x in address_raw.split(',')]
|
||
ip_v4 = None
|
||
ip_v6 = None
|
||
|
||
for ip in ips:
|
||
clean_ip = ip.split('/')[0]
|
||
if ':' in clean_ip:
|
||
if not ip_v6: ip_v6 = clean_ip
|
||
else:
|
||
if not ip_v4: ip_v4 = clean_ip
|
||
|
||
if not ip_v4 and not ip_v6:
|
||
return None, "No valid IP address found"
|
||
|
||
y = []
|
||
y.append(f'- name: "{name}"')
|
||
y.append(f' type: wireguard')
|
||
y.append(f' server: {server}')
|
||
y.append(f' port: {port}')
|
||
|
||
if ip_v4: y.append(f' ip: {ip_v4}')
|
||
if ip_v6: y.append(f' ipv6: {ip_v6}')
|
||
|
||
pk = iface.get('privatekey')
|
||
if pk: y.append(f' private-key: {pk}')
|
||
|
||
pubk = peer.get('publickey')
|
||
if pubk: y.append(f' public-key: {pubk}')
|
||
|
||
psk = peer.get('presharedkey')
|
||
if psk: y.append(f' pre-shared-key: {psk}')
|
||
|
||
dns_raw = iface.get('dns')
|
||
if dns_raw:
|
||
dns_list = [d.strip() for d in dns_raw.split(',')]
|
||
y.append(f' dns: {json.dumps(dns_list)}')
|
||
|
||
mtu = iface.get('mtu')
|
||
if mtu: y.append(f' mtu: {mtu}')
|
||
|
||
y.append(' udp: true')
|
||
|
||
# AmneziaWG options
|
||
std_wg_keys = ['privatekey', 'address', 'dns', 'mtu', 'listenport', 'table', 'preup', 'postup', 'predown',
|
||
'postdown']
|
||
amn_opts = {}
|
||
|
||
for k, v in iface.items():
|
||
if k not in std_wg_keys:
|
||
# Проверяем, является ли значение числом
|
||
if v.isdigit() or (v.startswith('-') and v[1:].isdigit()):
|
||
amn_opts[k] = int(v)
|
||
else:
|
||
# Если не число, сохраняем как есть (строкой)
|
||
amn_opts[k] = v
|
||
|
||
if amn_opts:
|
||
y.append(' amnezia-wg-option:')
|
||
for k, v in amn_opts.items():
|
||
if isinstance(v, str):
|
||
if not v: # Пустая строка
|
||
y.append(f' {k}: ""')
|
||
else:
|
||
y.append(f' {k}: {v}')
|
||
else:
|
||
y.append(f' {k}: {v}')
|
||
|
||
allowed = peer.get('allowedips')
|
||
if allowed:
|
||
al_list = [x.strip() for x in allowed.split(',')]
|
||
y.append(f' allowed-ips: {json.dumps(al_list)}')
|
||
|
||
ka = peer.get('persistentkeepalive')
|
||
if ka:
|
||
y.append(f' persistent-keepalive: {ka}')
|
||
|
||
return {"yaml": "\n".join(y), "name": name}, None
|
||
|
||
except Exception as e:
|
||
return None, str(e)
|
||
|
||
|
||
def insert_proxy_logic(content, proxy_name, target_groups):
|
||
lines = content.splitlines()
|
||
new_lines = []
|
||
|
||
def get_indent(s):
|
||
return len(s) - len(s.lstrip())
|
||
|
||
in_group_section = False
|
||
current_group_name = None
|
||
in_proxies_list = False
|
||
proxies_list_indent = -1
|
||
inserted_in_group = set()
|
||
|
||
for i, line in enumerate(lines):
|
||
stripped = line.strip()
|
||
indent = get_indent(line)
|
||
is_new_group = stripped.startswith('- name:')
|
||
|
||
if is_new_group:
|
||
if in_proxies_list and current_group_name in target_groups and current_group_name not in inserted_in_group:
|
||
prefix = " " * (proxies_list_indent + 2)
|
||
new_lines.append(prefix + '- "' + proxy_name + '"')
|
||
inserted_in_group.add(current_group_name)
|
||
in_proxies_list = False
|
||
|
||
if stripped.startswith('proxy-groups:'):
|
||
in_group_section = True
|
||
elif in_group_section and indent == 0 and stripped and not stripped.startswith('#'):
|
||
in_group_section = False
|
||
in_proxies_list = False
|
||
current_group_name = None
|
||
|
||
if in_group_section:
|
||
if is_new_group:
|
||
raw_name = stripped.split(':', 1)[1].strip()
|
||
current_group_name = raw_name.strip("'").strip('"')
|
||
|
||
if current_group_name in target_groups and stripped.startswith('proxies:'):
|
||
if '[' in stripped and stripped.rstrip().endswith(']'):
|
||
start = line.find('[')
|
||
end = line.rfind(']')
|
||
if start != -1 and end != -1:
|
||
content_inner = line[start + 1:end]
|
||
if proxy_name not in content_inner:
|
||
sep = ", " if content_inner.strip() else ""
|
||
new_content = content_inner + sep + f'"{proxy_name}"'
|
||
new_line = line[:start + 1] + new_content + line[end:]
|
||
new_lines.append(new_line)
|
||
inserted_in_group.add(current_group_name)
|
||
continue
|
||
else:
|
||
new_lines.append(line)
|
||
inserted_in_group.add(current_group_name)
|
||
continue
|
||
|
||
in_proxies_list = True
|
||
proxies_list_indent = indent
|
||
new_lines.append(line)
|
||
continue
|
||
|
||
if in_proxies_list:
|
||
if not stripped or stripped.startswith('#'):
|
||
new_lines.append(line)
|
||
continue
|
||
if ('DIRECT' in stripped or 'REJECT' in stripped) and current_group_name not in inserted_in_group:
|
||
prefix = " " * indent
|
||
new_lines.append(prefix + '- "' + proxy_name + '"')
|
||
inserted_in_group.add(current_group_name)
|
||
|
||
if indent <= proxies_list_indent:
|
||
if current_group_name not in inserted_in_group:
|
||
prefix = " " * (proxies_list_indent + 2)
|
||
new_lines.append(prefix + '- "' + proxy_name + '"')
|
||
inserted_in_group.add(current_group_name)
|
||
in_proxies_list = False
|
||
|
||
new_lines.append(line)
|
||
|
||
if in_proxies_list and current_group_name in target_groups and current_group_name not in inserted_in_group:
|
||
prefix = " " * (proxies_list_indent + 2)
|
||
new_lines.append(prefix + '- "' + proxy_name + '"')
|
||
|
||
return "\n".join(new_lines)
|
||
|
||
|
||
def replace_proxy_block(content, target_name, new_yaml_lines):
|
||
lines = content.splitlines()
|
||
new_content_lines = []
|
||
|
||
in_proxies = False
|
||
found_target = False
|
||
replaced = False
|
||
|
||
name_pattern = re.compile(r'^\s*-\s+name:\s*(["\'])?' + re.escape(target_name) + r'(\1)?\s*$')
|
||
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
stripped = line.strip()
|
||
|
||
if stripped.startswith('proxies:'):
|
||
in_proxies = True
|
||
new_content_lines.append(line)
|
||
i += 1
|
||
continue
|
||
|
||
if in_proxies and line and not line.startswith(' ') and not line.startswith('\t') and not line.startswith('#'):
|
||
in_proxies = False
|
||
|
||
if in_proxies and not replaced:
|
||
if name_pattern.match(stripped):
|
||
indent_len = len(line) - len(line.lstrip())
|
||
if new_yaml_lines and "name:" in new_yaml_lines[0]:
|
||
new_yaml_lines[0] = re.sub(r'name:\s*".*"', f'name: "{target_name}"', new_yaml_lines[0])
|
||
|
||
for n_line in new_yaml_lines:
|
||
new_content_lines.append(" " * indent_len + n_line)
|
||
|
||
replaced = True
|
||
found_target = True
|
||
|
||
i += 1
|
||
while i < len(lines):
|
||
next_line = lines[i]
|
||
next_stripped = next_line.strip()
|
||
next_indent = len(next_line) - len(next_line.lstrip())
|
||
if not next_stripped:
|
||
i += 1
|
||
continue
|
||
if next_indent < indent_len: break
|
||
if next_indent == indent_len and next_stripped.startswith('-'): break
|
||
i += 1
|
||
continue
|
||
|
||
new_content_lines.append(line)
|
||
i += 1
|
||
|
||
return "\n".join(new_content_lines)
|
||
|
||
|
||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<title>Mihomo Studio v1.4</title>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.7/ace.js"></script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||
:root {
|
||
--bg-grad: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%);
|
||
--bg-sec: rgba(255, 255, 255, 0.4);
|
||
--bg-ter: rgba(255, 255, 255, 0.6);
|
||
--txt: #1c1e21; --txt-sec: #4b4f56; --bd: rgba(255, 255, 255, 0.5);
|
||
--btn-s: linear-gradient(135deg, #1877f2, #2851a3); --btn-r: linear-gradient(135deg, #42b72a, #2b7a1a); --btn-d: linear-gradient(135deg, #fa383e, #a61a1e); --btn-u: linear-gradient(135deg, #f7b928, #b8860b); --btn-g: rgba(255,255,255,0.7);
|
||
--btn-g-txt: #050505;
|
||
--log-bg: rgba(0, 0, 0, 0.7); --log-txt: #e4e6eb;
|
||
--comp-h: 40px; --radius: 12px;
|
||
--shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||
--glass-sh: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||
--glass-blur: blur(12px);
|
||
}
|
||
body.dark {
|
||
--bg-grad: linear-gradient(135deg, #1f1c2c 0%, #928dab 100%);
|
||
--bg-sec: rgba(30, 30, 30, 0.5);
|
||
--bg-ter: rgba(45, 45, 45, 0.6);
|
||
--txt: #e4e6eb; --txt-sec: #b0b3b8; --bd: rgba(255, 255, 255, 0.1);
|
||
--btn-s: linear-gradient(135deg, #2d88ff, #1a5bb8); --btn-r: linear-gradient(135deg, #45bd62, #2d8240); --btn-d: linear-gradient(135deg, #f02849, #9e152b); --btn-u: linear-gradient(135deg, #f7b928, #b8860b); --btn-g: rgba(255,255,255,0.1);
|
||
--btn-g-txt: #e4e6eb;
|
||
--log-bg: rgba(0, 0, 0, 0.85);
|
||
--shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||
--glass-sh: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||
}
|
||
body.midnight {
|
||
--bg-grad: linear-gradient(135deg, #0f2027, #203a43, #2c5364);
|
||
--bg-sec: rgba(15, 23, 42, 0.6);
|
||
--bg-ter: rgba(30, 41, 59, 0.7);
|
||
--txt: #f1f5f9; --txt-sec: #94a3b8; --bd: rgba(255, 255, 255, 0.08);
|
||
--btn-s: linear-gradient(135deg, #3b82f6, #1d4ed8); --btn-r: linear-gradient(135deg, #10b981, #047857); --btn-d: linear-gradient(135deg, #ef4444, #b91c1c); --btn-u: linear-gradient(135deg, #f59e0b, #b45309); --btn-g: rgba(255,255,255,0.1);
|
||
--btn-g-txt: #f1f5f9;
|
||
}
|
||
body.cyber {
|
||
--bg-grad: radial-gradient(circle at center, #001100 0%, #000000 100%);
|
||
--bg-sec: rgba(0, 20, 0, 0.6);
|
||
--bg-ter: rgba(0, 40, 0, 0.7);
|
||
--txt: #00ff00; --txt-sec: #00cc00; --bd: rgba(0, 255, 0, 0.3);
|
||
--btn-s: linear-gradient(135deg, #007700, #004400); --btn-r: linear-gradient(135deg, #00aa00, #005500); --btn-d: linear-gradient(135deg, #aa0000, #550000); --btn-u: linear-gradient(135deg, #aaaa00, #555500); --btn-g: rgba(0, 255, 0, 0.1);
|
||
--btn-g-txt: #00ff00;
|
||
--radius: 0px;
|
||
--shadow: 0 0 12px rgba(0, 255, 0, 0.2);
|
||
--glass-sh: inset 0 0 0 1px rgba(0, 255, 0, 0.2);
|
||
}
|
||
|
||
body{font-family:'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background:var(--bg-grad); color:var(--txt); margin:0; display:flex; flex-direction:column; height:100vh; overflow:hidden;}
|
||
* { box-sizing: border-box; }
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.3); border-radius: var(--radius); }
|
||
::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.5); }
|
||
|
||
.hdr{background:var(--bg-sec); backdrop-filter:var(--glass-blur); -webkit-backdrop-filter:var(--glass-blur); padding:0 20px; border-bottom:1px solid var(--bd); display:flex; justify-content:space-between; align-items:center; height:60px; flex-shrink:0; box-shadow:var(--shadow), var(--glass-sh); z-index:10;}
|
||
.bar{background:var(--bg-sec); backdrop-filter:var(--glass-blur); -webkit-backdrop-filter:var(--glass-blur); padding:10px 20px; display:flex; gap:10px; border-bottom:1px solid var(--bd); flex-wrap:wrap; flex-shrink:0; align-items:center; z-index:9; box-shadow:var(--shadow), var(--glass-sh);}
|
||
|
||
button, input, select, textarea {
|
||
font-family: inherit; font-size: 14px; color: var(--txt);
|
||
border: 1px solid var(--bd); border-radius: var(--radius);
|
||
background: var(--bg-ter);
|
||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); outline: none;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
button {
|
||
height: var(--comp-h); padding: 0 20px; cursor: pointer; color: #fff; font-weight: 600;
|
||
display: flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap; border: 1px solid rgba(255,255,255,0.1);
|
||
box-shadow: 0 4px 10px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.2);
|
||
}
|
||
button:hover { transform: translateY(-1px); filter: brightness(1.1); box-shadow: 0 6px 15px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.3); }
|
||
button:active { transform: translateY(1px) scale(0.97); box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); }
|
||
input, select { height: var(--comp-h); padding: 0 12px; width: 100%; backdrop-filter: blur(5px); }
|
||
input:focus, select:focus, textarea:focus { border-color: rgba(24, 119, 242, 0.6); box-shadow: 0 0 0 3px rgba(24, 119, 242, 0.2), inset 0 1px 2px rgba(0,0,0,0.1); background: var(--bg-sec); }
|
||
|
||
.btn-s{background:var(--btn-s)}.btn-r{background:var(--btn-r)}.btn-d{background:var(--btn-d)}.btn-u{background:var(--btn-u)}
|
||
.btn-g{background:var(--btn-g); color:var(--btn-g-txt); border:1px solid var(--bd); box-shadow:var(--shadow);}
|
||
|
||
.main{display:flex;flex:1;overflow:hidden; position:relative;}
|
||
#ed{flex:1;font-size:14px;}
|
||
.sb{width:340px; background:var(--bg-sec); backdrop-filter:var(--glass-blur); -webkit-backdrop-filter:var(--glass-blur); border-left:1px solid var(--bd); display:flex; flex-direction:column; overflow-y:auto; flex-shrink:0; box-shadow: -4px 0 20px rgba(0,0,0,0.1), inset 1px 0 0 rgba(255,255,255,0.1);}
|
||
.sec{padding:20px; border-bottom:1px solid var(--bd); display:flex; flex-direction:column; gap:12px;}
|
||
.sec h3{margin:0 0 8px 0; font-size:16px; font-weight:700; color:var(--txt); text-shadow: 0 1px 2px rgba(0,0,0,0.1);}
|
||
|
||
.ovl{position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); z-index:999; display:none; justify-content:center; align-items:center; padding:20px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); animation: fadeIn 0.3s ease;}
|
||
.mod{background:var(--bg-sec); backdrop-filter:var(--glass-blur); -webkit-backdrop-filter:var(--glass-blur); padding:24px; border-radius:calc(var(--radius) + 4px); width:100%; max-width:600px; border:1px solid var(--bd); display:flex; flex-direction:column; max-height:90vh; box-shadow: 0 25px 50px rgba(0,0,0,0.25), inset 0 0 0 1px rgba(255,255,255,0.1); animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);}
|
||
.mod h3{margin-top:0; color:var(--txt); border-bottom:1px solid var(--bd); padding-bottom:15px; margin-bottom:20px; font-size:18px;}
|
||
@keyframes slideUp { from { opacity:0; transform: translateY(20px) scale(0.95); } to { opacity:1; transform: translateY(0) scale(1); } }
|
||
|
||
.bk-item{background:var(--bg-ter); padding:10px 12px; margin-bottom:8px; border:1px solid var(--bd); border-radius:var(--radius); display:flex; justify-content:space-between; align-items:center; height:auto; min-height:44px; transition: all 0.2s;}
|
||
.bk-item:hover { background: var(--bg-sec); transform: translateX(2px); box-shadow: var(--shadow); border-color: rgba(255,255,255,0.3); }
|
||
.bk-item div:first-child { flex: 1; min-width: 0; padding-right: 10px; display: flex; flex-direction: column; justify-content: center; }
|
||
.bk-item b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; font-size: 14px; margin-bottom: 2px;}
|
||
.bk-btns { display: flex; gap: 6px; flex-shrink: 0; }
|
||
.bk-btns button { width: 32px; padding: 0; height: 32px; font-size: 16px; border-radius: 50%; border-radius: var(--radius); }
|
||
|
||
#bk-content { background: var(--log-bg); color: var(--log-txt); font-family: 'Consolas', 'Monaco', monospace; padding: 15px; border-radius: var(--radius); border: 1px solid var(--bd); white-space: pre-wrap; overflow-y: auto; flex-grow: 1; min-height: 200px; max-height: 60vh; font-size: 13px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.2); }
|
||
.bk-controls {display:flex; gap:10px; align-items:center; background:var(--bg-ter); padding:8px 12px; border-radius:var(--radius); border: 1px solid var(--bd); box-shadow: inset 0 2px 5px rgba(0,0,0,0.05);}
|
||
.bk-controls input {width: 60px !important; text-align: center; margin:0; height: 32px; padding: 0;}
|
||
.bk-controls span {font-size:13px; color:var(--txt-sec); white-space: nowrap;}
|
||
.bk-controls button { height: 32px; font-size: 12px; padding: 0 12px; margin-left: auto; }
|
||
#bk-list { max-height: 250px; overflow-y: auto; padding-right: 5px; }
|
||
|
||
.prof-row {display:flex; gap:10px; align-items:center;}
|
||
#prof-sel { flex: 1; font-weight: 500;}
|
||
.prof-btns { display: flex; gap: 10px; margin-top: 5px; }
|
||
.prof-btns button { flex: 1; }
|
||
.proxy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||
|
||
#last-load { font-size: 12px; color: var(--txt-sec); background: var(--bg-ter); border: 1px solid var(--bd); padding: 4px 12px; border-radius: 20px; display: inline-flex; align-items: center; height: 28px; font-weight: 500; box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); }
|
||
|
||
#cons{background:var(--log-bg); color:var(--log-txt); font-family:'Consolas', 'Monaco', monospace; padding:15px; height:350px; overflow:auto; white-space:pre-wrap; font-size:13px; border:1px solid var(--bd); border-radius:var(--radius); box-shadow: inset 0 2px 10px rgba(0,0,0,0.2);}
|
||
.g-list {display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; overflow-y: auto; padding: 5px; margin-top: 5px; max-height: 300px;}
|
||
.g-item { position: relative; }
|
||
.g-item input { position: absolute; opacity: 0; cursor: pointer; height: 0; width: 0; }
|
||
.g-item label {display: flex; align-items: center; justify-content: center; background: var(--bg-ter); border: 1px solid var(--bd); border-radius: var(--radius); padding: 10px 5px; font-size: 13px; color: var(--txt); cursor: pointer; transition: all 0.2s; text-align: center; user-select: none; word-break: break-word; min-height: 40px; font-weight: 500;}
|
||
.g-item label:hover { transform: translateY(-1px); background: var(--bg-sec); box-shadow: var(--shadow); }
|
||
.g-item input:checked + label {background: linear-gradient(135deg, #1877f2, #2851a3); color: white; border-color: transparent; font-weight: bold; box-shadow: 0 4px 12px rgba(24, 119, 242, 0.4);}
|
||
|
||
.toast {position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: var(--bg-sec); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: var(--txt); padding: 12px 24px; border-radius: 30px; z-index: 3000; display: none; box-shadow: 0 10px 25px rgba(0,0,0,0.2), inset 0 0 0 1px rgba(255,255,255,0.1); border: none; font-weight: 500; align-items: center; gap: 10px; animation: slideUpToast 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);}
|
||
@keyframes slideUpToast { from { opacity:0; transform: translate(-50%, 20px); } to { opacity:1; transform: translate(-50%, 0); } }
|
||
.toast-icon { font-size: 18px; }
|
||
|
||
.modal-tabs { display: flex; border-bottom: 1px solid var(--bd); margin-bottom: 20px; gap: 20px;}
|
||
.modal-tabs button { flex: 1; justify-content: center; background: none; border: none; border-bottom: 3px solid transparent; border-radius: 0; padding: 10px; font-size: 15px; color: var(--txt-sec); height: auto; box-shadow: none; font-weight:600; transition: color 0.2s; }
|
||
.modal-tabs button:hover { color: var(--txt); background: none; transform:none; }
|
||
.modal-tabs button.active { color: #1877f2; border-bottom-color: #1877f2; }
|
||
.tab-content { display: none; animation: fadeIn 0.2s ease-out; }
|
||
.tab-content.active { display: block; }
|
||
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||
|
||
.file-drop-zone { border: 2px dashed var(--bd); border-radius: var(--radius); padding: 30px; text-align: center; color: var(--txt-sec); cursor: pointer; transition: all 0.2s; margin-bottom: 15px; background: rgba(0,0,0,0.02); }
|
||
.file-drop-zone:hover { background: var(--bg-sec); border-color: rgba(24,119,242,0.5); color: #1877f2; transform: translateY(-1px); }
|
||
.file-drop-zone.dragover { background: rgba(24,119,242,0.1); border-color: #1877f2; }
|
||
|
||
.log-time { color: #888; margin-right: 8px; font-size: 0.9em; }
|
||
.log-info { color: #4dabf7; font-weight: bold; }
|
||
.log-warn { color: #ff922b; font-weight: bold; }
|
||
.log-err { color: #ff6b6b; font-weight: bold; }
|
||
.log-green { color: #51cf66; }
|
||
.log-yellow { color: #fcc419; }
|
||
|
||
.logo { font-size: 22px; font-weight: 800; background: linear-gradient(135deg, #1877f2, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: -0.5px; text-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||
|
||
@media (max-width: 768px) {
|
||
.main { flex-direction: column; }
|
||
.sb { width: 100%; border-left: none; border-top: 1px solid var(--bd); height: auto; max-height: 50vh; box-shadow: 0 -2px 10px rgba(0,0,0,0.05); }
|
||
#ed { flex: 1; min-height: 40vh; }
|
||
.bar { padding: 8px; gap: 6px; }
|
||
.bar button, .bar select { flex: 1 1 calc(33% - 6px); justify-content: center; font-size: 13px; padding: 0 10px; }
|
||
.mod { width: 95%; max-height: 95vh; padding: 15px; }
|
||
.hdr { padding: 8px 10px; height: auto; min-height: 50px; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||
.logo { font-size: 16px; }
|
||
.hdr button { font-size: 11px !important; padding: 0 6px !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="toast" id="toast"><span class="toast-icon"></span> <span id="toast-msg" data-i18n="toast_saved">Saved</span></div>
|
||
<div class="hdr">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div class="logo" data-i18n="title">Mihomo Studio</div>
|
||
<span style="color:var(--txt-sec);font-size:11px;background:var(--bg-ter);padding:2px 6px;border-radius:4px;border:1px solid var(--bd)">v1.4</span>
|
||
</div>
|
||
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; justify-content:center;">
|
||
<button onclick="restartService()" class="btn-r" style="height:28px; padding:0 10px; font-size:12px; gap:4px;" title="Перезапустить сервис"><span data-i18n="restart_service_short">🔄 Рестарт</span></button>
|
||
<button onclick="updateStudio()" class="btn-u" style="height:28px; padding:0 10px; font-size:12px; gap:4px;" title="Проверить обновления"><span data-i18n="update_btn_short">🔄 Обновить</span></button>
|
||
<div id="last-load" data-i18n="last_load_lbl">Loaded: __TIME__</div>
|
||
</div>
|
||
</div>
|
||
<div class="bar">
|
||
<button onclick="save('save')" class="btn-s" data-i18n="save">💾 Сохранить</button>
|
||
<button onclick="save('restart')" class="btn-r" data-i18n="restart">🚀 Рестарт</button>
|
||
<button onclick="openPanel()" class="btn-g" title="Открыть встроенную панель Mihomo" data-i18n="panel">🌐 Панель</button>
|
||
|
||
<div style="display:flex; gap:8px; margin-left:auto; flex-wrap: wrap;">
|
||
<select id="lang-sel" onchange="setLang(this.value)" style="width:auto; min-width: 80px;">
|
||
<option value="ru">🇷🇺 RU</option>
|
||
<option value="en">🇺🇸 EN</option>
|
||
<option value="uk">🇺🇦 UA</option>
|
||
</select>
|
||
<select id="theme-sel" onchange="setTheme(this.value)" style="width:auto; min-width: 100px;">
|
||
<option value="dark" data-i18n="theme_dark">🌑 Dark</option>
|
||
<option value="light" data-i18n="theme_light">☀️ Light</option>
|
||
<option value="midnight" data-i18n="theme_midnight">🌃 Midnight</option>
|
||
<option value="cyber" data-i18n="theme_cyber">👾 Cyber</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="main">
|
||
<div id="ed"></div>
|
||
<div class="sb">
|
||
<div class="sec">
|
||
<h3><span data-i18n="profiles">Профили</span></h3>
|
||
<div class="prof-row">
|
||
<select id="prof-sel">__PROFILES__</select>
|
||
<button onclick="switchProf()" class="btn-s" style="padding:0; width:40px; justify-content:center;" title="Выбрать" data-i18n="select">✔</button>
|
||
<button onclick="downloadProf()" class="btn-g" style="padding:0; width:40px; justify-content:center;" title="Скачать" data-i18n="download">💾</button>
|
||
</div>
|
||
<div class="prof-btns">
|
||
<button onclick="openAddProf()" class="btn-u" data-i18n="create">➕ Создать</button>
|
||
<button onclick="delProf()" class="btn-d" data-i18n="delete">🗑 Удалить</button>
|
||
</div>
|
||
</div>
|
||
<div class="sec">
|
||
<h3><span data-i18n="proxy_mgmt">Управление</span></h3>
|
||
<div class="proxy-grid">
|
||
<button onclick="openAddProxyModal()" class="btn-s" data-i18n="add">➕ Добавить</button>
|
||
<button onclick="openEditProxyModal()" class="btn-u" data-i18n="edit">✏️ Заменить</button>
|
||
<button onclick="showRename()" class="btn-g" data-i18n="rename">Переименовать</button>
|
||
<button onclick="showDel()" class="btn-d" data-i18n="delete">🗑 Удалить</button>
|
||
</div>
|
||
</div>
|
||
<div class="sec">
|
||
<h3><span data-i18n="backups">Бэкапы</span></h3>
|
||
<div class="bk-controls">
|
||
<span data-i18n="keep">Оставить:</span>
|
||
<input type="number" id="bk-lim" value="5" min="1" max="50">
|
||
<button onclick="cleanBackups()" class="btn-g" data-i18n="clean">Очистить</button>
|
||
</div>
|
||
<div id="bk-list">__BACKUPS__</div>
|
||
</div>
|
||
<div class="sec" style="text-align: center; font-size: 11px; color: var(--txt-sec); padding: 15px; border-bottom: none; margin-top: auto;">
|
||
Mihomo Studio © 2025 - 2026
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="m-grp" class="ovl"><div class="mod"><h3 data-i18n="modal_groups">Добавить в группы:</h3>
|
||
<div style="display:flex; gap:10px; margin-bottom:15px"><button onclick="tgGrp(true)" class="btn-g" style="flex:1; justify-content:center" data-i18n="btn_sel_all">☑ Выбрать все</button><button onclick="tgGrp(false)" class="btn-g" style="flex:1; justify-content:center" data-i18n="btn_sel_none">☐ Снять все</button></div>
|
||
<div id="g-cnt" class="g-list"></div>
|
||
<div style="display:flex;justify-content:flex-end;gap:12px;margin-top:20px;padding-top:15px;border-top:1px solid var(--bd)"><button onclick="applyVless()" class="btn-s" style="flex:1;justify-content:center" data-i18n="btn_add">Добавить</button><button onclick="closeM('m-grp')" class="btn-g" style="flex:1;justify-content:center" data-i18n="btn_cancel">Отмена</button></div></div></div>
|
||
|
||
<div id="m-del" class="ovl"><div class="mod"><h3 data-i18n="modal_del_proxy">Удалить прокси</h3><select id="sel-del"></select><div style="display:flex;justify-content:flex-end;gap:12px;margin-top:20px"><button onclick="doDel()" class="btn-d" data-i18n="delete">Удалить</button><button onclick="closeM('m-del')" class="btn-g" data-i18n="btn_cancel">Отмена</button></div></div></div>
|
||
<div id="m-con" class="ovl"><div class="mod"><h3 data-i18n="modal_console">Консоль</h3><div id="cons">...</div><div style="display:flex;justify-content:flex-end;gap:12px;margin-top:20px"><button onclick="location.reload()" class="btn-s" data-i18n="btn_update">Обновить</button><button onclick="closeM('m-con')" class="btn-g" data-i18n="btn_close">Закрыть</button></div></div></div>
|
||
|
||
<div id="m-ren" class="ovl"><div class="mod">
|
||
<h3 data-i18n="modal_ren_proxy">Переименовать прокси</h3>
|
||
<p style="margin-top:0;font-size:13px;color:var(--txt-sec);margin-bottom:8px;" data-i18n="lbl_sel_ren">Выберите прокси для переименования:</p>
|
||
<select id="sel-ren-proxy"></select>
|
||
<p style="margin-top:15px;font-size:13px;color:var(--txt-sec);margin-bottom:8px;" data-i18n="lbl_new_name">Новое имя:</p>
|
||
<input id="inp-ren-newname" placeholder="Введите новое имя" data-i18n-ph="ph_new_name">
|
||
<div style="display:flex;justify-content:flex-end;gap:12px;margin-top:25px">
|
||
<button onclick="doRename()" class="btn-s" data-i18n="btn_rename">Переименовать</button>
|
||
<button onclick="closeM('m-ren')" class="btn-g" data-i18n="btn_cancel">Отмена</button>
|
||
</div>
|
||
</div></div>
|
||
|
||
<div id="m-add-prof" class="ovl"><div class="mod">
|
||
<h3 data-i18n="modal_new_prof">Новый профиль</h3>
|
||
<label style="font-size:13px; margin-bottom:6px; color:var(--txt-sec); display:block;" data-i18n="lbl_prof_name">Имя (англ, без пробелов):</label>
|
||
<input id="np-name" placeholder="my_config" style="margin-bottom:15px">
|
||
<label style="font-size:13px; margin-bottom:6px; color:var(--txt-sec); display:block;" data-i18n="lbl_content">Содержимое:</label>
|
||
<div style="display:flex; gap:5px; margin-bottom:10px">
|
||
<button onclick="document.getElementById('np-file').click()" class="btn-u" style="flex:1;justify-content:center" data-i18n="btn_load_file">📂 Загрузить файл</button>
|
||
</div>
|
||
<input type="file" id="np-file" style="display:none" onchange="loadProfFile(this)">
|
||
<textarea id="np-content" rows="10" placeholder="Вставьте YAML конфиг сюда..." data-i18n-ph="ph_paste_yaml"></textarea>
|
||
<div style="display:flex;justify-content:flex-end;gap:12px;margin-top:20px">
|
||
<button onclick="saveNewProf()" class="btn-s" data-i18n="btn_save">Сохранить</button>
|
||
<button onclick="closeM('m-add-prof')" class="btn-g" data-i18n="btn_cancel">Отмена</button>
|
||
</div>
|
||
</div></div>
|
||
|
||
<div id="addProxyModal" class="ovl"><div class="mod">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--bd); padding-bottom:15px; margin-bottom:20px;">
|
||
<h3 id="proxyModalTitle" style="margin:0; padding:0; border:0;" data-i18n="modal_add_proxy">Добавить прокси</h3>
|
||
<button onclick="closeM('addProxyModal')" style="width:32px; height:32px; padding:0; background:transparent; color:var(--txt-sec); font-size:20px; box-shadow:none;">✕</button>
|
||
</div>
|
||
|
||
<div id="edit-proxy-container" style="display:none; margin-bottom:15px;">
|
||
<label style="font-size:13px; margin-bottom:6px; color:var(--txt-sec); display:block;" data-i18n="lbl_select_edit">Выберите прокси для изменения:</label>
|
||
<select id="edit-proxy-sel"></select>
|
||
<div style="font-size:12px; color:var(--btn-u); margin-top:8px; background:rgba(247, 185, 40, 0.1); padding:8px; border-radius:4px;" data-i18n="warn_edit">⚠️ Данные этого прокси будут полностью заменены новыми!</div>
|
||
</div>
|
||
|
||
<div class="modal-tabs">
|
||
<button class="active" onclick="switchTab(event, 'vlessTab')" data-i18n="tab_vless">VLESS</button>
|
||
<button onclick="switchTab(event, 'wgTab')" data-i18n="tab_wg">WireGuard|AmneziaWG</button>
|
||
</div>
|
||
|
||
<div id="vlessTab" class="tab-content active">
|
||
<label style="font-size:13px; margin-bottom:6px; color:var(--txt-sec); display:block;" data-i18n="lbl_vless_link">Ссылка VLESS:</label>
|
||
<input id="vlessLink" placeholder="vless://..." style="margin-bottom:15px;">
|
||
|
||
<div id="vless-name-block">
|
||
<label style="font-size:13px; margin-bottom:6px; color:var(--txt-sec); display:block;" data-i18n="lbl_proxy_name">Имя прокси (необязательно):</label>
|
||
<input id="vlessProxyName" placeholder="Автоматически из ссылки" data-i18n-ph="ph_auto_vless" style="margin-bottom:15px;">
|
||
</div>
|
||
|
||
<button onclick="parseVless()" class="btn-s" style="width:100%; justify-content:center;" data-i18n="btn_save">Сохранить</button>
|
||
</div>
|
||
|
||
<div id="wgTab" class="tab-content">
|
||
<label style="font-size:13px; margin-bottom:6px; color:var(--txt-sec); display:block;" data-i18n="lbl_wg_conf">Конфигурация WireGuard:</label>
|
||
<textarea id="wgConfig" rows="8" placeholder="Вставьте содержимое .conf файла сюда..." data-i18n-ph="ph_paste_conf" style="width:100%; margin-bottom:15px;"></textarea>
|
||
|
||
<div id="wg-name-block">
|
||
<label style="font-size:13px; margin-bottom:6px; color:var(--txt-sec); display:block;" data-i18n="lbl_proxy_name">Имя прокси (необязательно):</label>
|
||
<input id="wgProxyName" placeholder="Автоматически из Endpoint" data-i18n-ph="ph_auto_wg" style="margin-bottom:15px;">
|
||
</div>
|
||
|
||
<input type="file" id="wgFile" accept=".conf" style="display:none" onchange="loadWgFile(this)">
|
||
<button onclick="document.getElementById('wgFile').click()" class="btn-u" style="width:100%; justify-content:center; margin-bottom:15px;" data-i18n="btn_load_file">📂 Или загрузить .conf файл</button>
|
||
<button onclick="addWireguard()" class="btn-s" style="width:100%; justify-content:center;" data-i18n="btn_save">Сохранить</button>
|
||
</div>
|
||
</div></div>
|
||
|
||
<script>
|
||
var ed=ace.edit("ed");ed.setTheme("ace/theme/monokai");ed.session.setMode("ace/mode/yaml");ed.setOptions({fontSize:14,tabSize:2,useSoftTabs:true});
|
||
var pData=null, GRP_KEY="mihomo_grp_sel", LIM_KEY="mihomo_bk_lim", THM_KEY="mihomo_theme", LANG_KEY="mihomo_lang";
|
||
var initialConfig = __JSON_CONTENT__;
|
||
var isEditMode = false;
|
||
var currLang = 'ru';
|
||
|
||
const TR = {
|
||
ru: {
|
||
title: "Mihomo Studio",
|
||
save: "💾 Сохранить",
|
||
restart: "🚀 Рестарт",
|
||
panel: "🌐 Панель",
|
||
update_btn: "🔄 Проверить обновления",
|
||
restart_service: "🔄 Рестарт",
|
||
profiles: "Профили",
|
||
create: "➕ Создать",
|
||
delete: "🗑 Удалить",
|
||
select: "✔",
|
||
download: "💾",
|
||
proxy_mgmt: "Управление",
|
||
add: "➕ Добавить",
|
||
edit: "✏️ Заменить",
|
||
rename: "Переименовать",
|
||
backups: "Бэкапы",
|
||
clean: "Очистить",
|
||
keep: "Оставить:",
|
||
theme_dark: "🌑 Тёмная",
|
||
theme_light: "☀️ Светлая",
|
||
theme_midnight: "🌃 Полночь",
|
||
theme_cyber: "👾 Кибер",
|
||
toast_saved: "✅ Успешно сохранено",
|
||
toast_cleaned: "🧹 Очищено",
|
||
toast_deleted: "🗑 Удалено",
|
||
toast_restored: "♻️ Восстановлено",
|
||
toast_added: "✅ Добавлено",
|
||
toast_renamed: "✏️ Прокси переименован",
|
||
toast_updated: "✏️ Данные прокси обновлены",
|
||
toast_checking: "🔍 Проверка обновлений...",
|
||
toast_restarting: "🔄 Перезапуск сервиса...",
|
||
confirm_switch: "Переключиться на профиль {0}?",
|
||
confirm_del_prof: "Удалить профиль {0}? Это действие необратимо.",
|
||
confirm_del_bk: "Удалить бэкап {0}?",
|
||
confirm_clean: "Оставить только {0} последних бэкапов?",
|
||
confirm_restore: "Восстановить {0}? Текущий конфиг будет перезаписан.",
|
||
confirm_del_proxy: "Удалить?",
|
||
confirm_replace: "Заменить данные прокси '{0}'?",
|
||
confirm_update: "Проверить обновления и установить?",
|
||
confirm_restart_service: "Перезапустить веб-сервис Mihomo Studio?",
|
||
prompt_enter_name: "Введите имя!",
|
||
error_invalid_name: "Недопустимое имя!",
|
||
error_exists: "Профиль с таким именем уже существует",
|
||
error_no_proxy_edit: "Выберите прокси для редактирования",
|
||
error_empty_wg: "Конфигурация WireGuard не может быть пустой.",
|
||
alert_updating: "Обновление запущено. Сервис перезапускается...",
|
||
modal_add_proxy: "Добавить прокси",
|
||
modal_edit_proxy: "Изменить прокси",
|
||
lbl_vless_link: "Ссылка VLESS:",
|
||
lbl_proxy_name: "Имя прокси (необязательно):",
|
||
lbl_wg_conf: "Конфигурация WireGuard:",
|
||
btn_add: "Добавить",
|
||
btn_save: "Сохранить",
|
||
btn_cancel: "Отмена",
|
||
btn_restore: "Восстановить",
|
||
btn_close: "Закрыть",
|
||
btn_update: "Обновить",
|
||
tab_vless: "VLESS",
|
||
tab_wg: "WireGuard|AmneziaWG",
|
||
lbl_select_edit: "Выберите прокси для изменения:",
|
||
warn_edit: "⚠️ Данные этого прокси будут полностью заменены новыми!",
|
||
modal_new_prof: "Новый профиль",
|
||
lbl_prof_name: "Имя (англ, без пробелов):",
|
||
lbl_content: "Содержимое:",
|
||
btn_load_file: "📂 Загрузить файл",
|
||
ph_paste_yaml: "Вставьте YAML конфиг сюда...",
|
||
ph_auto_vless: "Автоматически из ссылки",
|
||
ph_auto_wg: "Автоматически из Endpoint",
|
||
ph_paste_conf: "Вставьте содержимое .conf файла сюда...",
|
||
modal_groups: "Добавить в группы:",
|
||
btn_sel_all: "☑ Выбрать все",
|
||
btn_sel_none: "☐ Снять все",
|
||
modal_del_proxy: "Удалить прокси",
|
||
modal_ren_proxy: "Переименовать прокси",
|
||
lbl_sel_ren: "Выберите прокси для переименования:",
|
||
lbl_new_name: "Новое имя:",
|
||
ph_new_name: "Введите новое имя",
|
||
btn_rename: "Перейменувати",
|
||
modal_console: "Консоль",
|
||
modal_view_bk: "Просмотр бэкапа",
|
||
log_loading: "⏳ Выполнение xkeen -restart...",
|
||
last_load_lbl: "Загружено: __TIME__",
|
||
last_saved: "Сохранено:",
|
||
restart_service_short: "🔄 Рестарт",
|
||
update_btn_short: "🔄 Обновить"
|
||
},
|
||
uk: {
|
||
title: "Mihomo Studio",
|
||
save: "💾 Зберегти",
|
||
restart: "🚀 Рестарт",
|
||
panel: "🌐 Панель",
|
||
update_btn: "🔄 Перевірити оновлення",
|
||
restart_service: "🔄 Рестарт",
|
||
profiles: "Профілі",
|
||
create: "➕ Створити",
|
||
delete: "🗑 Видалити",
|
||
select: "✔",
|
||
download: "💾",
|
||
proxy_mgmt: "Керування",
|
||
add: "➕ Додати",
|
||
edit: "✏️ Замінити",
|
||
rename: "Перейменувати",
|
||
backups: "Бекапи",
|
||
clean: "Очистити",
|
||
keep: "Залишити:",
|
||
theme_dark: "🌑 Темна",
|
||
theme_light: "☀️ Світла",
|
||
theme_midnight: "🌃 Північ",
|
||
theme_cyber: "👾 Кібер",
|
||
toast_saved: "✅ Успішно збережено",
|
||
toast_cleaned: "🧹 Очищено",
|
||
toast_deleted: "🗑 Видалено",
|
||
toast_restored: "♻️ Відновлено",
|
||
toast_added: "✅ Додано",
|
||
toast_renamed: "✏️ Проксі перейменовано",
|
||
toast_updated: "✏️ Дані проксі оновлено",
|
||
toast_checking: "🔍 Перевірка оновлень...",
|
||
toast_restarting: "🔄 Перезапуск сервісу...",
|
||
confirm_switch: "Переключитися на профіль {0}?",
|
||
confirm_del_prof: "Видалити профіль {0}? Ця дія незворотна.",
|
||
confirm_del_bk: "Видалити бекап {0}?",
|
||
confirm_clean: "Залишити тільки {0} останніх бекапів?",
|
||
confirm_restore: "Відновити {0}? Поточний конфіг буде перезаписано.",
|
||
confirm_del_proxy: "Видалити?",
|
||
confirm_replace: "Замінити дані проксі '{0}'?",
|
||
confirm_update: "Перевірити оновлення та встановити?",
|
||
confirm_restart_service: "Перезапустити веб-сервіс Mihomo Studio?",
|
||
prompt_enter_name: "Введіть ім'я!",
|
||
error_invalid_name: "Неприпустиме ім'я!",
|
||
error_exists: "Профіль з таким ім'ям вже існує",
|
||
error_no_proxy_edit: "Виберіть проксі для редагування",
|
||
error_empty_wg: "Конфігурація WireGuard не може бути порожньою.",
|
||
alert_updating: "Оновлення запущено. Сервіс перезапускається...",
|
||
modal_add_proxy: "Додати проксі",
|
||
modal_edit_proxy: "Змінити проксі",
|
||
lbl_vless_link: "Посилання VLESS:",
|
||
lbl_proxy_name: "Ім'я проксі (необов'язково):",
|
||
lbl_wg_conf: "Конфігурація WireGuard:",
|
||
btn_add: "Додати",
|
||
btn_save: "Зберегти",
|
||
btn_cancel: "Скасувати",
|
||
btn_restore: "Відновити",
|
||
btn_close: "Закрити",
|
||
btn_update: "Оновити",
|
||
tab_vless: "VLESS",
|
||
tab_wg: "WireGuard|AmneziaWG",
|
||
lbl_select_edit: "Виберіть проксі для зміни:",
|
||
warn_edit: "⚠️ Дані цього проксі будуть повністю замінені новими!",
|
||
modal_new_prof: "Новий профіль",
|
||
lbl_prof_name: "Ім'я (англ, без пробілів):",
|
||
lbl_content: "Вміст:",
|
||
btn_load_file: "📂 Завантажити файл",
|
||
ph_paste_yaml: "Вставте YAML конфіг сюди...",
|
||
ph_auto_vless: "Автоматично з посилання",
|
||
ph_auto_wg: "Автоматично з Endpoint",
|
||
ph_paste_conf: "Вставте вміст .conf файлу сюди...",
|
||
modal_groups: "Додати в групи:",
|
||
btn_sel_all: "☑ Обрати всі",
|
||
btn_sel_none: "☐ Зняти всі",
|
||
modal_del_proxy: "Видалити проксі",
|
||
modal_ren_proxy: "Перейменувати проксі",
|
||
lbl_sel_ren: "Виберіть проксі для перейменування:",
|
||
lbl_new_name: "Нове ім'я:",
|
||
ph_new_name: "Введіть нове ім'я",
|
||
btn_rename: "Перейменувати",
|
||
modal_console: "Консоль",
|
||
modal_view_bk: "Перегляд бекапу",
|
||
log_loading: "⏳ Виконання xkeen -restart...",
|
||
last_load_lbl: "Завантажено: __TIME__",
|
||
last_saved: "Збережено:",
|
||
restart_service_short: "🔄 Рестарт",
|
||
update_btn_short: "🔄 Оновити"
|
||
},
|
||
en: {
|
||
title: "Mihomo Studio",
|
||
save: "💾 Save",
|
||
restart: "🚀 Restart",
|
||
panel: "🌐 Panel",
|
||
update_btn: "🔄 Check for updates",
|
||
restart_service: "🔄 Restart",
|
||
profiles: "Profiles",
|
||
create: "➕ Create",
|
||
delete: "🗑 Delete",
|
||
select: "✔",
|
||
download: "💾",
|
||
proxy_mgmt: "Management",
|
||
add: "➕ Add",
|
||
edit: "✏️ Replace",
|
||
rename: "Rename",
|
||
backups: "Backups",
|
||
clean: "Clean",
|
||
keep: "Keep:",
|
||
theme_dark: "🌑 Dark",
|
||
theme_light: "☀️ Light",
|
||
theme_midnight: "🌃 Midnight",
|
||
theme_cyber: "👾 Cyber",
|
||
toast_saved: "✅ Saved successfully",
|
||
toast_cleaned: "🧹 Cleaned",
|
||
toast_deleted: "🗑 Deleted",
|
||
toast_restored: "♻️ Restored",
|
||
toast_added: "✅ Added",
|
||
toast_renamed: "✏️ Proxy renamed",
|
||
toast_updated: "✏️ Proxy data updated",
|
||
toast_checking: "🔍 Checking for updates...",
|
||
toast_restarting: "🔄 Restarting service...",
|
||
confirm_switch: "Switch to profile {0}?",
|
||
confirm_del_prof: "Delete profile {0}? This action is irreversible.",
|
||
confirm_del_bk: "Delete backup {0}?",
|
||
confirm_clean: "Keep only the last {0} backups?",
|
||
confirm_restore: "Restore {0}? Current config will be overwritten.",
|
||
confirm_del_proxy: "Delete?",
|
||
confirm_replace: "Replace data for proxy '{0}'?",
|
||
confirm_update: "Check for updates and install?",
|
||
confirm_restart_service: "Restart Mihomo Studio web service?",
|
||
prompt_enter_name: "Enter name!",
|
||
error_invalid_name: "Invalid name!",
|
||
error_exists: "Profile with this name already exists",
|
||
error_no_proxy_edit: "Select a proxy to edit",
|
||
error_empty_wg: "WireGuard configuration cannot be empty.",
|
||
alert_updating: "Update started. Service is restarting...",
|
||
modal_add_proxy: "Add Proxy",
|
||
modal_edit_proxy: "Edit Proxy",
|
||
lbl_vless_link: "VLESS Link:",
|
||
lbl_proxy_name: "Proxy Name (optional):",
|
||
lbl_wg_conf: "WireGuard Config:",
|
||
btn_add: "Add",
|
||
btn_save: "Save",
|
||
btn_cancel: "Cancel",
|
||
btn_restore: "Restore",
|
||
btn_close: "Close",
|
||
btn_update: "Update",
|
||
tab_vless: "VLESS",
|
||
tab_wg: "WireGuard|AmneziaWG",
|
||
lbl_select_edit: "Select proxy to replace:",
|
||
warn_edit: "⚠️ This proxy's data will be fully replaced!",
|
||
modal_new_prof: "New Profile",
|
||
lbl_prof_name: "Name (English, no spaces):",
|
||
lbl_content: "Content:",
|
||
btn_load_file: "📂 Upload File",
|
||
ph_paste_yaml: "Paste YAML config here...",
|
||
ph_auto_vless: "Automatically from link",
|
||
ph_auto_wg: "Automatically from Endpoint",
|
||
ph_paste_conf: "Paste .conf file content here...",
|
||
modal_groups: "Add to groups:",
|
||
btn_sel_all: "☑ Select All",
|
||
btn_sel_none: "☐ Select None",
|
||
modal_del_proxy: "Delete Proxy",
|
||
modal_ren_proxy: "Rename Proxy",
|
||
lbl_sel_ren: "Select proxy to rename:",
|
||
lbl_new_name: "New Name:",
|
||
ph_new_name: "Enter new name",
|
||
btn_rename: "Rename",
|
||
modal_console: "Console",
|
||
modal_view_bk: "View Backup",
|
||
log_loading: "⏳ Running xkeen -restart...",
|
||
last_load_lbl: "Loaded: __TIME__",
|
||
last_saved: "Saved:",
|
||
restart_service_short: "🔄 Restart",
|
||
update_btn_short: "🔄 Update"
|
||
}
|
||
};
|
||
|
||
function t(k, ...args) {
|
||
let s = TR[currLang][k] || k;
|
||
args.forEach((a, i) => s = s.replace('{'+i+'}', a));
|
||
return s;
|
||
}
|
||
|
||
function setLang(l) {
|
||
currLang = l;
|
||
localStorage.setItem(LANG_KEY, l);
|
||
document.getElementById('lang-sel').value = l;
|
||
|
||
document.querySelectorAll('[data-i18n]').forEach(e => {
|
||
let k = e.getAttribute('data-i18n');
|
||
if(TR[l][k]) e.innerText = TR[l][k];
|
||
});
|
||
document.querySelectorAll('[data-i18n-ph]').forEach(e => {
|
||
let k = e.getAttribute('data-i18n-ph');
|
||
if(TR[l][k]) e.placeholder = TR[l][k];
|
||
});
|
||
|
||
// Update dynamic parts
|
||
if(isEditMode) document.getElementById('proxyModalTitle').innerText = TR[l].modal_edit_proxy;
|
||
else document.getElementById('proxyModalTitle').innerText = TR[l].modal_add_proxy;
|
||
|
||
// Update last load label with time
|
||
var timeElem = document.getElementById('last-load');
|
||
var timeText = timeElem.innerText.split(': ')[1] || '';
|
||
timeElem.innerText = TR[l].last_load_lbl.replace('__TIME__', timeText);
|
||
}
|
||
|
||
// Открываем панель через наш локальный прокси (безопасно для PNA/CORS)
|
||
function openPanel() {
|
||
var url = window.location.protocol + "//" + window.location.host + "/mihomo_panel/ui/";
|
||
window.open(url, '_blank');
|
||
}
|
||
ed.setValue(initialConfig); ed.clearSelection();
|
||
|
||
document.getElementById('vlessLink').addEventListener('input', function() {
|
||
if(isEditMode) return;
|
||
var link = this.value;
|
||
if (link.startsWith("vless://") && link.includes("#")) {
|
||
var name = link.split('#')[1];
|
||
document.getElementById('vlessProxyName').value = decodeURIComponent(name).trim();
|
||
}
|
||
});
|
||
|
||
document.getElementById('wgConfig').addEventListener('input', function() {
|
||
if(isEditMode) return;
|
||
var conf = this.value;
|
||
var nameField = document.getElementById('wgProxyName');
|
||
var endpointMatch = conf.match(/Endpoint\s*=\s*(.+)/);
|
||
if (endpointMatch && endpointMatch[1]) {
|
||
var server = endpointMatch[1].split(':')[0].trim();
|
||
if (server) nameField.value = 'WG_' + server;
|
||
}
|
||
});
|
||
|
||
function closeM(i){document.getElementById(i).style.display='none'}
|
||
function showToast(msg){
|
||
var tBox=document.getElementById('toast');
|
||
var tMsg=document.getElementById('toast-msg');
|
||
tMsg.innerText=msg||t('toast_saved');
|
||
tBox.style.display='flex';
|
||
setTimeout(()=>{tBox.style.display='none'}, 2500);
|
||
}
|
||
|
||
function switchProf() {
|
||
var p = document.getElementById('prof-sel').value;
|
||
if(!confirm(t('confirm_switch', p))) return;
|
||
var params = new URLSearchParams(); params.append('act', 'switch_prof'); params.append('name', p);
|
||
fetch('/',{method:'POST',body:params}).then(r=>r.json()).then(d=>{
|
||
if(d.error) alert(d.error);
|
||
else window.location.reload();
|
||
});
|
||
}
|
||
function openAddProf() {
|
||
document.getElementById('np-name').value='';
|
||
document.getElementById('np-content').value='';
|
||
document.getElementById('m-add-prof').style.display='flex';
|
||
}
|
||
function loadProfFile(input) {
|
||
var f=input.files[0]; var r=new FileReader();
|
||
r.onload=function(e){document.getElementById('np-content').value = e.target.result};
|
||
r.readAsText(f); input.value='';
|
||
}
|
||
function saveNewProf() {
|
||
var n = document.getElementById('np-name').value.trim();
|
||
var c = document.getElementById('np-content').value;
|
||
if(!n) return alert(t('prompt_enter_name'));
|
||
if(!n.match(/^[a-zA-Z0-9_-]+$/)) return alert(t('error_invalid_name'));
|
||
var params = new URLSearchParams(); params.append('act', 'add_prof'); params.append('name', n); params.append('content', c);
|
||
fetch('/',{method:'POST',body:params}).then(r=>r.json()).then(d=>{
|
||
if(d.error) alert(d.error);
|
||
else { showToast(t('toast_saved')); setTimeout(()=>{window.location.reload()}, 500); }
|
||
});
|
||
}
|
||
function delProf() {
|
||
var p = document.getElementById('prof-sel').value;
|
||
if(!p) return;
|
||
if(!confirm(t('confirm_del_prof', p))) return;
|
||
var params = new URLSearchParams(); params.append('act', 'del_prof'); params.append('name', p);
|
||
fetch('/',{method:'POST',body:params}).then(r=>r.json()).then(d=>{
|
||
if(d.error) alert(d.error);
|
||
else { showToast(t('toast_deleted')); setTimeout(()=>{window.location.reload()}, 500); }
|
||
});
|
||
}
|
||
|
||
function downloadProf() {
|
||
var sel = document.getElementById('prof-sel');
|
||
if (!sel.value) return;
|
||
var name = sel.value;
|
||
var params = new URLSearchParams();
|
||
params.append('act', 'get_prof_content');
|
||
params.append('name', name);
|
||
fetch('/', { method: 'POST', body: params })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) {
|
||
alert(d.error);
|
||
} else {
|
||
var a = document.createElement('a');
|
||
a.href = 'data:text/yaml;charset=utf-8,' + encodeURIComponent(d.content);
|
||
a.download = name + '.yaml';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
showToast('💾');
|
||
}
|
||
});
|
||
}
|
||
|
||
function fmtLog(raw) {
|
||
if(!raw) return "Log empty";
|
||
return raw.split('\\n').map(l => {
|
||
if(!l.trim()) return "";
|
||
l = l.replace(/\\x1b\\[32m/g, '<span class="log-green">')
|
||
.replace(/\\x1b\\[33m/g, '<span class="log-yellow">')
|
||
.replace(/\\x1b\\[0m/g, '</span>');
|
||
var m = l.match(/time="(.*?)"\s+level=(\w+)\s+msg="(.*)"/);
|
||
if(m) {
|
||
var ts = new Date(m[1]).toLocaleTimeString();
|
||
var lvl = m[2].toUpperCase();
|
||
var txt = m[3];
|
||
var cls = 'log-info';
|
||
if(lvl==='WARN'||lvl==='WARNING') cls='log-warn';
|
||
if(lvl==='ERROR'||lvl==='FATAL') cls='log-err';
|
||
return `<div class="log-line"><span class="log-time">[${ts}]</span><span class="${cls}">[${lvl}]</span> ${txt}</div>`;
|
||
}
|
||
return `<div class="log-line">${l}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function setTheme(t) {
|
||
document.body.className = t;
|
||
localStorage.setItem(THM_KEY, t);
|
||
document.getElementById('theme-sel').value = t;
|
||
var aceT = 'ace/theme/monokai';
|
||
var edBg = '#18191a'; // monokai default bg but slightly matched to dark theme
|
||
if(t === 'light') { aceT = 'ace/theme/chrome'; edBg = '#f0f2f5'; }
|
||
if(t === 'midnight') { aceT = 'ace/theme/tomorrow_night_blue'; edBg = '#0f172a'; }
|
||
if(t === 'cyber') { aceT = 'ace/theme/terminal'; edBg = '#000000'; }
|
||
ed.setTheme(aceT);
|
||
document.getElementById('ed').style.background = edBg;
|
||
document.getElementById('ed').style.backdropFilter = 'none';
|
||
}
|
||
var savedTheme = localStorage.getItem(THM_KEY) || 'dark';
|
||
setTheme(savedTheme);
|
||
|
||
var savedLang = localStorage.getItem(LANG_KEY) || 'ru';
|
||
setLang(savedLang);
|
||
|
||
var bkInp = document.getElementById('bk-lim');
|
||
if(localStorage.getItem(LIM_KEY)) bkInp.value = localStorage.getItem(LIM_KEY);
|
||
bkInp.addEventListener('change', function(){ localStorage.setItem(LIM_KEY, this.value); });
|
||
|
||
function save(mode){
|
||
var c=ed.getValue();
|
||
var p=new URLSearchParams(); p.append('act', mode); p.append('content', c);
|
||
if(mode==='restart') {
|
||
document.getElementById('m-con').style.display='flex';
|
||
document.getElementById('cons').innerHTML='<div style="padding:20px;text-align:center">' + t('log_loading') + '</div>';
|
||
}
|
||
fetch('/',{method:'POST',body:p}).then(r=>r.json()).then(d=>{
|
||
if(mode==='save'){
|
||
showToast(t('toast_saved'));
|
||
document.getElementById('last-load').innerText = t('last_saved') + " " + d.time;
|
||
if(d.backups) document.getElementById('bk-list').innerHTML = d.backups;
|
||
} else {
|
||
var consoleDiv = document.getElementById('cons');
|
||
consoleDiv.innerHTML = fmtLog(d.log);
|
||
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
||
}
|
||
}).catch(e=>alert("Error: "+e));
|
||
}
|
||
|
||
function cleanBackups(){
|
||
var lim = document.getElementById('bk-lim').value;
|
||
if(!confirm(t('confirm_clean', lim))) return;
|
||
var p=new URLSearchParams(); p.append('act', 'clean_backups'); p.append('limit', lim);
|
||
fetch('/',{method:'POST',body:p}).then(r=>r.json()).then(d=>{
|
||
showToast(t('toast_cleaned'));
|
||
if(d.backups) document.getElementById('bk-list').innerHTML = d.backups;
|
||
});
|
||
}
|
||
|
||
function delBackup(fname){
|
||
if(!confirm(t('confirm_del_bk', fname))) return;
|
||
var p=new URLSearchParams(); p.append('act', 'del_backup'); p.append('f', fname);
|
||
fetch('/',{method:'POST',body:p}).then(r=>r.json()).then(d=>{
|
||
showToast(t('toast_deleted'));
|
||
if(d.backups) document.getElementById('bk-list').innerHTML = d.backups;
|
||
});
|
||
}
|
||
|
||
function viewBackup(fname) {
|
||
var p = new URLSearchParams();
|
||
p.append('act', 'view_backup');
|
||
p.append('f', fname);
|
||
fetch('/', { method: 'POST', body: p }).then(r => r.json()).then(d => {
|
||
if (d.error) {
|
||
alert(d.error);
|
||
} else {
|
||
document.getElementById('bk-content').textContent = d.content;
|
||
document.getElementById('bk-restore-btn').onclick = function() { restoreBackup(fname) };
|
||
document.getElementById('m-view-bk').style.display = 'flex';
|
||
}
|
||
});
|
||
}
|
||
|
||
function restoreBackup(fname){
|
||
if(!confirm(t('confirm_restore', fname))) return;
|
||
var p=new URLSearchParams(); p.append('act', 'rest'); p.append('f', fname);
|
||
fetch('/',{method:'POST',body:p}).then(r=>r.text()).then(()=>{
|
||
window.location.reload();
|
||
});
|
||
}
|
||
|
||
function updateStudio() {
|
||
if(!confirm(t('confirm_update'))) return;
|
||
showToast(t('toast_checking'));
|
||
var p = new URLSearchParams(); p.append('act', 'update_service');
|
||
fetch('/', { method: 'POST', body: p })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
alert(d.log);
|
||
if(d.log.includes("Установка/обновление завершено")) location.reload();
|
||
})
|
||
.catch(e => {
|
||
alert(t('alert_updating'));
|
||
setTimeout(() => location.reload(), 5000);
|
||
});
|
||
}
|
||
|
||
function restartService() {
|
||
if(!confirm(t('confirm_restart_service'))) return;
|
||
showToast(t('toast_restarting'));
|
||
var p = new URLSearchParams(); p.append('act', 'restart_service');
|
||
fetch('/', { method: 'POST', body: p })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
// Сервис перезапускается, поэтому ответ может не прийти или прийти с задержкой
|
||
// Но мы все равно попробуем перезагрузить страницу через некоторое время
|
||
setTimeout(() => location.reload(), 3000);
|
||
})
|
||
.catch(e => {
|
||
// Ошибка ожидаема, так как сервер убивает сам себя
|
||
setTimeout(() => location.reload(), 3000);
|
||
});
|
||
}
|
||
|
||
function getProxiesList() {
|
||
var ls = ed.getValue().split(/\\r?\\n/);
|
||
var prs = [], inP = 0;
|
||
for (var l of ls) {
|
||
if (l.match(/^proxies:/)) inP = 1;
|
||
if (inP && l.match(/^[a-zA-Z]/) && !l.match(/^proxies:/)) inP = 0;
|
||
if (inP) {
|
||
var m = l.match(/^\s+-\s+name:\s+(.*)/);
|
||
if (m) prs.push(m[1].trim().replace(/^['"]|['"]$/g, ''))
|
||
}
|
||
}
|
||
return prs;
|
||
}
|
||
|
||
function openAddProxyModal() {
|
||
isEditMode = false;
|
||
document.getElementById('proxyModalTitle').innerText = t('modal_add_proxy');
|
||
document.querySelector('[data-i18n="tab_vless"]').innerText = t('tab_vless');
|
||
document.querySelector('[data-i18n="tab_wg"]').innerText = t('tab_wg');
|
||
document.getElementById('edit-proxy-container').style.display = 'none';
|
||
document.getElementById('vless-name-block').style.display = 'block';
|
||
document.getElementById('wg-name-block').style.display = 'block';
|
||
|
||
// Clear inputs
|
||
document.getElementById('vlessLink').value = '';
|
||
document.getElementById('vlessProxyName').value = '';
|
||
document.getElementById('wgConfig').value = '';
|
||
document.getElementById('wgProxyName').value = '';
|
||
|
||
document.getElementById('addProxyModal').style.display = 'flex';
|
||
}
|
||
|
||
function openEditProxyModal() {
|
||
isEditMode = true;
|
||
document.getElementById('proxyModalTitle').innerText = t('modal_edit_proxy');
|
||
document.querySelector('[data-i18n="tab_vless"]').innerText = t('tab_vless');
|
||
document.querySelector('[data-i18n="tab_wg"]').innerText = t('tab_wg');
|
||
document.getElementById('edit-proxy-container').style.display = 'block';
|
||
document.getElementById('vless-name-block').style.display = 'none';
|
||
document.getElementById('wg-name-block').style.display = 'none';
|
||
|
||
// Populate select
|
||
var prs = getProxiesList();
|
||
var sel = document.getElementById('edit-proxy-sel');
|
||
sel.innerHTML = '';
|
||
if(prs.length === 0) {
|
||
var o = document.createElement('option');
|
||
o.text = "---";
|
||
sel.add(o);
|
||
sel.disabled = true;
|
||
} else {
|
||
sel.disabled = false;
|
||
prs.forEach(p => {
|
||
var o = document.createElement('option');
|
||
o.text = p;
|
||
sel.add(o);
|
||
});
|
||
}
|
||
|
||
// Clear inputs
|
||
document.getElementById('vlessLink').value = '';
|
||
document.getElementById('wgConfig').value = '';
|
||
|
||
document.getElementById('addProxyModal').style.display = 'flex';
|
||
}
|
||
|
||
function switchTab(evt, tabName) {
|
||
var i, tabcontent, tablinks;
|
||
tabcontent = document.getElementsByClassName("tab-content");
|
||
for (i = 0; i < tabcontent.length; i++) {
|
||
tabcontent[i].classList.remove("active");
|
||
}
|
||
tablinks = document.getElementsByClassName("modal-tabs")[0].getElementsByTagName("button");
|
||
for (i = 0; i < tablinks.length; i++) {
|
||
tablinks[i].classList.remove("active");
|
||
}
|
||
document.getElementById(tabName).classList.add("active");
|
||
evt.currentTarget.classList.add("active");
|
||
}
|
||
|
||
function loadWgFile(input) {
|
||
var f = input.files[0];
|
||
if (!f) return;
|
||
var r = new FileReader();
|
||
r.onload = function(e) {
|
||
var content = e.target.result;
|
||
document.getElementById('wgConfig').value = content;
|
||
document.getElementById('wgConfig').dispatchEvent(new Event('input'));
|
||
};
|
||
r.readAsText(f);
|
||
input.value = '';
|
||
}
|
||
|
||
function addWireguard() {
|
||
var conf = document.getElementById('wgConfig').value;
|
||
var name = '';
|
||
|
||
if(isEditMode) {
|
||
name = document.getElementById('edit-proxy-sel').value;
|
||
if(!name || document.getElementById('edit-proxy-sel').disabled) return alert(t('error_no_proxy_edit'));
|
||
} else {
|
||
name = document.getElementById('wgProxyName').value.trim();
|
||
}
|
||
|
||
if (!conf) return alert(t('error_empty_wg'));
|
||
|
||
var p = new URLSearchParams();
|
||
p.append('act', 'add_wireguard');
|
||
p.append('config_text', conf);
|
||
if (name) p.append('proxy_name', name);
|
||
|
||
fetch('/', { method: 'POST', body: p })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) {
|
||
alert(d.error);
|
||
} else {
|
||
if(isEditMode) {
|
||
replaceProxyData(name, d.yaml);
|
||
} else {
|
||
pData = d;
|
||
closeM('addProxyModal');
|
||
showG();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function parseVless(){
|
||
var link = document.getElementById('vlessLink').value;
|
||
var name = '';
|
||
|
||
if(isEditMode) {
|
||
name = document.getElementById('edit-proxy-sel').value;
|
||
if(!name || document.getElementById('edit-proxy-sel').disabled) return alert(t('error_no_proxy_edit'));
|
||
} else {
|
||
name = document.getElementById('vlessProxyName').value.trim();
|
||
}
|
||
|
||
if (!link) return;
|
||
|
||
var p = new URLSearchParams();
|
||
p.append('act', 'parse');
|
||
p.append('link', link);
|
||
if (name) p.append('proxy_name', name);
|
||
|
||
fetch('/', { method: 'POST', body: p })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) {
|
||
alert(d.error);
|
||
} else {
|
||
if(isEditMode) {
|
||
replaceProxyData(name, d.yaml);
|
||
} else {
|
||
pData = d;
|
||
closeM('addProxyModal');
|
||
showG();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function replaceProxyData(targetName, newYaml) {
|
||
if(!confirm(t('confirm_replace', targetName))) return;
|
||
var content = ed.getValue();
|
||
var p = new URLSearchParams();
|
||
p.append('act', 'replace_proxy');
|
||
p.append('target_name', targetName);
|
||
p.append('new_yaml', newYaml);
|
||
p.append('content', content);
|
||
|
||
fetch('/', { method: 'POST', body: p })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) {
|
||
alert(d.error);
|
||
} else {
|
||
ed.setValue(d.new_content);
|
||
ed.clearSelection();
|
||
closeM('addProxyModal');
|
||
showToast(t('toast_updated'));
|
||
}
|
||
});
|
||
}
|
||
|
||
function showG(){
|
||
var txt=ed.getValue(); var ls=txt.split(/\\r?\\n/); var grps=[], inG=false;
|
||
for(var l of ls){ if(l.match(/^proxy-groups:/))inG=true; if(inG && l.match(/^[a-zA-Z]/) && !l.match(/^proxy-groups:/))inG=false; if(inG){var m=l.match(/^\s*-\s+name:\s+(.*)/);if(m)grps.push(m[1].trim().replace(/^['"]|['"]$/g,''))}}
|
||
var c=document.getElementById('g-cnt'); c.innerHTML=''; var sv=JSON.parse(localStorage.getItem(GRP_KEY));
|
||
if(grps.length===0) c.innerHTML='<div style="color:orange">Группы не найдены</div>';
|
||
else grps.forEach(g=>{
|
||
var ch=(sv===null||sv.includes(g))?'checked':'';
|
||
c.innerHTML+=`<div class="g-item"><input type="checkbox" id="c_${g}" value="${g}" ${ch}><label for="c_${g}">${g}</label></div>`;
|
||
});
|
||
document.getElementById('m-grp').style.display='flex';
|
||
}
|
||
function tgGrp(s){document.querySelectorAll('#g-cnt input').forEach(c=>c.checked=s)}
|
||
function applyVless(){
|
||
closeM('m-grp'); var txt=ed.getValue(); var sels=[];
|
||
document.querySelectorAll('#g-cnt input:checked').forEach(c=>sels.push(c.value));
|
||
localStorage.setItem(GRP_KEY, JSON.stringify(sels));
|
||
var p=new URLSearchParams(); p.append('act','apply_insert'); p.append('content',txt); p.append('proxy_name',pData.name); p.append('proxy_yaml',pData.yaml); p.append('targets',JSON.stringify(sels));
|
||
fetch('/',{method:'POST',body:p}).then(r=>r.json()).then(d=>{if(d.error)alert(d.error);else{ed.setValue(d.new_content);ed.clearSelection();showToast(t('toast_added'))}});
|
||
}
|
||
function showDel(){
|
||
var prs = getProxiesList();
|
||
var s=document.getElementById('sel-del');s.innerHTML='';
|
||
prs.forEach(p=>{var o=document.createElement('option');o.text=p;s.add(o)});
|
||
document.getElementById('m-del').style.display='flex';
|
||
}
|
||
function doDel(){
|
||
var nm=document.getElementById('sel-del').value;if(!nm)return;if(!confirm(t('confirm_del_proxy')))return;closeM('m-del');
|
||
var ls=ed.getValue().split(/\\r?\\n/); var nls=[], inP=false, delB=false, bInd=-1;
|
||
for(var l of ls){
|
||
if(l.match(/^proxies:/)){inP=true;nls.push(l);continue} if(inP && l.match(/^[a-zA-Z]/) && !l.match(/^proxies:/)){inP=false;delB=false}
|
||
if(inP){
|
||
var df=l.match(/^(\s+)-\s+name:\s+(.*)/);
|
||
if(df){
|
||
var ind=df[1].length, pn=df[2].trim().replace(/^['"]|['"]$/g,'');
|
||
if(pn===nm){delB=true;bInd=ind;continue}else if(delB)delB=false;
|
||
} else if(delB){
|
||
var ci=l.search(/\S/); if(l.trim()===''||ci>bInd)continue; else delB=false;
|
||
}
|
||
}
|
||
if(delB)continue;
|
||
|
||
if(l.match(/^\s*proxies:\s*\[.*\]/)){
|
||
var st = l.indexOf('['); var en = l.lastIndexOf(']');
|
||
var pre = l.substring(0, st+1); var suf = l.substring(en);
|
||
var mid = l.substring(st+1, en);
|
||
var parts = mid.split(',');
|
||
var res = []; var changed = false;
|
||
for(var p of parts){
|
||
var clean = p.trim().replace(/^['"]|['"]$/g, '');
|
||
if(clean === nm){ changed = true; } else { res.push(p); }
|
||
}
|
||
if(changed){ nls.push(pre + res.join(',') + suf); continue; }
|
||
}
|
||
|
||
var rm=l.match(/^\s+-\s+(?:"([^"]+)"|'([^']+)'|([^"':]+))\s*$/);
|
||
if(rm){var rn=rm[1]||rm[2]||rm[3];if(rn&&rn.trim()===nm)continue}
|
||
nls.push(l);
|
||
}
|
||
ed.setValue(nls.join('\\n'));
|
||
}
|
||
|
||
function showRename() {
|
||
var prs = getProxiesList();
|
||
var s = document.getElementById('sel-ren-proxy');
|
||
s.innerHTML = '';
|
||
prs.forEach(p => {
|
||
var o = document.createElement('option');
|
||
o.text = p;
|
||
s.add(o)
|
||
});
|
||
document.getElementById('inp-ren-newname').value = '';
|
||
document.getElementById('m-ren').style.display = 'flex';
|
||
}
|
||
|
||
function doRename() {
|
||
var oldName = document.getElementById('sel-ren-proxy').value;
|
||
var newName = document.getElementById('inp-ren-newname').value.trim();
|
||
if (!newName) {
|
||
alert("Новое имя не может быть пустым.");
|
||
return;
|
||
}
|
||
if (!oldName) {
|
||
alert("Прокси не выбран.");
|
||
return;
|
||
}
|
||
|
||
var content = ed.getValue();
|
||
var params = new URLSearchParams();
|
||
params.append('act', 'rename_proxy');
|
||
params.append('old_name', oldName);
|
||
params.append('new_name', newName);
|
||
params.append('content', content);
|
||
|
||
fetch('/', { method: 'POST', body: params })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) {
|
||
alert('Ошибка: ' + d.error);
|
||
} else {
|
||
ed.setValue(d.new_content);
|
||
ed.clearSelection();
|
||
closeM('m-ren');
|
||
showToast(t('toast_renamed'));
|
||
}
|
||
})
|
||
.catch(e => alert("Сетевая ошибка: " + e));
|
||
}
|
||
</script></body></html>"""
|
||
|
||
|
||
class H(http.server.SimpleHTTPRequestHandler):
|
||
def end_headers(s):
|
||
s.send_header('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||
s.send_header('Pragma',
|
||
'no-cache');
|
||
s.send_header(
|
||
'Expires', '0');
|
||
super().end_headers()
|
||
|
||
def get_bks(s):
|
||
b = ""
|
||
# Теперь берем ВСЕ бэкапы, а не только первые 10
|
||
for f in sorted(glob.glob(BACKUP_DIR + "/*.yaml"), key=os.path.getmtime, reverse=True):
|
||
n = os.path.basename(f);
|
||
t = datetime.fromtimestamp(os.path.getmtime(f)).strftime("%d.%m %H:%M")
|
||
b += f'''<div class="bk-item">
|
||
<div><b>{n}</b><span style="font-size:11px;color:var(--txt-sec)">{t}</span></div>
|
||
<div class="bk-btns">
|
||
<button onclick="viewBackup('{n}')" class="btn-u" title="Просмотр">👁️</button>
|
||
<button onclick="restoreBackup('{n}')" class="btn-g" title="Восстановить">↺</button>
|
||
<button onclick="delBackup('{n}')" class="btn-d" title="Удалить">✕</button>
|
||
</div>
|
||
</div>'''
|
||
if not b: b = '<div style="color:var(--txt-sec);font-size:13px;text-align:center;padding:15px">Нет бэкапов</div>'
|
||
return b
|
||
|
||
def get_prof_opts(s):
|
||
curr = ""
|
||
if os.path.exists(CONFIG_PATH):
|
||
real = os.path.realpath(CONFIG_PATH)
|
||
curr = os.path.splitext(os.path.basename(real))[0]
|
||
|
||
opts = ""
|
||
files = sorted(glob.glob(PROFILES_DIR + "/*.yaml"))
|
||
for f in files:
|
||
n = os.path.splitext(os.path.basename(f))[0]
|
||
sel = "selected" if n == curr else ""
|
||
opts += f'<option value="{n}" {sel}>{n}</option>'
|
||
return opts
|
||
|
||
def get_panel_port(self):
|
||
panel_port = ''
|
||
try:
|
||
with open(CONFIG_PATH, 'r') as f:
|
||
config_content = f.read()
|
||
# Улучшенный regex для поиска порта (учитывает кавычки и IP)
|
||
# Ищет external-controller: "0.0.0.0:9090" или '127.0.0.1:9090' или просто :9090
|
||
match = re.search(r"external-controller:\s*(?:['\"]?)(?:[^:]*):(\d+)(?:['\"]?)", config_content)
|
||
if match:
|
||
panel_port = match.group(1)
|
||
except (IOError, FileNotFoundError):
|
||
pass
|
||
return panel_port
|
||
|
||
# --- PROXY LOGIC ---
|
||
def proxy_pass(self, method):
|
||
panel_port = self.get_panel_port()
|
||
if not panel_port:
|
||
self.send_error(500, "Panel port not found in config")
|
||
return
|
||
|
||
# Strip prefix
|
||
rel_path = self.path.replace('/mihomo_panel/', '', 1)
|
||
target_url = f"http://127.0.0.1:{panel_port}/{rel_path}"
|
||
|
||
# Read Body
|
||
content_len = int(self.headers.get('Content-Length', 0))
|
||
body = self.rfile.read(content_len) if content_len > 0 else None
|
||
|
||
# Create Request
|
||
try:
|
||
req = urllib.request.Request(target_url, data=body, method=method)
|
||
for k, v in self.headers.items():
|
||
if k.lower() not in ['host', 'origin', 'referer']:
|
||
req.add_header(k, v)
|
||
|
||
# Важно: подменяем Host для корректной работы backend
|
||
req.add_header('Host', f'127.0.0.1:{panel_port}')
|
||
|
||
with urllib.request.urlopen(req) as resp:
|
||
self.send_response(resp.status)
|
||
for k, v in resp.getheaders():
|
||
# Фильтруем CORS заголовки от backend, т.к. мы их сами выставим если надо,
|
||
# но здесь мы действуем как same-origin
|
||
if k.lower() not in ['access-control-allow-origin', 'server', 'date']:
|
||
self.send_header(k, v)
|
||
self.end_headers()
|
||
self.wfile.write(resp.read())
|
||
|
||
except urllib.error.HTTPError as e:
|
||
self.send_response(e.code)
|
||
for k, v in e.headers.items():
|
||
self.send_header(k, v)
|
||
self.end_headers()
|
||
self.wfile.write(e.read())
|
||
except Exception as e:
|
||
# self.send_error(500, str(e))
|
||
pass # Silent fail to avoid crashing
|
||
|
||
def do_GET(s):
|
||
if s.path.startswith('/mihomo_panel/'):
|
||
s.proxy_pass('GET')
|
||
return
|
||
|
||
if s.path != '/': return s.send_error(404)
|
||
c = open(CONFIG_PATH).read() if os.path.exists(CONFIG_PATH) else "proxies:\n"
|
||
|
||
s.send_response(200);
|
||
s.send_header('Content-type', 'text/html;charset=utf-8');
|
||
s.end_headers()
|
||
out = HTML_TEMPLATE.replace('__JSON_CONTENT__', json.dumps(c)) \
|
||
.replace('__BACKUPS__', s.get_bks()) \
|
||
.replace('__PROFILES__', s.get_prof_opts()) \
|
||
.replace('__TIME__', datetime.now().strftime("%H:%M:%S"))
|
||
s.wfile.write(out.encode('utf-8'))
|
||
|
||
def do_POST(s):
|
||
if s.path.startswith('/mihomo_panel/'):
|
||
s.proxy_pass('POST')
|
||
return
|
||
|
||
l = int(s.headers['Content-Length']);
|
||
d = s.rfile.read(l).decode('utf-8', 'ignore')
|
||
p = {k: v[0] for k, v in urllib.parse.parse_qs(d).items()};
|
||
a = p.get('act')
|
||
s.send_response(200);
|
||
s.send_header('Content-Type', 'application/json');
|
||
s.end_headers()
|
||
|
||
# --- PROFILE ACTIONS ---
|
||
if a == 'switch_prof':
|
||
n = p.get('name')
|
||
target = os.path.join(PROFILES_DIR, n + ".yaml")
|
||
if os.path.exists(target):
|
||
if os.path.exists(CONFIG_PATH) or os.path.islink(CONFIG_PATH):
|
||
os.unlink(CONFIG_PATH)
|
||
os.symlink(target, CONFIG_PATH)
|
||
s.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
|
||
else:
|
||
s.wfile.write(json.dumps({'error': 'Profile not found'}).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'add_prof':
|
||
n = p.get('name')
|
||
c = p.get('content', '')
|
||
target = os.path.join(PROFILES_DIR, n + ".yaml")
|
||
if os.path.exists(target):
|
||
s.wfile.write(json.dumps({'error': 'Профиль с таким именем уже существует'}).encode('utf-8'))
|
||
else:
|
||
with open(target, 'w') as f:
|
||
f.write(c)
|
||
if not os.path.exists(CONFIG_PATH): os.symlink(target, CONFIG_PATH)
|
||
s.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'del_prof':
|
||
n = p.get('name')
|
||
target = os.path.join(PROFILES_DIR, n + ".yaml")
|
||
real_curr = os.path.realpath(CONFIG_PATH)
|
||
if os.path.realpath(target) == real_curr:
|
||
s.wfile.write(
|
||
json.dumps({'error': 'Нельзя удалить активный профиль. Сначала переключитесь на другой.'}).encode(
|
||
'utf-8'))
|
||
elif os.path.exists(target):
|
||
os.remove(target)
|
||
s.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
|
||
else:
|
||
s.wfile.write(json.dumps({'error': 'File not found'}).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'get_prof_content':
|
||
n = p.get('name')
|
||
target = os.path.join(PROFILES_DIR, n + ".yaml")
|
||
if os.path.exists(target):
|
||
with open(target, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
s.wfile.write(json.dumps({'status': 'ok', 'content': content}).encode('utf-8'))
|
||
else:
|
||
s.wfile.write(json.dumps({'error': 'Profile not found'}).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'rename_proxy':
|
||
old_name = p.get('old_name')
|
||
new_name = p.get('new_name')
|
||
content = p.get('content', '')
|
||
if not all([old_name, new_name, content]):
|
||
s.wfile.write(json.dumps({'error': 'Missing parameters'}).encode('utf-8'))
|
||
return
|
||
|
||
# 1. Замена в определении прокси: - name: "old_name"
|
||
# Regex для поиска `name: 'old_name'`, `name: "old_name"` или `name: old_name`
|
||
# Используем `re.escape` для безопасности
|
||
escaped_old = re.escape(old_name)
|
||
# (?P<quote>['"]?) - захватывает кавычку (если она есть) в группу 'quote'
|
||
# \\1 - ссылается на захваченную кавычку, чтобы заменить на такую же
|
||
pattern_def = r"(name\s*:\s*)(?P<quote>['\"]?)" + escaped_old + r"(?P=quote)"
|
||
# Заменяем, сохраняя оригинальные кавычки
|
||
content = re.sub(pattern_def, r'\g<1>"' + new_name + '"', content, count=1)
|
||
|
||
# 2. Замена в списках proxy-groups: - "old_name"
|
||
# Regex для поиска `- 'old_name'`, `- "old_name"` или `- old_name`
|
||
pattern_list = r"(-\s+)(?P<quote>['\"]?)" + escaped_old + r"(?P=quote)"
|
||
content = re.sub(pattern_list, r'\g<1>"' + new_name + '"', content)
|
||
|
||
# 3. Замена в Inline Lists: [ ..., "old_name", ... ]
|
||
# Ищем old_name внутри delimiters [ или , с последующим , или ]
|
||
pattern_inline = r"([\[,]\s*)(?P<q>['\"]?)" + escaped_old + r"(?P=q)(\s*[,\]])"
|
||
content = re.sub(pattern_inline, r'\1\g<q>' + new_name + r'\g<q>\3', content)
|
||
|
||
s.wfile.write(json.dumps({'status': 'ok', 'new_content': content}).encode('utf-8'))
|
||
return
|
||
|
||
# --- EXISTING ACTIONS ---
|
||
|
||
if a == 'parse':
|
||
link = p.get('link', '')
|
||
custom_name = p.get('proxy_name')
|
||
d, e = parse_vless(link, custom_name)
|
||
s.wfile.write(json.dumps(d if d else {'error': e}).encode('utf-8'));
|
||
return
|
||
|
||
if a == 'add_wireguard':
|
||
config_text = p.get('config_text', '')
|
||
custom_name = p.get('proxy_name')
|
||
if not config_text:
|
||
s.wfile.write(json.dumps({'error': 'Empty config'}).encode('utf-8'))
|
||
return
|
||
|
||
proxy_data, err = parse_wireguard(config_text, custom_name)
|
||
if err:
|
||
s.wfile.write(json.dumps({'error': err}).encode('utf-8'))
|
||
return
|
||
|
||
s.wfile.write(json.dumps(proxy_data).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'apply_insert':
|
||
content = p.get('content', '');
|
||
p_name = p.get('proxy_name', '');
|
||
p_yaml = p.get('proxy_yaml', '');
|
||
targets = json.loads(p.get('targets', '[]'))
|
||
lines = content.splitlines();
|
||
inserted = False
|
||
for i, line in enumerate(lines):
|
||
if line.strip().startswith('proxies:'):
|
||
blk = p_yaml.splitlines();
|
||
for bi, bl in enumerate(blk): lines.insert(i + 1 + bi, " " + bl)
|
||
inserted = True;
|
||
break
|
||
if not inserted: lines.append("proxies:"); lines.extend([" " + l for l in p_yaml.splitlines()])
|
||
uc = insert_proxy_logic("\n".join(lines), p_name, targets)
|
||
s.wfile.write(json.dumps({'new_content': uc}).encode('utf-8'));
|
||
return
|
||
|
||
if a == 'replace_proxy':
|
||
target_name = p.get('target_name', '')
|
||
new_yaml = p.get('new_yaml', '')
|
||
content = p.get('content', '')
|
||
|
||
new_yaml_lines = new_yaml.splitlines()
|
||
uc = replace_proxy_block(content, target_name, new_yaml_lines)
|
||
s.wfile.write(json.dumps({'new_content': uc}).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'clean_backups':
|
||
limit = int(p.get('limit', 5))
|
||
files = sorted(glob.glob(BACKUP_DIR + "/*.yaml"), key=os.path.getmtime, reverse=True)
|
||
if len(files) > limit:
|
||
for f in files[limit:]:
|
||
try:
|
||
os.remove(f)
|
||
except:
|
||
pass
|
||
s.wfile.write(json.dumps({'backups': s.get_bks()}).encode('utf-8'));
|
||
return
|
||
|
||
if a == 'del_backup':
|
||
fname = p.get('f')
|
||
path = os.path.join(BACKUP_DIR, os.path.basename(fname))
|
||
if os.path.exists(path): os.remove(path)
|
||
s.wfile.write(json.dumps({'backups': s.get_bks()}).encode('utf-8'));
|
||
return
|
||
|
||
if a == 'rest':
|
||
shutil.copy(os.path.join(BACKUP_DIR, os.path.basename(p.get('f'))), CONFIG_PATH)
|
||
s.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'));
|
||
return
|
||
|
||
if a == 'view_backup':
|
||
fname = p.get('f')
|
||
path = os.path.join(BACKUP_DIR, os.path.basename(fname))
|
||
if os.path.exists(path):
|
||
with open(path, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
s.wfile.write(json.dumps({'content': content}).encode('utf-8'))
|
||
else:
|
||
s.wfile.write(json.dumps({'error': 'File not found'}).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'update_service':
|
||
try:
|
||
output = subprocess.check_output(UPDATE_CMD, shell=True, stderr=subprocess.STDOUT)
|
||
log = output.decode('utf-8', 'ignore')
|
||
except subprocess.CalledProcessError as e:
|
||
log = e.output.decode('utf-8', 'ignore')
|
||
except Exception as e:
|
||
log = str(e)
|
||
s.wfile.write(json.dumps({'log': log}).encode('utf-8'))
|
||
return
|
||
|
||
if a == 'restart_service':
|
||
# Отправляем ответ клиенту сразу, так как после перезапуска сервер умрет
|
||
s.wfile.write(json.dumps({'status': 'restarting'}).encode('utf-8'))
|
||
# Запускаем перезапуск в фоне с небольшой задержкой, чтобы успеть отправить ответ
|
||
subprocess.Popen("/opt/etc/init.d/S95mihomo-web restart", shell=True)
|
||
return
|
||
|
||
new_c = p.get('content', '').replace('\r\n', '\n')
|
||
if a in ['save', 'restart']:
|
||
if os.path.exists(CONFIG_PATH):
|
||
real_p = os.path.basename(os.path.realpath(CONFIG_PATH))
|
||
prof_n = os.path.splitext(real_p)[0]
|
||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
shutil.copy(CONFIG_PATH, f"{BACKUP_DIR}/{prof_n}_{ts}.yaml")
|
||
|
||
with open(CONFIG_PATH, 'w') as f:
|
||
f.write(new_c);
|
||
f.flush();
|
||
os.fsync(f.fileno())
|
||
|
||
if a == 'restart':
|
||
my_env = os.environ.copy();
|
||
my_env["TERM"] = "xterm-256color"
|
||
subprocess.run(RESTART_CMD, shell=True, env=my_env)
|
||
log = open(LOG_FILE).read() if os.path.exists(LOG_FILE) else "Log empty"
|
||
s.wfile.write(json.dumps({'log': log}).encode('utf-8'))
|
||
elif a == 'save':
|
||
s.wfile.write(json.dumps(
|
||
{'status': 'ok', 'time': datetime.now().strftime("%H:%M:%S"), 'backups': s.get_bks()}).encode('utf-8'))
|
||
|
||
def do_PUT(s):
|
||
if s.path.startswith('/mihomo_panel/'):
|
||
s.proxy_pass('PUT')
|
||
return
|
||
s.send_error(405, "Method Not Allowed")
|
||
|
||
def do_DELETE(s):
|
||
if s.path.startswith('/mihomo_panel/'):
|
||
s.proxy_pass('DELETE')
|
||
return
|
||
s.send_error(405, "Method Not Allowed")
|
||
|
||
|
||
try:
|
||
socketserver.TCPServer.allow_reuse_address = True;
|
||
socketserver.TCPServer(("", PORT), H).serve_forever()
|
||
except Exception as e:
|
||
print(e)
|