1 Commits
Author SHA1 Message Date
ded_ikar a3a8e951c5 sdm-xray v0.1.0: stealth transport server installer + clients 2026-08-16 02:08:24 +05:00
6 changed files with 11 additions and 363 deletions
+1 -11
View File
@@ -21,17 +21,7 @@
bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) ваш-домен.ru
```
Скрипт: ставит бинарь + systemd, генерит UUID/ключ, получает Let's Encrypt, поднимает
туннель на 443 с зеркалом. В конце печатает готовый клиентский конфиг и пароль панели.
## Панель управления (`sdm-panel`)
Отдельный бинарь на VPS, слушает `127.0.0.1:8080` (наружу не торчит). Доступ через
SSH-туннель (трафик шифрован SSH):
```bash
ssh -L 8080:127.0.0.1:8080 root@<vps-ip>
# затем http://localhost:8080 (login: admin / пароль из вывода install.sh)
```
Возможности: статус, просмотр/правка config.json, ротация UUID+ключ, логи, рестарт xray.
Пароль хранится в `/etc/xray/panel.json` (root-only). Конфиг панели: bind/password.
туннель на 443 с зеркалом. В конце печатает готовый клиентский конфиг.
## Клиенты
- **Windows**: скачать `xray-stealth-client-windows-amd64.exe` из Releases, сохранить
+1 -39
View File
@@ -13,9 +13,8 @@ if [ -z "$DOMAIN" ]; then
fi
GITEA="https://git.my-handbook.ru/ded_ikar/sdm-xray"
VERSION="v0.2.0"
VERSION="v0.1.0"
BIN="xray-stealth-server-linux-amd64"
PANEL_BIN="sdm-panel-linux-amd64"
INSTALL_DIR="/usr/local/xray"
DATA_DIR="/etc/xray"
WEBROOT="/var/www/html"
@@ -144,42 +143,6 @@ echo
echo "=== INSTALLED OK ==="
echo "uuid: $UUID"
echo "key: $KEY"
# --- panel ---
echo
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)"
cat > "$DATA_DIR/panel.json" <<EOF
{
"bind": "127.0.0.1:8080",
"password": "$PANEL_PASS"
}
EOF
chmod 600 "$DATA_DIR/panel.json"
cat > /etc/systemd/system/sdm-panel.service <<'PANEL'
[Unit]
Description=sdm-xray control panel
After=network.target xray.service
[Service]
ExecStart=/usr/local/xray/sdm-panel
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
PANEL
systemctl daemon-reload
systemctl enable sdm-panel >/dev/null 2>&1 || true
systemctl restart sdm-panel
sleep 1
echo "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
echo "=== client config (client-stealth.json) ==="
cat <<CFG
@@ -208,4 +171,3 @@ echo
echo "=== client binaries ==="
echo " Windows: $GITEA/releases/download/$VERSION/xray-stealth-client-windows-amd64.exe"
echo " Router: $GITEA/releases/download/$VERSION/xray-stealth-client-linux-arm64"
echo " Panel: $GITEA/releases/download/$VERSION/sdm-panel-linux-amd64"
-8
View File
@@ -1,8 +0,0 @@
# sdm-panel
Простая веб-панель для управления stealth-туннелем на VPS.
- Читает/пишет `/etc/xray/config.json`, контролирует systemd `xray`.
- Слушает `127.0.0.1:8080`, вход: Basic auth (login `admin`, пароль в `/etc/xray/panel.json`).
- Доступ — только через SSH-туннель: `ssh -L 8080:127.0.0.1:8080 root@<vps>`.
Сборка: `GOOS=linux GOARCH=amd64 go build -o sdm-panel-linux-amd64 .`
-3
View File
@@ -1,3 +0,0 @@
module sdm-panel
go 1.26
-302
View File
@@ -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)
}
}
+9
View File
@@ -0,0 +1,9 @@
# stealth — кастомный транспорт
Изменения относительно upstream Xray-core:
- `transport/internet/stealth/` — новый транспорт (framing/conn/dialer/hub), наш код.
- `infra/conf/transport_internet.go`, `infra/conf/transport_method.go` — изменённые файлы
upstream (регистрация сети "stealth" + параметры). Остальной исходник — Xray-core,
см. https://github.com/XTLS/Xray-core.
MPL-2.0. Сборка: Go 1.26, `go build ./main`.