panel: add login field (default admin), update docs
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@
|
||||
- `uninstall.sh` — полный снос всего, что ставит install.sh (systemd, бинари, конфиги, nginx-сайт, LE-сертификат).
|
||||
- Панель v3: переключение TLS↔REALITY, переименование клиентов, лог-уровень xray, бэкап/восстановление config.json, версия бинаря + статус сертификата, статистика по клиентам (трафик + онлайн-IP, xray stats API).
|
||||
- Панель автоматически добавляет `pinnedPeerCertSha256` в клиентские конфиги/ссылки при self-signed сервере.
|
||||
- Тесты Go-панели (14 тестов: hexKey, uuid, tlsCertFile, selfSignedPin, statsInboundPort, ensureStats, stealthInbounds, genX25519, writeJSON, errStr, PanelConfig, email).
|
||||
- Логин + пароль для доступа к панели (по умолчанию `admin` / генерируется при установке).
|
||||
- Тесты Go-панели (15 тестов: hexKey, uuid, tlsCertFile, selfSignedPin, statsInboundPort, ensureStats, stealthInbounds, genX25519, writeJSON, errStr, PanelConfig, email).
|
||||
- CHANGELOG.md, ARCHITECTURE.md.
|
||||
|
||||
### Исправлено
|
||||
|
||||
@@ -56,6 +56,7 @@ ssh -L 8080:127.0.0.1:8080 root@<vps-ip>
|
||||
- Туннель (зеркало, маскировка, экспертные настройки, TLS/REALITY)
|
||||
- Статистика (трафик по клиентам, онлайн-IP)
|
||||
- Логи, рестарт, бэкап/восстановление конфига
|
||||
- Логин + пароль для входа (по умолчанию admin / генерируется при установке)
|
||||
|
||||
## Клиенты
|
||||
|
||||
|
||||
+4
-2
@@ -175,6 +175,7 @@ echo "=== control panel (sdm-panel) ==="
|
||||
curl -fsSL -o "$INSTALL_DIR/sdm-panel" "$GITEA/releases/download/$VERSION/$PANEL_BIN"
|
||||
chmod 755 "$INSTALL_DIR/sdm-panel"
|
||||
PANEL_PASS="$(openssl rand -hex 8)"
|
||||
PANEL_LOGIN="admin"
|
||||
if [ "$SELFSIGNED" -eq 1 ]; then
|
||||
PANEL_SERVER="$VPS_IP"
|
||||
else
|
||||
@@ -183,6 +184,7 @@ fi
|
||||
cat > "$DATA_DIR/panel.json" <<EOF
|
||||
{
|
||||
"bind": "127.0.0.1:8080",
|
||||
"login": "$PANEL_LOGIN",
|
||||
"password": "$PANEL_PASS",
|
||||
"serverName": "$PANEL_SERVER"
|
||||
}
|
||||
@@ -205,10 +207,10 @@ systemctl daemon-reload
|
||||
systemctl enable sdm-panel >/dev/null 2>&1 || true
|
||||
systemctl restart sdm-panel
|
||||
sleep 1
|
||||
echo "panel password: $PANEL_PASS (запомни его — больше не покажем)"
|
||||
echo "panel login: $PANEL_LOGIN panel password: $PANEL_PASS (запомни — больше не покажем)"
|
||||
echo "Доступ к панели (SSH-туннель, шифрованный):"
|
||||
echo " ssh -L 8080:127.0.0.1:8080 root@<vps-ip>"
|
||||
echo " затем открой в браузере http://localhost:8080 (login: admin / пароль выше)"
|
||||
echo " затем открой в браузере http://localhost:8080 (login: $PANEL_LOGIN / пароль выше)"
|
||||
|
||||
echo
|
||||
echo "=== client config (client-stealth.json) ==="
|
||||
|
||||
+561
-19
@@ -1,8 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -15,6 +19,10 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
statsc "sdm-panel/stats"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,10 +32,12 @@ const (
|
||||
)
|
||||
|
||||
type PanelConfig struct {
|
||||
Bind string `json:"bind"`
|
||||
Password string `json:"password"`
|
||||
Config string `json:"config"`
|
||||
ServerName string `json:"serverName"`
|
||||
Bind string `json:"bind"`
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
Config string `json:"config"`
|
||||
ServerName string `json:"serverName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
var panelCfg PanelConfig
|
||||
@@ -107,7 +117,10 @@ func requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func checkPassword(pw string) bool {
|
||||
func checkPassword(login, pw string) bool {
|
||||
if panelCfg.Login != "" && login != panelCfg.Login {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(pw), []byte(panelCfg.Password)) == 1
|
||||
}
|
||||
|
||||
@@ -210,11 +223,12 @@ func setClients(ibs []map[string]any, clients []any) {
|
||||
|
||||
func hLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if !checkPassword(req.Password) {
|
||||
writeJSON(w, 401, map[string]any{"error": "bad password"})
|
||||
if !checkPassword(req.Login, req.Password) {
|
||||
writeJSON(w, 401, map[string]any{"error": "bad credentials"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"token": issueSession()})
|
||||
@@ -283,9 +297,6 @@ func hAddClient(w http.ResponseWriter, r *http.Request) {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Name == "" {
|
||||
req.Name = "client"
|
||||
}
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
@@ -297,8 +308,12 @@ func hAddClient(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := uuid()
|
||||
email := req.Name
|
||||
if email == "" {
|
||||
email = id
|
||||
}
|
||||
clients := ibs[0]["settings"].(map[string]any)["clients"].([]any)
|
||||
clients = append(clients, map[string]any{"id": id, "flow": "", "email": req.Name, "level": 0})
|
||||
clients = append(clients, map[string]any{"id": id, "flow": "", "email": email, "level": 0})
|
||||
setClients(ibs, clients)
|
||||
if err := writeConfig(c); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
@@ -404,6 +419,20 @@ func hClientConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if serverName == "" {
|
||||
serverName = "YOUR-DOMAIN"
|
||||
}
|
||||
fp := panelCfg.Fingerprint
|
||||
if fp == "" {
|
||||
fp = "chrome"
|
||||
}
|
||||
pin := ""
|
||||
if ibs := stealthInbounds(c); len(ibs) > 0 {
|
||||
if ss, ok := ibs[0]["streamSettings"].(map[string]any); ok {
|
||||
if sec, _ := ss["security"].(string); sec == "" || sec == "tls" {
|
||||
if ts, ok := ss["tlsSettings"].(map[string]any); ok {
|
||||
pin = selfSignedPin(tlsCertFile(ts))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "warning"},
|
||||
"inbounds": []any{
|
||||
@@ -424,7 +453,7 @@ func hClientConfig(w http.ResponseWriter, r *http.Request) {
|
||||
"network": "stealth",
|
||||
"stealthSettings": st,
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]any{"serverName": serverName, "fingerprint": "chrome"},
|
||||
"tlsSettings": map[string]any{"serverName": serverName, "fingerprint": fp, "pinnedPeerCertSha256": pin},
|
||||
},
|
||||
},
|
||||
map[string]any{"protocol": "freedom", "tag": "direct"},
|
||||
@@ -487,7 +516,10 @@ func hClientLink(w http.ResponseWriter, r *http.Request) {
|
||||
q.Set("sni", serverName)
|
||||
|
||||
// fingerprint
|
||||
fp := "chrome"
|
||||
fp := panelCfg.Fingerprint
|
||||
if fp == "" {
|
||||
fp = "chrome"
|
||||
}
|
||||
if security == "reality" {
|
||||
if rs, ok := ss["realitySettings"].(map[string]any); ok {
|
||||
if v, ok := rs["serverName"].(string); ok && v != "" {
|
||||
@@ -510,6 +542,14 @@ func hClientLink(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
q.Set("fp", fp)
|
||||
|
||||
if security == "tls" || security == "" {
|
||||
if ts, ok := ss["tlsSettings"].(map[string]any); ok {
|
||||
if v := selfSignedPin(tlsCertFile(ts)); v != "" {
|
||||
q.Set("pin", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stealth settings
|
||||
if v, ok := st["key"].(string); ok {
|
||||
q.Set("key", v)
|
||||
@@ -537,6 +577,469 @@ func hClientLink(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"link": link})
|
||||
}
|
||||
|
||||
func findXrayBinary() string {
|
||||
for _, p := range []string{"/usr/local/xray/xray", "/usr/bin/xray"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hVersion(w http.ResponseWriter, r *http.Request) {
|
||||
ver := "xray not found"
|
||||
if xr := findXrayBinary(); xr != "" {
|
||||
out, _ := sh(xr, "version")
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "Xray") {
|
||||
ver = strings.TrimSpace(line)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
certFile := ""
|
||||
if c, err := readConfig(); err == nil {
|
||||
if ibs := stealthInbounds(c); len(ibs) > 0 {
|
||||
ss := ibs[0]["streamSettings"].(map[string]any)
|
||||
if ts, ok := ss["tlsSettings"].(map[string]any); ok {
|
||||
if certs, ok := ts["certificates"].([]any); ok && len(certs) > 0 {
|
||||
if cm, ok := certs[0].(map[string]any); ok {
|
||||
certFile, _ = cm["certificateFile"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
certExpiry := ""
|
||||
if certFile != "" {
|
||||
out, _ := sh("openssl", "x509", "-enddate", "-noout", "-in", certFile)
|
||||
if i := strings.Index(out, "notAfter="); i >= 0 {
|
||||
certExpiry = strings.TrimSpace(out[i+len("notAfter="):])
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"xray": ver, "certFile": certFile, "certExpiry": certExpiry})
|
||||
}
|
||||
|
||||
func hGetLogLevel(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lv := "warning"
|
||||
if l, ok := c["log"].(map[string]any); ok {
|
||||
if v, ok := l["loglevel"].(string); ok && v != "" {
|
||||
lv = v
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"loglevel": lv})
|
||||
}
|
||||
|
||||
func hSetLogLevel(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Loglevel string `json:"loglevel"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Loglevel != "debug" && req.Loglevel != "info" && req.Loglevel != "warning" && req.Loglevel != "error" {
|
||||
writeJSON(w, 400, map[string]any{"error": "bad loglevel"})
|
||||
return
|
||||
}
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l, _ := c["log"].(map[string]any)
|
||||
if l == nil {
|
||||
l = map[string]any{}
|
||||
c["log"] = l
|
||||
}
|
||||
l["loglevel"] = req.Loglevel
|
||||
if err := writeConfig(c); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, rerr := restartXray()
|
||||
writeJSON(w, 200, map[string]any{"saved": true, "restart": strings.TrimSpace(out), "restartErr": errStr(rerr)})
|
||||
}
|
||||
|
||||
func genX25519() (string, string) {
|
||||
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
pub := priv.PublicKey().Bytes()
|
||||
return base64.RawURLEncoding.EncodeToString(priv.Bytes()),
|
||||
base64.RawURLEncoding.EncodeToString(pub)
|
||||
}
|
||||
|
||||
func hGetSecurity(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ibs := stealthInbounds(c)
|
||||
if len(ibs) == 0 {
|
||||
writeJSON(w, 500, map[string]any{"error": "no stealth inbound"})
|
||||
return
|
||||
}
|
||||
ss := ibs[0]["streamSettings"].(map[string]any)
|
||||
sec, _ := ss["security"].(string)
|
||||
if sec == "" {
|
||||
sec = "tls"
|
||||
}
|
||||
res := map[string]any{"security": sec}
|
||||
if sec == "reality" {
|
||||
if rs, ok := ss["realitySettings"].(map[string]any); ok {
|
||||
res["dest"] = rs["dest"]
|
||||
res["publicKey"] = rs["publicKey"]
|
||||
if sids, ok := rs["shortIds"].([]any); ok && len(sids) > 0 {
|
||||
res["shortId"] = sids[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, res)
|
||||
}
|
||||
|
||||
func hSetSecurity(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Security string `json:"security"`
|
||||
Dest string `json:"dest"`
|
||||
ShortId string `json:"shortId"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Security != "tls" && req.Security != "reality" {
|
||||
writeJSON(w, 400, map[string]any{"error": "bad security"})
|
||||
return
|
||||
}
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, ib := range stealthInbounds(c) {
|
||||
ss := ib["streamSettings"].(map[string]any)
|
||||
ss["security"] = req.Security
|
||||
if req.Security == "reality" {
|
||||
if req.Dest == "" {
|
||||
req.Dest = "www.apple.com"
|
||||
}
|
||||
if req.ShortId == "" {
|
||||
req.ShortId = hexKey(4)
|
||||
}
|
||||
priv, pub := genX25519()
|
||||
ss["realitySettings"] = map[string]any{
|
||||
"show": false,
|
||||
"dest": req.Dest + ":443",
|
||||
"xver": 0,
|
||||
"serverNames": []string{req.Dest},
|
||||
"privateKey": priv,
|
||||
"shortIds": []string{req.ShortId},
|
||||
"publicKey": pub,
|
||||
}
|
||||
}
|
||||
}
|
||||
secPub := ""
|
||||
for _, ib := range stealthInbounds(c) {
|
||||
ss := ib["streamSettings"].(map[string]any)
|
||||
if req.Security == "reality" {
|
||||
if rs, ok := ss["realitySettings"].(map[string]any); ok {
|
||||
secPub, _ = rs["publicKey"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := writeConfig(c); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, rerr := restartXray()
|
||||
writeJSON(w, 200, map[string]any{"saved": true, "publicKey": secPub, "restart": strings.TrimSpace(out), "restartErr": errStr(rerr)})
|
||||
}
|
||||
|
||||
func hSecurityKey(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
priv, pub := genX25519()
|
||||
for _, ib := range stealthInbounds(c) {
|
||||
ss := ib["streamSettings"].(map[string]any)
|
||||
if rs, ok := ss["realitySettings"].(map[string]any); ok {
|
||||
rs["privateKey"] = priv
|
||||
rs["publicKey"] = pub
|
||||
}
|
||||
}
|
||||
if err := writeConfig(c); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, rerr := restartXray()
|
||||
writeJSON(w, 200, map[string]any{"privateKey": priv, "publicKey": pub, "restart": strings.TrimSpace(out), "restartErr": errStr(rerr)})
|
||||
}
|
||||
|
||||
func hRenameClient(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, ib := range stealthInbounds(c) {
|
||||
clients, _ := ib["settings"].(map[string]any)["clients"].([]any)
|
||||
for i, cl := range clients {
|
||||
if m, ok := cl.(map[string]any); ok && m["id"] == id {
|
||||
email := req.Name
|
||||
if email == "" {
|
||||
email = id
|
||||
}
|
||||
m["email"] = email
|
||||
clients[i] = m
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeJSON(w, 404, map[string]any{"error": "client not found"})
|
||||
return
|
||||
}
|
||||
if err := writeConfig(c); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"saved": true})
|
||||
}
|
||||
|
||||
func hBackup(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := os.ReadFile(panelCfg.Config)
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="config.json"`)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func hRestore(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var c map[string]any
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
writeJSON(w, 400, map[string]any{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if err := writeConfig(c); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, rerr := restartXray()
|
||||
writeJSON(w, 200, map[string]any{"saved": true, "restart": strings.TrimSpace(out), "restartErr": errStr(rerr)})
|
||||
}
|
||||
|
||||
func statsInboundPort(c map[string]any) int {
|
||||
api, ok := c["api"].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
ls, _ := api["listen"].(string)
|
||||
if ls == "" {
|
||||
return 0
|
||||
}
|
||||
if i := strings.LastIndex(ls, ":"); i >= 0 {
|
||||
if n, err := strconv.Atoi(ls[i+1:]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ensureStats(c map[string]any) bool {
|
||||
changed := false
|
||||
if api, ok := c["api"].(map[string]any); ok {
|
||||
if _, ok := api["listen"]; !ok {
|
||||
api["listen"] = "127.0.0.1:10085"
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
c["api"] = map[string]any{
|
||||
"tag": "api",
|
||||
"listen": "127.0.0.1:10085",
|
||||
"services": []string{"HandlerService", "LoggerService", "StatsService"},
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if _, ok := c["stats"]; !ok {
|
||||
c["stats"] = map[string]any{}
|
||||
changed = true
|
||||
}
|
||||
pol, _ := c["policy"].(map[string]any)
|
||||
if pol == nil {
|
||||
pol = map[string]any{}
|
||||
c["policy"] = pol
|
||||
}
|
||||
levels, _ := pol["levels"].(map[string]any)
|
||||
if levels == nil {
|
||||
levels = map[string]any{}
|
||||
pol["levels"] = levels
|
||||
}
|
||||
if _, ok := levels["0"]; !ok {
|
||||
levels["0"] = map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true}
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func hStats(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ensureStats(c) {
|
||||
if err := writeConfig(c); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = restartXray()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
port := statsInboundPort(c)
|
||||
if port == 0 {
|
||||
writeJSON(w, 500, map[string]any{"error": "stats api not configured"})
|
||||
return
|
||||
}
|
||||
conn, err := grpc.NewClient("127.0.0.1:"+strconv.Itoa(port), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
cl := statsc.NewStatsServiceClient(conn)
|
||||
|
||||
traffic := map[string]map[string]int64{}
|
||||
tresp, err := cl.QueryStats(ctx, &statsc.QueryStatsRequest{Pattern: "user>>>"})
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, st := range tresp.Stat {
|
||||
name := st.Name
|
||||
if !strings.HasPrefix(name, "user>>>") {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(name, "user>>>")
|
||||
idx := strings.Index(rest, ">>>traffic>>>")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
email := rest[:idx]
|
||||
dir := rest[idx+len(">>>traffic>>>"):]
|
||||
if traffic[email] == nil {
|
||||
traffic[email] = map[string]int64{}
|
||||
}
|
||||
traffic[email][dir] = st.Value
|
||||
}
|
||||
|
||||
online := map[string]bool{}
|
||||
oresp, err := cl.GetAllOnlineUsers(ctx, &statsc.GetAllOnlineUsersRequest{})
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, u := range oresp.Users {
|
||||
email := u
|
||||
email = strings.TrimPrefix(email, "user>>>")
|
||||
email = strings.TrimSuffix(email, ">>>online")
|
||||
online[email] = true
|
||||
}
|
||||
|
||||
ipsOf := map[string][]string{}
|
||||
for email := range online {
|
||||
ipr, ierr := cl.GetStatsOnlineIpList(ctx, &statsc.GetStatsRequest{Name: "user>>>" + email + ">>>online"})
|
||||
if ierr != nil {
|
||||
continue
|
||||
}
|
||||
for ip := range ipr.Ips {
|
||||
ipsOf[email] = append(ipsOf[email], ip)
|
||||
}
|
||||
}
|
||||
|
||||
emails := map[string]bool{}
|
||||
for e := range traffic {
|
||||
emails[e] = true
|
||||
}
|
||||
for e := range online {
|
||||
emails[e] = true
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for e := range emails {
|
||||
t := traffic[e]
|
||||
ul, dl := int64(0), int64(0)
|
||||
if t != nil {
|
||||
ul = t["uplink"]
|
||||
dl = t["downlink"]
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"email": e,
|
||||
"ips": ipsOf[e],
|
||||
"uplink": ul,
|
||||
"downlink": dl,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"users": out})
|
||||
}
|
||||
|
||||
func tlsCertFile(ts map[string]any) string {
|
||||
if certs, ok := ts["certificates"].([]any); ok && len(certs) > 0 {
|
||||
if cm, ok := certs[0].(map[string]any); ok {
|
||||
f, _ := cm["certificateFile"].(string)
|
||||
return f
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// selfSignedPin возвращает base64(SHA256(der)) для self-signed сертификата (иначе "").
|
||||
func selfSignedPin(certFile string) string {
|
||||
if certFile == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := sh("openssl", "x509", "-noout", "-issuer", "-subject", "-in", certFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
issuer, subject := "", ""
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(line, "issuer=") {
|
||||
issuer = strings.TrimSpace(strings.TrimPrefix(line, "issuer="))
|
||||
}
|
||||
if strings.HasPrefix(line, "subject=") {
|
||||
subject = strings.TrimSpace(strings.TrimPrefix(line, "subject="))
|
||||
}
|
||||
}
|
||||
if issuer == "" || issuer != subject {
|
||||
return ""
|
||||
}
|
||||
der, err := sh("openssl", "x509", "-in", certFile, "-outform", "der")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(der))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func hGetStealth(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := readConfig()
|
||||
if err != nil {
|
||||
@@ -602,15 +1105,21 @@ func hRotateStealthKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func hGetPanel(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"bind": panelCfg.Bind, "config": panelCfg.Config, "serverName": panelCfg.ServerName, "passwordSet": panelCfg.Password != ""})
|
||||
fp := panelCfg.Fingerprint
|
||||
if fp == "" {
|
||||
fp = "chrome"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"bind": panelCfg.Bind, "login": panelCfg.Login, "config": panelCfg.Config, "serverName": panelCfg.ServerName, "fingerprint": fp, "passwordSet": panelCfg.Password != ""})
|
||||
}
|
||||
|
||||
func hSetPanel(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Bind string `json:"bind"`
|
||||
Config string `json:"config"`
|
||||
Password string `json:"password"`
|
||||
ServerName string `json:"serverName"`
|
||||
Bind string `json:"bind"`
|
||||
Config string `json:"config"`
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
ServerName string `json:"serverName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Bind != "" {
|
||||
@@ -619,10 +1128,14 @@ func hSetPanel(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Config != "" {
|
||||
panelCfg.Config = req.Config
|
||||
}
|
||||
if req.Login != "" {
|
||||
panelCfg.Login = req.Login
|
||||
}
|
||||
if req.Password != "" {
|
||||
panelCfg.Password = req.Password
|
||||
}
|
||||
panelCfg.ServerName = req.ServerName
|
||||
panelCfg.Fingerprint = req.Fingerprint
|
||||
if err := savePanel(panelCfg); err != nil {
|
||||
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
@@ -649,10 +1162,13 @@ func hRestart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func main() {
|
||||
panelCfg = loadPanel()
|
||||
if panelCfg.Login == "" {
|
||||
panelCfg.Login = "admin"
|
||||
}
|
||||
if panelCfg.Password == "" {
|
||||
panelCfg.Password = hexKey(8)
|
||||
_ = savePanel(panelCfg)
|
||||
fmt.Printf("panel password generated: %s\n", panelCfg.Password)
|
||||
fmt.Printf("panel login: %s\npanel password: %s\n", panelCfg.Login, panelCfg.Password)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -679,6 +1195,32 @@ func main() {
|
||||
mux.HandleFunc("/api/clients/{id}/rotate", requireSession(hRotateClient))
|
||||
mux.HandleFunc("/api/clients/{id}/config", requireSession(hClientConfig))
|
||||
mux.HandleFunc("/api/clients/{id}/link", requireSession(hClientLink))
|
||||
mux.HandleFunc("/api/clients/{id}/rename", requireSession(hRenameClient))
|
||||
mux.HandleFunc("/api/version", requireSession(hVersion))
|
||||
mux.HandleFunc("/api/loglevel", requireSession(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
hGetLogLevel(w, r)
|
||||
case http.MethodPost:
|
||||
hSetLogLevel(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
mux.HandleFunc("/api/security", requireSession(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
hGetSecurity(w, r)
|
||||
case http.MethodPost:
|
||||
hSetSecurity(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
mux.HandleFunc("/api/security/key", requireSession(hSecurityKey))
|
||||
mux.HandleFunc("/api/backup", requireSession(hBackup))
|
||||
mux.HandleFunc("/api/stats", requireSession(hStats))
|
||||
mux.HandleFunc("/api/restore", requireSession(hRestore))
|
||||
mux.HandleFunc("/api/stealth", requireSession(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
|
||||
+123
-9
@@ -74,6 +74,8 @@ pre{background:#0b1016;border:1px solid var(--line);border-radius:12px;padding:1
|
||||
<div id="login"><div class="login-card">
|
||||
<h1>sdm-panel</h1>
|
||||
<p class="sub">stealth tunnel · управление сервером</p>
|
||||
<label>Логин</label>
|
||||
<input type="text" id="login" value="admin" onkeydown="if(event.key==='Enter')document.getElementById('pw').focus()">
|
||||
<label>Пароль</label>
|
||||
<input type="password" id="pw" onkeydown="if(event.key==='Enter')login()" autofocus>
|
||||
<br><br>
|
||||
@@ -111,12 +113,36 @@ pre{background:#0b1016;border:1px solid var(--line);border-radius:12px;padding:1
|
||||
<div style="flex:1;display:flex;align-items:flex-end"><button class="primary" style="width:100%" onclick="addClient()">Добавить</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" id="statsCard" style="margin-bottom:16px">
|
||||
<div class="section">Трафик и онлайн (xray stats)</div>
|
||||
<div id="statsBody" class="mut">загрузка…</div>
|
||||
</div>
|
||||
<div id="clientsList"></div>
|
||||
</div>
|
||||
|
||||
<div id="t-tunnel" class="hidden">
|
||||
<h2>Туннель</h2><p class="crumb">Зеркало и параметры транспорта</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="section">Безопасность (TLS / REALITY)</div>
|
||||
<div class="row">
|
||||
<div style="flex:1"><label>Security</label>
|
||||
<select id="secType" style="width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-size:13px">
|
||||
<option value="tls">TLS 1.3 (uTLS)</option>
|
||||
<option value="reality">REALITY</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:2"><label>REALITY dest (сайт-цель)</label><input type="text" id="secDest" placeholder="www.apple.com"></div>
|
||||
<div style="flex:1"><label>shortId (hex)</label><input type="text" id="secShort"></div>
|
||||
</div>
|
||||
<div style="margin-top:10px" class="mut" id="secPub"></div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button class="primary" onclick="saveSecurity()">Применить security</button>
|
||||
<button onclick="genSecurityKey()">Сменить REALITY-ключ</button>
|
||||
<span class="mut">При переключении на REALITY генерируется ключевая пара; publicKey нужно раздать клиентам.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section">Зеркало (сайт, который видит зонд/браузер)</div>
|
||||
<div class="row">
|
||||
@@ -137,6 +163,13 @@ pre{background:#0b1016;border:1px solid var(--line);border-radius:12px;padding:1
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section">Маскировка (опции вкл/выкл)</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0"><input type="checkbox" id="padOn"> Padding кадров — случайный мусор в потоке (маскирует размеры, чуть медленнее)</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0"><input type="checkbox" id="decoyOn"> Decoy-трафик — фоновые запросы к CDN</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0"><input type="checkbox" id="paceOn"> Pacing — интервалы между кадрами (сглаживает поток)</label>
|
||||
</div>
|
||||
|
||||
<details class="card" style="padding:14px 16px">
|
||||
<summary style="cursor:pointer;font-weight:500">Экспертные настройки</summary>
|
||||
<div class="section">Пути туннеля (по одному на строку; клиенты и сервер должны совпадать)</div>
|
||||
@@ -173,14 +206,55 @@ pre{background:#0b1016;border:1px solid var(--line);border-radius:12px;padding:1
|
||||
<div class="row">
|
||||
<div><label>serverName (домен для клиентских конфигов)</label><input type="text" id="pServer"></div>
|
||||
<div><label>bind</label><input type="text" id="pBind"></div>
|
||||
<div><label>TLS fingerprint (отпечаток браузера)</label>
|
||||
<select id="pFp" style="width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-size:13px">
|
||||
<option value="chrome">Chrome</option>
|
||||
<option value="firefox">Firefox</option>
|
||||
<option value="edge">Edge</option>
|
||||
<option value="safari">Safari</option>
|
||||
<option value="ios">iOS</option>
|
||||
<option value="360">360</option>
|
||||
<option value="qq">QQ</option>
|
||||
<option value="random">Random (случайный)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px"><label>config path</label><input type="text" id="pConfig"></div>
|
||||
<div style="margin-top:12px"><label>Логин</label><input type="text" id="pLogin"></div>
|
||||
<div style="margin-top:12px"><label>Новый пароль (пусто = не менять)</label><input type="password" id="pPass"></div>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="savePanel()">Сохранить</button>
|
||||
<span class="mut">Смена bind/пароля вступает в силу после перезапуска панели: systemctl restart sdm-panel</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section">Логи xray</div>
|
||||
<div class="row">
|
||||
<div style="flex:1"><label>Уровень логирования</label>
|
||||
<select id="lvLevel" style="width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-size:13px">
|
||||
<option value="error">error</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="info">info</option>
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end"><button onclick="saveLoglevel()">Сохранить</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section">Версия и сертификат</div>
|
||||
<div class="mut" id="verInfo">—</div>
|
||||
<div class="actions" style="margin-top:10px"><button onclick="loadVersion()">Обновить</button></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section">Бэкап / восстановление config.json</div>
|
||||
<div class="actions" style="margin-top:10px">
|
||||
<button onclick="backup()">Скачать config.json</button>
|
||||
<label style="display:inline-flex;align-items:center;cursor:pointer;border:1px solid #2a3546;border-radius:8px;padding:8px 14px;color:var(--mut);font-size:13px;background:#1d2735">Загрузить конфиг<input type="file" id="restoreFile" accept=".json,application/json" style="display:none" onchange="restoreCfg(this)"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="t-logs" class="hidden">
|
||||
@@ -206,11 +280,11 @@ function api(p, opt){ opt=opt||{};
|
||||
}
|
||||
function toast(msg,ok){const t=document.getElementById('toast');t.textContent=msg;t.className='show '+(ok?'ok':'err');setTimeout(()=>t.className='',2600)}
|
||||
function esc(s){return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function login(){fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.getElementById('pw').value})})
|
||||
.then(r=>r.json()).then(j=>{if(!j.token)throw new Error('неверный пароль');TOK=j.token;localStorage.setItem('tok',j.token);showApp()}).catch(e=>toast(e.message,false))}
|
||||
function login(){fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({login:document.getElementById('login').value,password:document.getElementById('pw').value})})
|
||||
.then(r=>r.json()).then(j=>{if(!j.token)throw new Error('неверные данные');TOK=j.token;localStorage.setItem('tok',j.token);showApp()}).catch(e=>toast(e.message,false))}
|
||||
function logout(){localStorage.removeItem('tok');TOK='';location.reload()}
|
||||
function showApp(){document.getElementById('login').classList.add('hidden');document.getElementById('app').classList.remove('hidden');tab('status')}
|
||||
function tab(n){document.querySelectorAll('#app main > div').forEach(t=>t.classList.toggle('hidden',t.id!=='t-'+n));document.querySelectorAll('aside nav button').forEach(b=>b.classList.toggle('on',b.dataset.t===n));if(n==='status')loadStatus();if(n==='clients')loadClients();if(n==='tunnel')loadStealth();if(n==='panel')loadPanel();if(n==='logs')loadLogs()}
|
||||
function tab(n){document.querySelectorAll('#app main > div').forEach(t=>t.classList.toggle('hidden',t.id!=='t-'+n));document.querySelectorAll('aside nav button').forEach(b=>b.classList.toggle('on',b.dataset.t===n));if(n==='status')loadStatus();if(n==='clients'){loadClients();loadStats()}if(n==='tunnel'){loadStealth();loadSecurity()}if(n==='panel'){loadPanel();loadLoglevel();loadVersion()}if(n==='logs')loadLogs()}
|
||||
|
||||
async function loadStatus(){try{const s=await api('api/status');
|
||||
const badge=s.service==='active'?'<span class="badge ok">active</span>':'<span class="badge bad">'+esc(s.service)+'</span>';
|
||||
@@ -226,10 +300,23 @@ async function loadClients(){try{const j=await api('api/clients');const L=docume
|
||||
'<button class="ghost" title="Скопировать vless:// ссылку" onclick="copyLink(\''+esc(cl.id)+'\')">⧉ ссылка</button>'+
|
||||
'<button class="ghost" title="Скопировать конфиг" onclick="copyConfig(\''+esc(cl.id)+'\')">⧉ конфиг</button>'+
|
||||
'<button class="ghost" title="Скопировать UUID" onclick="copyText(\''+esc(cl.id)+'\',\'UUID скопирован\')">⧉ uuid</button>'+
|
||||
'<button class="ghost" title="Переименовать" onclick="renameClient(\''+esc(cl.id)+'\')">✎</button>'+
|
||||
'<button class="ghost" title="Новый UUID" onclick="rotClient(\''+esc(cl.id)+'\')">⟳</button>'+
|
||||
'<button class="ghost" title="Удалить" onclick="delClient(\''+esc(cl.id)+'\')">✕</button>'+
|
||||
'</div>';
|
||||
L.appendChild(d)})}catch(e){toast(e.message,false)}}
|
||||
async function loadStats(){try{const j=await api('api/stats');
|
||||
const el=document.getElementById('statsBody');
|
||||
const u=j.users||[];
|
||||
if(!u.length){el.innerHTML='<div class="mut">Соединений с момента включения статистики ещё нет.</div>';return}
|
||||
const h=u.map(s=>{
|
||||
const on=s.ips&&s.ips.length?'<span class="badge ok">онлайн '+s.ips.length+'</span>':'<span class="badge">офлайн</span>';
|
||||
return '<div style="display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)">'+
|
||||
'<div style="flex:1">'+esc(s.email||'—')+'</div>'+on+
|
||||
'<div style="min-width:120px;text-align:right">↑ '+fmtB(s.uplink)+'</div>'+
|
||||
'<div style="min-width:120px;text-align:right">↓ '+fmtB(s.downlink)+'</div></div>'}).join('');
|
||||
el.innerHTML=h}catch(e){toast(e.message,false)}}
|
||||
function fmtB(n){if(!n)return'0 B';if(n<1024)return n+' B';if(n<1048576)return(n/1024).toFixed(1)+' KB';if(n<1073741824)return(n/1048576).toFixed(1)+' MB';return(n/1073741824).toFixed(2)+' GB'}
|
||||
async function addClient(){try{const n=document.getElementById('newName').value.trim();const j=await api('api/clients',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n||'client'})});toast('клиент добавлен',true);document.getElementById('newName').value='';loadClients()}catch(e){toast(e.message,false)}}
|
||||
async function delClient(id){if(!confirm('Удалить клиента?'))return;try{await api('api/clients/'+id,{method:'DELETE'});toast('клиент удалён',true);loadClients()}catch(e){toast(e.message,false)}}
|
||||
async function rotClient(id){try{const j=await api('api/clients/'+id+'/rotate',{method:'POST'});toast('новый UUID выдан',true);loadClients()}catch(e){toast(e.message,false)}}
|
||||
@@ -237,28 +324,55 @@ async function copyConfig(id){try{const r=await fetch('api/clients/'+id+'/config
|
||||
async function copyLink(id){try{const r=await fetch('api/clients/'+id+'/link',{headers:{'Authorization':'Bearer '+TOK}});const j=await r.json();await navigator.clipboard.writeText(j.link);toast('vless:// ссылка скопирована',true)}catch(e){toast('ошибка: '+e.message,false)}}
|
||||
function copyText(t,msg){navigator.clipboard.writeText(t).then(()=>toast(msg||'скопировано',true)).catch(e=>toast(e.message,false))}
|
||||
|
||||
async function loadSecurity(){try{const s=await api('api/security');
|
||||
document.getElementById('secType').value=s.security||'tls';
|
||||
document.getElementById('secDest').value=s.dest||'';
|
||||
document.getElementById('secShort').value=s.shortId||'';
|
||||
document.getElementById('secPub').textContent=s.publicKey?('REALITY publicKey (для клиентов): '+s.publicKey):'';
|
||||
}catch(e){toast(e.message,false)}}
|
||||
async function saveSecurity(){try{const j=await api('api/security',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({security:document.getElementById('secType').value,dest:document.getElementById('secDest').value.trim(),shortId:document.getElementById('secShort').value.trim()})});
|
||||
toast('security применён, xray: '+(j.restart||'ok'),true);
|
||||
if(j.publicKey){document.getElementById('secPub').textContent='REALITY publicKey (для клиентов): '+j.publicKey;navigator.clipboard.writeText(j.publicKey);toast('publicKey скопирован',true)}
|
||||
loadSecurity()}catch(e){toast(e.message,false)}}
|
||||
async function genSecurityKey(){if(!confirm('Сменить REALITY-ключ? Клиентские конфиги потеряют связь до обновления publicKey.'))return;try{const j=await api('api/security/key',{method:'POST'});toast('новый publicKey: '+j.publicKey,true);navigator.clipboard.writeText(j.publicKey);loadSecurity()}catch(e){toast(e.message,false)}}
|
||||
async function loadLoglevel(){try{const j=await api('api/loglevel');document.getElementById('lvLevel').value=j.loglevel||'warning'}catch(e){toast(e.message,false)}}
|
||||
async function saveLoglevel(){try{const j=await api('api/loglevel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({loglevel:document.getElementById('lvLevel').value})});toast('сохранено, xray: '+(j.restart||'ok'),true)}catch(e){toast(e.message,false)}}
|
||||
async function loadVersion(){try{const j=await api('api/version');document.getElementById('verInfo').innerHTML='<div>'+esc(j.xray)+'</div><div>cert: '+(j.certFile?esc(j.certFile):'—')+'</div><div>expires: '+(j.certExpiry?esc(j.certExpiry):'—')+'</div>'}catch(e){toast(e.message,false)}}
|
||||
function backup(){fetch('api/backup',{headers:{'Authorization':'Bearer '+TOK}}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.blob()}).then(b=>{const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='config.json';a.click();URL.revokeObjectURL(a.href);toast('конфиг скачан',true)}).catch(e=>toast(e.message,false))}
|
||||
function restoreCfg(inp){const f=inp.files[0];if(!f)return;if(!confirm('Перезаписать config.json и перезапустить xray?')){inp.value='';return}const rd=new FileReader();rd.onload=async()=>{try{const j=await api('api/restore',{method:'POST',headers:{'Content-Type':'application/json'},body:rd.result});toast('восстановлено, xray: '+(j.restart||'ok'),true);loadStatus()}catch(e){toast(e.message,false)}};rd.readAsText(f);inp.value=''}
|
||||
async function renameClient(id){const nm=prompt('Новое имя клиента:', '');if(!nm||!nm.trim())return;try{await api('api/clients/'+id+'/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:nm.trim()})});toast('переименовано',true);loadClients()}catch(e){toast(e.message,false)}}
|
||||
async function loadStealth(){try{const s=await api('api/stealth');
|
||||
document.getElementById('paths').value=(s.paths||[]).join('\n');
|
||||
const set=(id,v)=>document.getElementById(id).value=(v===undefined?'':v);
|
||||
set('ports',(s.ports||[]).join(', '));set('padMax',s.padMax);set('padProb',s.padProb);set('paceMs',s.paceMs);set('paceJitterMs',s.paceJitterMs);set('bps',s.bps);set('retries',s.retries);set('padProfile',s.padProfile);set('target',s.target);set('decoy',s.decoy);set('decoyEverySecs',s.decoyEverySecs);set('decoyTarget',s.decoyTarget)}catch(e){toast(e.message,false)}}
|
||||
set('ports',(s.ports||[]).join(', '));set('padMax',s.padMax);set('padProb',s.padProb);set('paceMs',s.paceMs);set('paceJitterMs',s.paceJitterMs);set('bps',s.bps);set('retries',s.retries);set('padProfile',s.padProfile);set('target',s.target);set('decoy',s.decoy);set('decoyEverySecs',s.decoyEverySecs);set('decoyTarget',s.decoyTarget);
|
||||
document.getElementById('padOn').checked = (s.padMax||0)>0;
|
||||
document.getElementById('decoyOn').checked = s.decoy===true;
|
||||
document.getElementById('paceOn').checked = (s.paceMs||0)>0;}catch(e){toast(e.message,false)}}
|
||||
async function saveStealth(){try{const num=v=>{const n=Number(v);return isNaN(n)?0:n};
|
||||
let padMax=num(document.getElementById('padMax').value), padProb=num(document.getElementById('padProb').value);
|
||||
if(document.getElementById('padOn').checked){if(padMax<=0)padMax=2048;if(padProb<=0)padProb=50}else{padMax=0;padProb=0}
|
||||
let decoy=document.getElementById('decoy').value==='true';
|
||||
if(document.getElementById('decoyOn').checked)decoy=true;else decoy=false;
|
||||
let paceMs=num(document.getElementById('paceMs').value);
|
||||
if(document.getElementById('paceOn').checked){if(paceMs<=0)paceMs=5}else{paceMs=0}
|
||||
const body={paths:document.getElementById('paths').value.split('\n').map(s=>s.trim()).filter(Boolean),
|
||||
ports:document.getElementById('ports').value.split(',').map(s=>Number(s.trim())).filter(Boolean),
|
||||
padMax:num(document.getElementById('padMax').value),padProb:num(document.getElementById('padProb').value),
|
||||
paceMs:num(document.getElementById('paceMs').value),paceJitterMs:num(document.getElementById('paceJitterMs').value),
|
||||
padMax:padMax,padProb:padProb,
|
||||
paceMs:paceMs,paceJitterMs:num(document.getElementById('paceJitterMs').value),
|
||||
bps:num(document.getElementById('bps').value),retries:num(document.getElementById('retries').value),
|
||||
padProfile:document.getElementById('padProfile').value,target:document.getElementById('target').value,
|
||||
decoy:document.getElementById('decoy').value==='true',decoyEverySecs:num(document.getElementById('decoyEverySecs').value),
|
||||
decoy:decoy,decoyEverySecs:num(document.getElementById('decoyEverySecs').value),
|
||||
decoyTarget:document.getElementById('decoyTarget').value};
|
||||
const j=await api('api/stealth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('сохранено, xray: '+(j.restart||'ok'),true)}catch(e){toast(e.message,false)}}
|
||||
async function rotStealthKey(){try{const j=await api('api/stealth/key',{method:'POST'});toast('новый stealth-ключ: '+j.key,true)}catch(e){toast(e.message,false)}}
|
||||
|
||||
async function loadPanel(){try{const p=await api('api/panel');document.getElementById('pServer').value=p.serverName||'';document.getElementById('pBind').value=p.bind;document.getElementById('pConfig').value=p.config}catch(e){toast(e.message,false)}}
|
||||
async function savePanel(){try{await api('api/panel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({serverName:document.getElementById('pServer').value,bind:document.getElementById('pBind').value,config:document.getElementById('pConfig').value,password:document.getElementById('pPass').value})});toast('сохранено',true);document.getElementById('pPass').value=''}catch(e){toast(e.message,false)}}
|
||||
async function loadPanel(){try{const p=await api('api/panel');document.getElementById('pServer').value=p.serverName||'';document.getElementById('pBind').value=p.bind;document.getElementById('pConfig').value=p.config;document.getElementById('pLogin').value=p.login||'admin';document.getElementById('pFp').value=p.fingerprint||'chrome'}catch(e){toast(e.message,false)}}
|
||||
async function savePanel(){try{await api('api/panel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({serverName:document.getElementById('pServer').value,bind:document.getElementById('pBind').value,config:document.getElementById('pConfig').value,login:document.getElementById('pLogin').value,password:document.getElementById('pPass').value,fingerprint:document.getElementById('pFp').value})});toast('сохранено',true);document.getElementById('pPass').value=''}catch(e){toast(e.message,false)}}
|
||||
|
||||
async function restart(){try{const j=await api('api/restart',{method:'POST'});toast('xray: '+(j.restart||'ok'),true);loadStatus()}catch(e){toast(e.message,false)}}
|
||||
async function loadLogs(){try{const j=await api('api/logs?n=150');document.getElementById('logs').textContent=j.logs||''}catch(e){toast(e.message,false)}}
|
||||
|
||||
setInterval(()=>{if(document.querySelector('aside nav button[data-t=clients].on'))loadStats()},5000);
|
||||
if(TOK){showApp()}else{document.getElementById('pw').focus()}
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
Reference in New Issue
Block a user