1262 lines
32 KiB
Go
1262 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
statsc "sdm-panel/stats"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
)
|
|
|
|
const (
|
|
defaultConfigPath = "/etc/xray/config.json"
|
|
panelPath = "/etc/xray/panel.json"
|
|
service = "xray"
|
|
)
|
|
|
|
type PanelConfig struct {
|
|
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
|
|
|
|
func loadPanel() PanelConfig {
|
|
p := PanelConfig{Bind: "127.0.0.1:8080", Config: defaultConfigPath}
|
|
if b, err := os.ReadFile(panelPath); err == nil {
|
|
_ = json.Unmarshal(b, &p)
|
|
}
|
|
if p.Bind == "" {
|
|
p.Bind = "127.0.0.1:8080"
|
|
}
|
|
if p.Config == "" {
|
|
p.Config = defaultConfigPath
|
|
}
|
|
return p
|
|
}
|
|
|
|
func savePanel(p PanelConfig) error {
|
|
b, _ := json.MarshalIndent(p, "", " ")
|
|
tmp := panelPath + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, panelPath); err != nil {
|
|
return err
|
|
}
|
|
_ = os.Chmod(panelPath, 0o600)
|
|
return nil
|
|
}
|
|
|
|
// --- sessions ---
|
|
|
|
var (
|
|
sessMu sync.Mutex
|
|
sess = map[string]time.Time{}
|
|
)
|
|
|
|
func issueSession() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
tok := hex.EncodeToString(b)
|
|
sessMu.Lock()
|
|
sess[tok] = time.Now().Add(24 * time.Hour)
|
|
sessMu.Unlock()
|
|
return tok
|
|
}
|
|
|
|
func validSession(r *http.Request) bool {
|
|
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if tok == "" {
|
|
tok = r.URL.Query().Get("token")
|
|
}
|
|
if tok == "" {
|
|
return false
|
|
}
|
|
sessMu.Lock()
|
|
defer sessMu.Unlock()
|
|
exp, ok := sess[tok]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if time.Now().After(exp) {
|
|
delete(sess, tok)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func requireSession(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !validSession(r) {
|
|
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func checkPassword(login, pw string) bool {
|
|
if panelCfg.Login != "" && login != panelCfg.Login {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(pw), []byte(panelCfg.Password)) == 1
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func errStr(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// --- config helpers ---
|
|
|
|
func readConfig() (map[string]any, error) {
|
|
b, err := os.ReadFile(panelCfg.Config)
|
|
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
|
|
}
|
|
tmp := panelCfg.Config + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, panelCfg.Config)
|
|
}
|
|
|
|
func stealthInbounds(c map[string]any) []map[string]any {
|
|
var out []map[string]any
|
|
ibs, _ := c["inbounds"].([]any)
|
|
for _, ib := range ibs {
|
|
m, ok := ib.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ss, _ := m["streamSettings"].(map[string]any)
|
|
if net, _ := ss["network"].(string); net == "stealth" {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func getStealthSettings(c map[string]any) (map[string]any, bool) {
|
|
ibs := stealthInbounds(c)
|
|
if len(ibs) == 0 {
|
|
return nil, false
|
|
}
|
|
ss, _ := ibs[0]["streamSettings"].(map[string]any)
|
|
st, _ := ss["stealthSettings"].(map[string]any)
|
|
return st, st != nil
|
|
}
|
|
|
|
func uuid() 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 hexKey(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func setClients(ibs []map[string]any, clients []any) {
|
|
for _, ib := range ibs {
|
|
s, _ := ib["settings"].(map[string]any)
|
|
s["clients"] = clients
|
|
}
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
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.Login, req.Password) {
|
|
writeJSON(w, 401, map[string]any{"error": "bad credentials"})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"token": issueSession()})
|
|
}
|
|
|
|
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")
|
|
c, err := readConfig()
|
|
info := map[string]any{
|
|
"service": active,
|
|
"activeAt": strings.TrimSpace(ts),
|
|
"uptime": strings.TrimSpace(uptime),
|
|
"configPath": panelCfg.Config,
|
|
"bind": panelCfg.Bind,
|
|
}
|
|
if err == nil {
|
|
var ports []any
|
|
for _, ib := range c["inbounds"].([]any) {
|
|
if m, ok := ib.(map[string]any); ok {
|
|
ports = append(ports, m["port"])
|
|
}
|
|
}
|
|
info["ports"] = ports
|
|
if st, ok := getStealthSettings(c); ok {
|
|
info["paths"] = st["paths"]
|
|
if t, ok := st["target"].(string); ok {
|
|
info["target"] = t
|
|
}
|
|
}
|
|
if ibs := stealthInbounds(c); len(ibs) > 0 {
|
|
cl := ibs[0]["settings"].(map[string]any)["clients"].([]any)
|
|
info["clientCount"] = len(cl)
|
|
}
|
|
}
|
|
writeJSON(w, 200, info)
|
|
}
|
|
|
|
func hClients(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
|
|
}
|
|
s := ibs[0]["settings"].(map[string]any)
|
|
clients, _ := s["clients"].([]any)
|
|
var list []map[string]any
|
|
for _, cl := range clients {
|
|
m, _ := cl.(map[string]any)
|
|
list = append(list, map[string]any{
|
|
"id": m["id"],
|
|
"name": m["email"],
|
|
})
|
|
}
|
|
writeJSON(w, 200, map[string]any{"clients": list})
|
|
}
|
|
|
|
func hAddClient(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
ibs := stealthInbounds(c)
|
|
if len(ibs) == 0 {
|
|
writeJSON(w, 500, map[string]any{"error": "no stealth inbound"})
|
|
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": email, "level": 0})
|
|
setClients(ibs, clients)
|
|
if err := writeConfig(c); err != nil {
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"id": id})
|
|
}
|
|
|
|
func hDelClient(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
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
|
|
}
|
|
s := ibs[0]["settings"].(map[string]any)
|
|
clients, _ := s["clients"].([]any)
|
|
var nc []any
|
|
for _, cl := range clients {
|
|
m, _ := cl.(map[string]any)
|
|
if m["id"] != id {
|
|
nc = append(nc, m)
|
|
}
|
|
}
|
|
setClients(ibs, nc)
|
|
if err := writeConfig(c); err != nil {
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func hRotateClient(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
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
|
|
}
|
|
s := ibs[0]["settings"].(map[string]any)
|
|
clients, _ := s["clients"].([]any)
|
|
newID := uuid()
|
|
done := false
|
|
for i, cl := range clients {
|
|
m, _ := cl.(map[string]any)
|
|
if m["id"] == id {
|
|
m["id"] = newID
|
|
clients[i] = m
|
|
done = true
|
|
}
|
|
}
|
|
if !done {
|
|
writeJSON(w, 404, map[string]any{"error": "client not found"})
|
|
return
|
|
}
|
|
setClients(ibs, clients)
|
|
if err := writeConfig(c); err != nil {
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"id": newID})
|
|
}
|
|
|
|
func hClientConfig(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
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
|
|
}
|
|
// verify client exists
|
|
found := false
|
|
for _, cl := range ibs[0]["settings"].(map[string]any)["clients"].([]any) {
|
|
if m, ok := cl.(map[string]any); ok && m["id"] == id {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
writeJSON(w, 404, map[string]any{"error": "client not found"})
|
|
return
|
|
}
|
|
st, _ := getStealthSettings(c)
|
|
if st == nil {
|
|
st = map[string]any{}
|
|
}
|
|
serverName := panelCfg.ServerName
|
|
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{
|
|
map[string]any{"listen": "127.0.0.1", "port": 10808, "protocol": "socks"},
|
|
map[string]any{"listen": "127.0.0.1", "port": 10809, "protocol": "http"},
|
|
},
|
|
"outbounds": []any{
|
|
map[string]any{
|
|
"protocol": "vless",
|
|
"settings": map[string]any{
|
|
"vnext": []any{map[string]any{
|
|
"address": serverName,
|
|
"port": 443,
|
|
"users": []any{map[string]any{"id": id, "encryption": "none"}},
|
|
}},
|
|
},
|
|
"streamSettings": map[string]any{
|
|
"network": "stealth",
|
|
"stealthSettings": st,
|
|
"security": "tls",
|
|
"tlsSettings": map[string]any{"serverName": serverName, "fingerprint": fp, "pinnedPeerCertSha256": pin},
|
|
},
|
|
},
|
|
map[string]any{"protocol": "freedom", "tag": "direct"},
|
|
},
|
|
}
|
|
b, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_, _ = w.Write(b)
|
|
}
|
|
|
|
func hClientLink(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
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
|
|
}
|
|
ib := ibs[0]
|
|
// find client
|
|
name := ""
|
|
found := false
|
|
for _, cl := range ib["settings"].(map[string]any)["clients"].([]any) {
|
|
if m, ok := cl.(map[string]any); ok && m["id"] == id {
|
|
found = true
|
|
name, _ = m["email"].(string)
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
writeJSON(w, 404, map[string]any{"error": "client not found"})
|
|
return
|
|
}
|
|
st, _ := getStealthSettings(c)
|
|
if st == nil {
|
|
st = map[string]any{}
|
|
}
|
|
serverName := panelCfg.ServerName
|
|
if serverName == "" {
|
|
serverName = "YOUR-DOMAIN"
|
|
}
|
|
ss := ib["streamSettings"].(map[string]any)
|
|
security, _ := ss["security"].(string)
|
|
if security == "" {
|
|
security = "tls"
|
|
}
|
|
|
|
q := url.Values{}
|
|
q.Set("encryption", "none")
|
|
q.Set("security", security)
|
|
q.Set("type", "stealth")
|
|
q.Set("sni", serverName)
|
|
|
|
// fingerprint
|
|
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 != "" {
|
|
q.Set("sni", v)
|
|
}
|
|
if v, ok := rs["fingerprint"].(string); ok && v != "" {
|
|
fp = v
|
|
}
|
|
if v, ok := rs["publicKey"].(string); ok {
|
|
q.Set("pbk", v)
|
|
}
|
|
if v, ok := rs["shortId"].(string); ok {
|
|
q.Set("sid", v)
|
|
}
|
|
}
|
|
} else if ts, ok := ss["tlsSettings"].(map[string]any); ok {
|
|
if v, ok := ts["fingerprint"].(string); ok && v != "" {
|
|
fp = v
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if ps, ok := st["paths"].([]any); ok {
|
|
var arr []string
|
|
for _, x := range ps {
|
|
if str, ok := x.(string); ok {
|
|
arr = append(arr, str)
|
|
}
|
|
}
|
|
if len(arr) > 0 {
|
|
q.Set("paths", strings.Join(arr, ","))
|
|
}
|
|
}
|
|
|
|
port := "443"
|
|
if pr, ok := st["ports"].([]any); ok && len(pr) > 0 {
|
|
if n, ok := pr[0].(float64); ok && n > 0 {
|
|
port = strconv.Itoa(int(n))
|
|
}
|
|
}
|
|
|
|
link := "vless://" + id + "@" + serverName + ":" + port + "?" + q.Encode() + "#" + url.QueryEscape(name)
|
|
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 {
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
st, ok := getStealthSettings(c)
|
|
if !ok {
|
|
writeJSON(w, 500, map[string]any{"error": "no stealth settings"})
|
|
return
|
|
}
|
|
writeJSON(w, 200, st)
|
|
}
|
|
|
|
func hSetStealth(w http.ResponseWriter, r *http.Request) {
|
|
var patch map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
|
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)
|
|
st, _ := ss["stealthSettings"].(map[string]any)
|
|
if st == nil {
|
|
st = map[string]any{}
|
|
}
|
|
for k, v := range patch {
|
|
st[k] = v
|
|
}
|
|
ss["stealthSettings"] = st
|
|
}
|
|
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 hRotateStealthKey(w http.ResponseWriter, r *http.Request) {
|
|
c, err := readConfig()
|
|
if err != nil {
|
|
writeJSON(w, 500, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
key := hexKey(8)
|
|
for _, ib := range stealthInbounds(c) {
|
|
ss := ib["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{"key": key, "restart": strings.TrimSpace(out), "restartErr": errStr(rerr)})
|
|
}
|
|
|
|
func hGetPanel(w http.ResponseWriter, r *http.Request) {
|
|
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"`
|
|
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 != "" {
|
|
panelCfg.Bind = req.Bind
|
|
}
|
|
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
|
|
}
|
|
writeJSON(w, 200, map[string]any{"saved": true})
|
|
}
|
|
|
|
func hLogs(w http.ResponseWriter, r *http.Request) {
|
|
n := r.URL.Query().Get("n")
|
|
if n == "" {
|
|
n = "150"
|
|
}
|
|
if _, err := strconv.Atoi(n); err != nil || n == "" {
|
|
n = "150"
|
|
}
|
|
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 main() {
|
|
panelCfg = loadPanel()
|
|
if panelCfg.Login == "" {
|
|
panelCfg.Login = "admin"
|
|
}
|
|
if panelCfg.Password == "" {
|
|
panelCfg.Password = hexKey(8)
|
|
_ = savePanel(panelCfg)
|
|
fmt.Printf("panel login: %s\npanel password: %s\n", panelCfg.Login, panelCfg.Password)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/login", hLogin)
|
|
mux.HandleFunc("/api/status", requireSession(hStatus))
|
|
mux.HandleFunc("/api/clients", requireSession(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
hClients(w, r)
|
|
case http.MethodPost:
|
|
hAddClient(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
mux.HandleFunc("/api/clients/{id}", requireSession(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodDelete:
|
|
hDelClient(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
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:
|
|
hGetStealth(w, r)
|
|
case http.MethodPost:
|
|
hSetStealth(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
mux.HandleFunc("/api/stealth/key", requireSession(hRotateStealthKey))
|
|
mux.HandleFunc("/api/panel", requireSession(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
hGetPanel(w, r)
|
|
case http.MethodPost:
|
|
hSetPanel(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
mux.HandleFunc("/api/logs", requireSession(hLogs))
|
|
mux.HandleFunc("/api/restart", requireSession(hRestart))
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = io.WriteString(w, ui)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
})
|
|
|
|
fmt.Printf("sdm-panel listening on %s\n", panelCfg.Bind)
|
|
if err := http.ListenAndServe(panelCfg.Bind, mux); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|