|
|
|
@@ -1,302 +0,0 @@
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"crypto/subtle"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
configPath = "/etc/xray/config.json"
|
|
|
|
|
panelPath = "/etc/xray/panel.json"
|
|
|
|
|
service = "xray"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type PanelConfig struct {
|
|
|
|
|
Bind string `json:"bind"`
|
|
|
|
|
Password string `json:"password"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadPanel() PanelConfig {
|
|
|
|
|
p := PanelConfig{Bind: "127.0.0.1:8080"}
|
|
|
|
|
if b, err := os.ReadFile(panelPath); err == nil {
|
|
|
|
|
_ = json.Unmarshal(b, &p)
|
|
|
|
|
}
|
|
|
|
|
if p.Bind == "" {
|
|
|
|
|
p.Bind = "127.0.0.1:8080"
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func auth(password string, next http.HandlerFunc) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u, p, ok := r.BasicAuth()
|
|
|
|
|
if !ok || u != "admin" || subtle.ConstantTimeCompare([]byte(p), []byte(password)) != 1 {
|
|
|
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="sdm-panel"`)
|
|
|
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
next(w, r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(code)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sh(cmd string, args ...string) (string, error) {
|
|
|
|
|
out, err := exec.Command(cmd, args...).CombinedOutput()
|
|
|
|
|
return strings.TrimSpace(string(out)), err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func restartXray() (string, error) {
|
|
|
|
|
return sh("systemctl", "restart", service)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func readConfig() (map[string]any, error) {
|
|
|
|
|
b, err := os.ReadFile(configPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var c map[string]any
|
|
|
|
|
if err := json.Unmarshal(b, &c); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeConfig(c map[string]any) error {
|
|
|
|
|
b, err := json.MarshalIndent(c, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !json.Valid(b) {
|
|
|
|
|
return fmt.Errorf("invalid json")
|
|
|
|
|
}
|
|
|
|
|
tmp := configPath + ".tmp"
|
|
|
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return os.Rename(tmp, configPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newUUID() string {
|
|
|
|
|
var b [16]byte
|
|
|
|
|
_, _ = rand.Read(b[:])
|
|
|
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
|
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
|
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newKey() string {
|
|
|
|
|
b := make([]byte, 8)
|
|
|
|
|
_, _ = rand.Read(b)
|
|
|
|
|
return hex.EncodeToString(b)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- handlers ---
|
|
|
|
|
|
|
|
|
|
func hStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
active, _ := sh("systemctl", "is-active", service)
|
|
|
|
|
ts, _ := sh("systemctl", "show", service, "--property=ActiveEnterTimestamp", "--value")
|
|
|
|
|
uptime, _ := sh("cat", "/proc/uptime")
|
|
|
|
|
conf, err := readConfig()
|
|
|
|
|
var uuid, key string
|
|
|
|
|
if err == nil {
|
|
|
|
|
if ib, ok := conf["inbounds"].([]any); ok && len(ib) > 0 {
|
|
|
|
|
if m, ok := ib[0].(map[string]any); ok {
|
|
|
|
|
if s, ok := m["settings"].(map[string]any); ok {
|
|
|
|
|
if cl, ok := s["clients"].([]any); ok && len(cl) > 0 {
|
|
|
|
|
if cm, ok := cl[0].(map[string]any); ok {
|
|
|
|
|
uuid, _ = cm["id"].(string)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ss, ok := m["streamSettings"].(map[string]any); ok {
|
|
|
|
|
if st, ok := ss["stealthSettings"].(map[string]any); ok {
|
|
|
|
|
key, _ = st["key"].(string)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, 200, map[string]any{
|
|
|
|
|
"service": active,
|
|
|
|
|
"activeAt": ts,
|
|
|
|
|
"uptime": strings.TrimSpace(uptime),
|
|
|
|
|
"uuid": uuid,
|
|
|
|
|
"key": key,
|
|
|
|
|
"configPath": configPath,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hConfig(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
b, err := os.ReadFile(configPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_, _ = w.Write(b)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hSave(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<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: " + err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := writeConfig(c); err != nil {
|
|
|
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
out, err := restartXray()
|
|
|
|
|
writeJSON(w, 200, map[string]any{"saved": true, "restart": strings.TrimSpace(out), "restartErr": errStr(err)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hRotateKey(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
c, err := readConfig()
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
uuid := newUUID()
|
|
|
|
|
key := newKey()
|
|
|
|
|
ib, _ := c["inbounds"].([]any)
|
|
|
|
|
if len(ib) == 0 {
|
|
|
|
|
writeJSON(w, 500, map[string]any{"error": "no inbounds"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
m := ib[0].(map[string]any)
|
|
|
|
|
s := m["settings"].(map[string]any)
|
|
|
|
|
cl := s["clients"].([]any)
|
|
|
|
|
cl[0].(map[string]any)["id"] = uuid
|
|
|
|
|
ss := m["streamSettings"].(map[string]any)
|
|
|
|
|
st := ss["stealthSettings"].(map[string]any)
|
|
|
|
|
st["key"] = key
|
|
|
|
|
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{"uuid": uuid, "key": key, "restart": strings.TrimSpace(out), "restartErr": errStr(rerr)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hLogs(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
n := r.URL.Query().Get("n")
|
|
|
|
|
if n == "" {
|
|
|
|
|
n = "100"
|
|
|
|
|
}
|
|
|
|
|
out, _ := sh("journalctl", "-u", service, "-n", n, "--no-pager")
|
|
|
|
|
writeJSON(w, 200, map[string]any{"logs": out})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hRestart(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
out, err := restartXray()
|
|
|
|
|
writeJSON(w, 200, map[string]any{"restart": strings.TrimSpace(out), "error": errStr(err)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func errStr(err error) string {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return err.Error()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- UI ---
|
|
|
|
|
|
|
|
|
|
const page = `<!DOCTYPE html>
|
|
|
|
|
<html lang="ru"><head><meta charset="utf-8"><title>sdm-panel</title>
|
|
|
|
|
<style>
|
|
|
|
|
body{font-family:ui-monospace,Menlo,Consolas,monospace;background:#101418;color:#d7dde3;margin:0;padding:24px}
|
|
|
|
|
h1{font-size:18px;margin:0 0 16px}
|
|
|
|
|
.card{background:#171d24;border:1px solid #262e38;border-radius:10px;padding:16px;margin-bottom:16px}
|
|
|
|
|
button{background:#234;color:#fff;border:1px solid #355;border-radius:6px;padding:6px 12px;cursor:pointer;margin-right:8px}
|
|
|
|
|
button:hover{background:#2a4a5a}
|
|
|
|
|
label{display:block;margin:8px 0 4px;color:#8aa}
|
|
|
|
|
input,textarea{width:100%;box-sizing:border-box;background:#0d1117;color:#d7dde3;border:1px solid #2a3340;border-radius:6px;padding:8px;font-family:inherit}
|
|
|
|
|
textarea{min-height:200px;font-size:12px}
|
|
|
|
|
pre{background:#0d1117;border:1px solid #2a3340;border-radius:6px;padding:8px;overflow:auto;max-height:300px;font-size:12px}
|
|
|
|
|
.kv{display:flex;gap:8px;flex-wrap:wrap}.kv>div{flex:1;min-width:200px}
|
|
|
|
|
</style></head><body>
|
|
|
|
|
<h1>sdm-panel — stealth tunnel</h1>
|
|
|
|
|
<div class="card"><div class="kv">
|
|
|
|
|
<div><label>service</label><input id="service" readonly></div>
|
|
|
|
|
<div><label>active since</label><input id="activeAt" readonly></div>
|
|
|
|
|
<div><label>uuid</label><input id="uuid" readonly></div>
|
|
|
|
|
<div><label>key</label><input id="key" readonly></div>
|
|
|
|
|
</div>
|
|
|
|
|
<button onclick="rotKey()">Сменить ключ+UUID</button>
|
|
|
|
|
<button onclick="restart()">Перезапустить xray</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card"><label>config.json (внешний stealthSettings — полный конфиг)</label>
|
|
|
|
|
<textarea id="cfg"></textarea><br><br>
|
|
|
|
|
<button onclick="save()">Сохранить и перезапустить</button>
|
|
|
|
|
<span id="msg"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card"><label>логи xray</label><button onclick="loadLogs()">Обновить</button>
|
|
|
|
|
<pre id="logs"></pre></div>
|
|
|
|
|
<script>
|
|
|
|
|
async function j(f, opts){const r=await fetch(f, opts);return r.json()}
|
|
|
|
|
async function status(){const s=await j('api/status');for(const k of ['service','activeAt','uuid','key'])document.getElementById(k).value=s[k]||''}
|
|
|
|
|
async function cfg(){const r=await fetch('api/config');const t=await r.text();document.getElementById('cfg').value=t}
|
|
|
|
|
async function save(){const m=document.getElementById('msg');m.textContent='...';try{const r=await fetch('api/config',{method:'POST',body:document.getElementById('cfg').value});const j=await r.json();m.textContent=JSON.stringify(j)}catch(e){m.textContent='err '+e}}
|
|
|
|
|
async function rotKey(){const m=document.getElementById('msg');m.textContent='...';const j=await fetch('api/key',{method:'POST'}).then(r=>r.json());document.getElementById('uuid').value=j.uuid||'';document.getElementById('key').value=j.key||'';m.textContent=JSON.stringify(j)}
|
|
|
|
|
async function restart(){await fetch('api/restart',{method:'POST'});status()}
|
|
|
|
|
async function loadLogs(){const j=await fetch('api/logs?n=100').then(r=>r.json());document.getElementById('logs').textContent=j.logs||''}
|
|
|
|
|
status();cfg();loadLogs()
|
|
|
|
|
</script></body></html>`
|
|
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
|
pc := loadPanel()
|
|
|
|
|
if pc.Password == "" {
|
|
|
|
|
pass := newKey()
|
|
|
|
|
pc.Password = pass
|
|
|
|
|
_ = os.WriteFile(panelPath, []byte(fmt.Sprintf("{\n \"bind\": \"%s\",\n \"password\": \"%s\"\n}\n", pc.Bind, pass)), 0o600)
|
|
|
|
|
fmt.Printf("panel password generated: %s\n", pass)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("/", auth(pc.Password, func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path == "/" {
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
_, _ = io.WriteString(w, page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}))
|
|
|
|
|
mux.HandleFunc("/api/status", auth(pc.Password, hStatus))
|
|
|
|
|
mux.HandleFunc("/api/config", auth(pc.Password, func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.Method {
|
|
|
|
|
case http.MethodGet:
|
|
|
|
|
hConfig(w, r)
|
|
|
|
|
case http.MethodPost:
|
|
|
|
|
hSave(w, r)
|
|
|
|
|
default:
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
mux.HandleFunc("/api/key", auth(pc.Password, hRotateKey))
|
|
|
|
|
mux.HandleFunc("/api/logs", auth(pc.Password, hLogs))
|
|
|
|
|
mux.HandleFunc("/api/restart", auth(pc.Password, hRestart))
|
|
|
|
|
|
|
|
|
|
fmt.Printf("sdm-panel listening on %s\n", pc.Bind)
|
|
|
|
|
if err := http.ListenAndServe(pc.Bind, mux); err != nil {
|
|
|
|
|
fmt.Fprintln(os.Stderr, err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|