Создание репозитория

This commit is contained in:
Petro1990 2025-11-24 16:23:47 +03:00
commit 2162439928
2 changed files with 735 additions and 0 deletions

34
S95mihomo-web Normal file
View File

@ -0,0 +1,34 @@
#!/bin/sh
PROG=/opt/scripts/mihomo_editor.py
PIDfile=/opt/var/run/mihomo_editor.pid
case "$1" in
start)
echo "Starting Mihomo Web Editor..."
# Запуск python в фоне
python3 $PROG > /dev/null 2>&1 &
echo $! > $PIDfile
;;
stop)
echo "Stopping Mihomo Web Editor..."
if [ -f $PIDfile ]; then
kill $(cat $PIDfile)
rm $PIDfile
else
echo "No PID file found."
# На всякий случай убиваем по имени, если PID потерян
pkill -f "python3 $PROG"
fi
;;
restart)
$0 stop
sleep 1
$0 start
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
esac
exit 0

701
mihomo_editor.py Normal file
View File

@ -0,0 +1,701 @@
# !/opt/bin/python3
# -*- coding: utf-8 -*-
import http.server
import socketserver
import os
import subprocess
import urllib.parse
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"
# --- ИНИЦИАЛИЗАЦИЯ ---
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):
try:
if not link.startswith("vless://"): return None, "Link error"
main = link[8:]
name = "VLESS"
if '#' 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 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:'):
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)
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ru">
<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 Editor v18.2</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.7/ace.js"></script>
<style>
:root {
--bg: #1a1a1a; --bg-sec: #252526; --bg-ter: #2d2d2d;
--txt: #e0e0e0; --txt-sec: #aaa; --bd: #333;
--btn-s: #007acc; --btn-r: #2e7d32; --btn-d: #d32f2f; --btn-u: #e6a23c; --btn-g: #555;
--log-bg: #111; --log-txt: #ccc;
--comp-h: 36px;
}
body.light {
--bg: #f5f5f5; --bg-sec: #ffffff; --bg-ter: #e0e0e0;
--txt: #333; --txt-sec: #666; --bd: #ccc;
--btn-s: #0078d4; --btn-g: #777; --log-bg: #fff; --log-txt: #222;
}
body.midnight {
--bg: #0f172a; --bg-sec: #1e293b; --bg-ter: #334155;
--txt: #f1f5f9; --txt-sec: #94a3b8; --bd: #475569;
--btn-s: #3b82f6; --btn-u: #f59e0b;
}
body.cyber {
--bg: #000000; --bg-sec: #111111; --bg-ter: #222222;
--txt: #00ff00; --txt-sec: #00cc00; --bd: #004400;
--btn-s: #007700; --btn-r: #00aa00; --btn-d: #aa0000; --btn-u: #aaaa00; --btn-g: #333;
}
body{font-family:'Segoe UI',sans-serif;background:var(--bg);color:var(--txt);margin:0;display:flex;flex-direction:column;height:100vh;overflow:hidden;}
* { box-sizing: border-box; }
.hdr{background:var(--bg-sec);padding:10px 15px;border-bottom:1px solid var(--bd);display:flex;justify-content:space-between;align-items:center;height:40px;flex-shrink:0}
.bar{background:var(--bg-ter);padding:8px 15px;display:flex;gap:10px;border-bottom:1px solid var(--bd);flex-wrap:wrap;flex-shrink:0}
button, input, select, textarea {
font-family: inherit; font-size: 13px; color: var(--txt);
border: 1px solid var(--bd); border-radius: 4px;
background: var(--bg-ter);
transition: 0.2s; outline: none;
}
button {
height: var(--comp-h); padding: 0 15px; cursor: pointer; color: #fff; font-weight: 600;
display: flex; align-items: center; justify-content: center; gap: 5px; white-space: nowrap; border: none;
}
input, select {
height: var(--comp-h); padding: 0 10px; width: 100%;
}
button:hover{filter:brightness(1.1)}
.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)}
.main{display:flex;flex:1;overflow:hidden}
#ed{flex:1;font-size:14px}
.sb{width:320px;background:var(--bg-sec);border-left:1px solid var(--bd);display:flex;flex-direction:column;overflow-y:auto;flex-shrink:0}
.sec{padding:15px;border-bottom:1px solid var(--bd); display:flex; flex-direction:column; gap:8px;}
.sec h3{margin:0 0 5px 0;font-size:14px;color:var(--txt-sec)}
.ovl{position:fixed;top:0;left:0;width:100%;height:100%;background:#000000b3;z-index:999;display:none;justify-content:center;align-items:center;padding:10px}
.mod{background:var(--bg-sec);padding:20px;border-radius:8px;width:100%;max-width:600px;border:1px solid var(--bd);display:flex;flex-direction:column;max-height:90vh}
.mod h3{margin-top:0;color:var(--txt);border-bottom:1px solid var(--bd);padding-bottom:10px}
.bk-item{background:var(--bg-ter);padding:5px 8px;margin-bottom:5px;border:1px solid var(--bd);border-radius:4px;display:flex;justify-content:space-between;align-items:center;height: auto; min-height: 38px;}
.bk-item div:first-child { flex: 1; min-width: 0; padding-right: 5px; display: flex; flex-direction: column; justify-content: center; }
.bk-item b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.bk-btns { display: flex; gap: 4px; flex-shrink: 0; }
.bk-btns button { width: 28px; padding: 0; height: 28px; font-size: 14px; }
.bk-controls {display:flex; gap:8px; align-items:center; background:var(--bg-ter); padding:5px 10px; border-radius:4px; border: 1px solid var(--bd);}
.bk-controls input {width: 50px !important; text-align: center; margin:0; height: 28px; padding: 0;}
.bk-controls span {font-size:12px; color:var(--txt-sec); white-space: nowrap;}
.bk-controls button { height: 28px; font-size: 11px; padding: 0 10px; margin-left: auto; }
.prof-row {display:flex; gap:8px; align-items:center;}
#prof-sel { flex: 1; }
.prof-btns { display: flex; gap: 8px; margin-top: 5px; }
.prof-btns button { flex: 1; }
#cons{background:var(--log-bg);color:var(--log-txt);font-family:'Consolas',monospace;padding:10px;height:350px;overflow:auto;white-space:pre-wrap;font-size:12px;border:1px solid var(--bd);border-radius:4px}
.g-list {display: grid;grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));gap: 8px;overflow-y: auto;padding: 5px;margin-top: 5px;}
.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: 4px;padding: 10px 5px;font-size: 12px;color: var(--txt-sec);cursor: pointer;transition: all 0.2s;text-align: center;user-select: none;word-break: break-word;min-height: 35px;}
.g-item input:checked + label {background: var(--btn-s);color: white;border-color: var(--btn-s);font-weight: bold;}
.toast {position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--btn-r); color: white; padding: 10px 20px; border-radius: 5px; z-index: 3000; display: none; box-shadow: 0 2px 10px rgba(0,0,0,0.5);}
.log-time { color: #888; margin-right: 8px; }
.log-info { color: #2196f3; font-weight: bold; }
.log-warn { color: #ff9800; font-weight: bold; }
.log-err { color: #f44336; font-weight: bold; }
.log-green { color: #4caf50; }
.log-yellow { color: #ffc107; }
@media (max-width: 768px) {
.main { flex-direction: column; }
.sb { width: 100%; border-left: none; border-top: 1px solid var(--bd); height: auto; max-height: 45vh; }
#ed { flex: 1; min-height: 40vh; }
.bar button, .bar select { flex: 1; justify-content: center; }
.mod { width: 95%; max-height: 95vh; padding: 15px; }
}
</style>
</head>
<body>
<div class="toast" id="toast"> Успешно сохранено</div>
<div class="hdr">
<div style="display:flex;align-items:baseline;gap:10px">
<h2 style="margin:0;color:#4caf50">Mihomo Studio</h2>
<span style="color:var(--txt-sec);font-size:12px">v18.2 Header Fix</span>
</div>
<div id="last-load" style="font-size:12px;color:var(--txt-sec)">Loaded: __TIME__</div>
</div>
<div class="bar">
<button onclick="save('save')" class="btn-s">💾 Сохранить</button>
<button onclick="save('restart')" class="btn-r">🚀 Рестарт</button>
<button onclick="document.getElementById('upl').click()" class="btn-u">📂 Файл</button>
<select id="theme-sel" onchange="setTheme(this.value)" style="max-width:120px; padding:0 10px; margin:0;">
<option value="dark">🌑 Dark</option>
<option value="light"> Light</option>
<option value="midnight">🌃 Midnight</option>
<option value="cyber">👾 Cyber</option>
</select>
<input type="file" id="upl" onchange="upload(this)" style="display:none">
</div>
<div class="main">
<div id="ed"></div>
<div class="sb">
<div class="sec">
<h3>Профили</h3>
<div class="prof-row">
<select id="prof-sel">__PROFILES__</select>
<button onclick="switchProf()" class="btn-s" style="padding:0; width:36px; justify-content:center;" title="Выбрать"></button>
</div>
<div class="prof-btns">
<button onclick="openAddProf()" class="btn-u"> Создать</button>
<button onclick="delProf()" class="btn-d">🗑 Удалить</button>
</div>
</div>
<div class="sec">
<h3>Быстрый VLESS</h3>
<input id="vl" placeholder="vless://..." style="margin:0">
<button onclick="parseVless()" class="btn-s" style="width:100%"> Добавить прокси</button>
</div>
<div class="sec">
<button onclick="showDel()" class="btn-d" style="width:100%">🗑 Удалить прокси</button>
</div>
<div class="sec">
<h3>Бэкапы</h3>
<div class="bk-controls">
<span>Оставить:</span>
<input type="number" id="bk-lim" value="5" min="1" max="50">
<button onclick="cleanBackups()" class="btn-g">Очистить</button>
</div>
<div id="bk-list">__BACKUPS__</div>
</div>
</div>
</div>
<div id="m-grp" class="ovl"><div class="mod"><h3>Добавить в группы:</h3>
<div style="display:flex; gap:10px; margin-bottom:10px"><button onclick="tgGrp(true)" class="btn-g" style="flex:1; justify-content:center"> Выбрать все</button><button onclick="tgGrp(false)" class="btn-g" style="flex:1; justify-content:center"> Снять все</button></div>
<div id="g-cnt" class="g-list"></div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:15px;padding-top:10px;border-top:1px solid var(--bd)"><button onclick="applyVless()" class="btn-s" style="flex:1;justify-content:center">Добавить</button><button onclick="closeM('m-grp')" class="btn-g" style="flex:1;justify-content:center">Отмена</button></div></div></div>
<div id="m-del" class="ovl"><div class="mod"><h3>Удалить прокси</h3><select id="sel-del"></select><div style="display:flex;justify-content:flex-end;gap:10px;margin-top:15px"><button onclick="doDel()" class="btn-d">Удалить</button><button onclick="closeM('m-del')" class="btn-g">Отмена</button></div></div></div>
<div id="m-con" class="ovl"><div class="mod"><h3>Консоль</h3><div id="cons">...</div><div style="display:flex;justify-content:flex-end;gap:10px;margin-top:15px"><button onclick="location.reload()" class="btn-s">Обновить</button><button onclick="closeM('m-con')" class="btn-g">Закрыть</button></div></div></div>
<div id="m-add-prof" class="ovl"><div class="mod">
<h3>Новый профиль</h3>
<label style="font-size:12px; margin-bottom:5px; color:var(--txt-sec)">Имя (англ, без пробелов):</label>
<input id="np-name" placeholder="my_config" style="margin-bottom:10px">
<label style="font-size:12px; margin-bottom:5px; color:var(--txt-sec)">Содержимое:</label>
<div style="display:flex; gap:5px; margin-bottom:5px">
<button onclick="document.getElementById('np-file').click()" class="btn-u" style="flex:1;justify-content:center">📂 Загрузить файл</button>
</div>
<input type="file" id="np-file" style="display:none" onchange="loadProfFile(this)">
<textarea id="np-content" rows="10" placeholder="Вставьте YAML конфиг сюда..."></textarea>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:15px">
<button onclick="saveNewProf()" class="btn-s">Сохранить</button>
<button onclick="closeM('m-add-prof')" class="btn-g">Отмена</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";
var initialConfig = __JSON_CONTENT__;
ed.setValue(initialConfig); ed.clearSelection();
function closeM(i){document.getElementById(i).style.display='none'}
function showToast(msg){ var t=document.getElementById('toast'); t.innerText=msg||'✅ Выполнено'; t.style.display='block'; setTimeout(()=>{t.style.display='none'}, 2000); }
function switchProf() {
var p = document.getElementById('prof-sel').value;
if(!confirm("Переключиться на профиль " + 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("Введите имя!");
if(!n.match(/^[a-zA-Z0-9_-]+$/)) return alert("Недопустимое имя!");
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("✅ Профиль создан"); setTimeout(()=>{window.location.reload()}, 500); }
});
}
function delProf() {
var p = document.getElementById('prof-sel').value;
if(!p) return;
if(!confirm("Удалить профиль " + 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("🗑 Удалено"); setTimeout(()=>{window.location.reload()}, 500); }
});
}
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';
if(t === 'light') aceT = 'ace/theme/chrome';
if(t === 'midnight') aceT = 'ace/theme/tomorrow_night_blue';
if(t === 'cyber') aceT = 'ace/theme/terminal';
ed.setTheme(aceT);
}
var savedTheme = localStorage.getItem(THM_KEY) || 'dark';
setTheme(savedTheme);
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">⏳ Выполнение xkeen -restart...</div>';
}
fetch('/',{method:'POST',body:p}).then(r=>r.json()).then(d=>{
if(mode==='save'){
showToast("✅ Сохранено");
document.getElementById('last-load').innerText = "Saved: " + d.time;
if(d.backups) document.getElementById('bk-list').innerHTML = d.backups;
} else {
document.getElementById('cons').innerHTML = fmtLog(d.log);
}
}).catch(e=>alert("Error: "+e));
}
function cleanBackups(){
var lim = document.getElementById('bk-lim').value;
if(!confirm('Оставить только ' + 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("🧹 Очищено");
if(d.backups) document.getElementById('bk-list').innerHTML = d.backups;
});
}
function delBackup(fname){
if(!confirm('Удалить бэкап ' + 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("🗑 Удалено");
if(d.backups) document.getElementById('bk-list').innerHTML = d.backups;
});
}
function restoreBackup(fname){
if(!confirm('Восстановить ' + 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 upload(i){var f=i.files[0];var r=new FileReader();r.onload=function(e){ed.setValue(e.target.result)};r.readAsText(f);i.value=''}
function parseVless(){
var l=document.getElementById('vl').value;if(!l)return;
var p=new URLSearchParams();p.append('act','parse');p.append('link',l);
fetch('/',{method:'POST',body:p}).then(r=>r.json()).then(d=>{if(d.error)alert(d.error);else{pData=d;showG();document.getElementById('vl').value=''}})
}
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("✅ Добавлено")}});
}
function showDel(){
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,''))}}
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('Удалить?'))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;
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'));
}
</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 = ""
for f in sorted(glob.glob(BACKUP_DIR + "/*.yaml"), key=os.path.getmtime, reverse=True)[:10]:
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:10px;color:var(--txt-sec)">{t}</span></div>
<div class="bk-btns">
<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:12px;text-align:center;padding:10px">Нет бэкапов</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 do_GET(s):
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):
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
# --- EXISTING ACTIONS ---
if a == 'parse':
d, e = parse_vless(p.get('link', ''))
s.wfile.write(json.dumps(d if d else {'error': e}).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 == '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
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'))
try:
socketserver.TCPServer.allow_reuse_address = True; socketserver.TCPServer(("", PORT), H).serve_forever()
except Exception as e:
print(e)