Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a9481d1a6 | ||
|
|
3e57c14022 | ||
|
|
689e051a58 | ||
|
|
9f6bff6704 | ||
|
|
bebd23d339 | ||
|
|
389d90bc83 | ||
|
|
d352912b9b |
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
# Архитектура sdm-xray
|
||||||
|
|
||||||
|
Документ для разработчиков. Для установки/использования — см. [README](README-sdm-xray.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Топология
|
||||||
|
|
||||||
|
```
|
||||||
|
клиент (ПК/роутер)
|
||||||
|
→ SOCKS5 127.0.0.1:10808
|
||||||
|
→ VLESS + stealth (свой фрейминг, HTTP/2, ротация путей)
|
||||||
|
→ TLS 1.3 (uTLS chrome, ALPN h2)
|
||||||
|
→ VPS:443
|
||||||
|
→ xray (сервер) → интернет
|
||||||
|
→ GET/HEAD от зондов → зеркало реального сайта
|
||||||
|
```
|
||||||
|
|
||||||
|
## Транспорт stealth
|
||||||
|
|
||||||
|
Кастомный транспорт Xray-core (поле `network: "stealth"` в streamSettings).
|
||||||
|
Каждая сессия = один HTTP/2 POST-стрим (bidirectional).
|
||||||
|
|
||||||
|
### Wire-протокол
|
||||||
|
```
|
||||||
|
[TLS 1.3] → [HTTP/2 POST на путь] → [тело: кадры]
|
||||||
|
```
|
||||||
|
|
||||||
|
Формат кадра: `[4B длина][1B тип][payload]`
|
||||||
|
|
||||||
|
| Тип | Значение | Смысл |
|
||||||
|
|-----|----------|-------|
|
||||||
|
| DATA | 0x01 | Данные туннеля (VLESS-поток) |
|
||||||
|
| PAD | 0x02 | Padding (читатель пропускает) |
|
||||||
|
| AUTH | 0xFE | Первый кадр тела запроса (ключ) |
|
||||||
|
| CLOSE | 0xFF | Закрытие |
|
||||||
|
|
||||||
|
### Поведение сервера
|
||||||
|
- POST на туннельный путь + AUTH → туннель (200 + двунаправленный обмен)
|
||||||
|
- POST без ключа/неверный ключ → 404 + закрытие
|
||||||
|
- GET/HEAD (не-туннельные) → reverse-proxy на `target` (зеркало)
|
||||||
|
|
||||||
|
### Зеркало
|
||||||
|
Сервер реверсивно проксирует GET/HEAD запросы на `target` (реальный сайт).
|
||||||
|
Переписывает Location, HTML-ссылки (href/src/content), CSS url() на свой домен.
|
||||||
|
Лимит тела: 8МБ. `Accept-Encoding: identity` (для переписывания).
|
||||||
|
|
||||||
|
## Файлы транспорта
|
||||||
|
|
||||||
|
`transport/internet/stealth/`:
|
||||||
|
- `config.proto` + `config.pb.go` — protobuf-конфигурация
|
||||||
|
- `config.go` — регистрация конфига в Xray
|
||||||
|
- `framing.go` — чтение/запись кадров
|
||||||
|
- `conn.go` — h2Conn (net.Conn поверх HTTP/2 стрима)
|
||||||
|
- `dialer.go` — клиент (TLS/uTLS, ротация, ретрай, decoy)
|
||||||
|
- `hub.go` — сервер (http.Server, зеркало, AUTH)
|
||||||
|
|
||||||
|
Изменённые файлы upstream:
|
||||||
|
- `infra/conf/transport_internet.go` — case "stealth", поле StealthSettings
|
||||||
|
- `infra/conf/transport_method.go` — struct StealthConfig + Build()
|
||||||
|
|
||||||
|
## Сборка (WSL)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/xray-mod
|
||||||
|
export PATH="$PATH:/usr/local/go/bin"
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o build/xray-server-amd64 ./main
|
||||||
|
GOOS=linux GOARCH=arm64 go build -o build/xray-client-arm64 ./main
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o build/xray-client-win.exe ./main
|
||||||
|
```
|
||||||
|
|
||||||
|
Regen protobuf:
|
||||||
|
```bash
|
||||||
|
protoc -I. --go_out=. --go_opt=paths=source_relative transport/internet/stealth/config.proto
|
||||||
|
```
|
||||||
|
|
||||||
|
Панель:
|
||||||
|
```bash
|
||||||
|
cd ~/sdm-panel
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o sdm-panel-linux-amd64 .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Деплой на VPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SFTP: build/xray-server-amd64 → /usr/local/xray/xray
|
||||||
|
# install -m 755, systemctl restart xray
|
||||||
|
```
|
||||||
|
|
||||||
|
Конфиги:
|
||||||
|
- `/etc/xray/config.json` — основной конфиг xray
|
||||||
|
- `/etc/xray/panel.json` — конфиг панели (bind/password/serverName)
|
||||||
|
- `/etc/xray/server.crt` + `/etc/xray/server.key` — self-signed сертификат (selfsigned-режим)
|
||||||
|
- `/etc/letsencrypt/live/<domain>/` — LE-сертификат (доменный режим)
|
||||||
|
|
||||||
|
## VPS (актуальные)
|
||||||
|
|
||||||
|
| VPS | Домен | IP | Система | xray |
|
||||||
|
|-----|-------|-----|---------|------|
|
||||||
|
| Основная | pay.dedikar.online | 13.143.132.66 | Ubuntu 24.04 | 978fd1a |
|
||||||
|
| Старая | vds.dedikar.online | 94.183.182.49 | Ubuntu 24.04 | 8f75aa4 |
|
||||||
|
|
||||||
|
## Интеграция в DedVpn.App
|
||||||
|
|
||||||
|
Копия: `C:\Users\dedikar\PsiphonWarp\DedVpn.App`
|
||||||
|
- `Services/Xray/XrayConfigBuilder.cs` — эмит `network: "stealth"` + `stealthSettings`
|
||||||
|
- `Services/Xray/SingBoxConfigBuilder.cs` — guard: stealth не поддерживается в sing-box
|
||||||
|
- vless://-ссылка из панели → импорт в приложение
|
||||||
|
- Runtime: патченый `xray.exe` в `runtime/xray/xray.exe`
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- HTTP/2 — один поток, throughput ограничен (~140 Мбит bulk, ~22 Мбит веб)
|
||||||
|
- Оптимизации (flush-батчинг, окна HTTP/2) провалились — откачены
|
||||||
|
- Стока REALITY быстрее (голый TLS), но stealth даёт уникальную маскировку
|
||||||
|
- Клиент и сервер должны быть одной версии форка
|
||||||
|
- На роутере (ARM Cortex-A53) — CPU для TLS является узким местом
|
||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Все заметные изменения проекта sdm-xray (анти-DPI туннель stealth).
|
||||||
|
|
||||||
|
Формат: [Keep a Changelog](https://keepachangelog.com/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- `install.sh --selfsigned` — режим без домена: самоподписанный сертификат (RSA 2048, 10 лет, CN=IP), клиенты доверяют по `pinnedPeerCertSha256`. Без nginx/certbot.
|
||||||
|
- `uninstall.sh` — полный снос всего, что ставит install.sh (systemd, бинари, конфиги, nginx-сайт, LE-сертификат).
|
||||||
|
- Панель v3: переключение TLS↔REALITY, переименование клиентов, лог-уровень xray, бэкап/восстановление config.json, версия бинаря + статус сертификата, статистика по клиентам (трафик + онлайн-IP, xray stats API).
|
||||||
|
- Панель автоматически добавляет `pinnedPeerCertSha256` в клиентские конфиги/ссылки при self-signed сервере.
|
||||||
|
- Логин + пароль для доступа к панели (по умолчанию `admin` / генерируется при установке).
|
||||||
|
- Тесты Go-панели (15 тестов: hexKey, uuid, tlsCertFile, selfSignedPin, statsInboundPort, ensureStats, stealthInbounds, genX25519, writeJSON, errStr, PanelConfig, email).
|
||||||
|
- CHANGELOG.md, ARCHITECTURE.md.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- Email клиента по умолчанию = UUID (вместо "client"). Раньше безымянные клиенты дублировали email "client" и их трафик сливался. Теперь каждый клиент уникален, статистика работает всегда.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [v0.2.1] — 2026-08-16
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- install.sh: SIGPIPE при чтении версии (заменено на `sed`), дублирующаяся `v` в баннере.
|
||||||
|
- Зеркало: drained-body баг — тело ответа не инжектируется в `ModifyResponse` при неизменённом состоянии (iana.org работает корректно).
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Панель управления sdm-panel v2 (Go, stdlib): статус, клиенты (UUID/key/paths), tunnel settings, логи, рестарт.
|
||||||
|
- install.sh интегрирует панель (systemd sdm-panel, автоматический пароль).
|
||||||
|
- PROTOCOL.md — спецификация wire-протокола для интеграторов.
|
||||||
|
- stealth.patch — полный патч транспорта (1372 строки, для референса).
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
- Ассеты релиза: сервер/клиенты (win/arm64)/geo/sdm-panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [v0.2.0] — 2026-08-15
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Панель управления sdm-panel (Go, stdlib): HTTP на 127.0.0.1:8080, авторизация по паролю, API для просмотра/редактирования config.json.
|
||||||
|
- `install.sh` — скрипт установки на свежую Ubuntu VPS (домен + LE + nginx + xray + панель).
|
||||||
|
- Клиентские конфиги (client-stealth.json, client-vds.json, client-pay.json).
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
- Транспорт stealth v10: адаптивный ретрай со сменой identity (свежий path/port при неудаче).
|
||||||
|
- Зеркало: редирект-фоллоу, HTML/CSS-переписывание ссылок, browser-like padding.
|
||||||
|
- REALITY: поддержка `security: "reality"` с apple.com в качестве таргета.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [v0.1.0] — 2026-08-14
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Кастомный транспорт `stealth` для Xray-core (поле `network: "stealth"` в streamSettings).
|
||||||
|
- Wire-протокол: фрейминг `[len][type][payload]`, AUTH ключом, DATA/PAD/CLOSE кадры.
|
||||||
|
- Клиент: ротация путей (`paths`), ротация портов (`ports`), padding/pacing, rate-limiter (`bps`).
|
||||||
|
- Сервер: зеркало реального сайта (`target`) для GET/HEAD запросов от зондов.
|
||||||
|
- Сборка: Go 1.26, protoc, protoc-gen-go. Бинари для linux/amd64, linux/arm64, windows/amd64.
|
||||||
|
|
||||||
|
### Транспорт (хронология разработки)
|
||||||
|
| Версия | Описание |
|
||||||
|
|--------|----------|
|
||||||
|
| v1 | Фрейминг + auth + padding + pacing поверх raw TLS |
|
||||||
|
| v2 | HTTP/2 транспорт (h2 POST-стримы, AUTH в теле) |
|
||||||
|
| v3 | Анти-пробиг (заглушка для зондов), jitter, path |
|
||||||
|
| v4 | Ротация identity (несколько путей, per-connection random) |
|
||||||
|
| v5 | Ротация портов, rate-limiter bps |
|
||||||
|
| v6 | Зеркало реального сайта (reverse-proxy для не-туннельных запросов) |
|
||||||
|
| v7 | Редирект-фоллоу в зеркале, опциональный decoy-трафик |
|
||||||
|
| v8 | HTML-переписывание ссылок в зеркале (href/src → наш домен) |
|
||||||
|
| v9 | CSS url()-переписывание, browser-like padding profile |
|
||||||
|
| v10 | Адаптивный ретрай со сменой identity при неудаче |
|
||||||
Executable
+199
@@ -0,0 +1,199 @@
|
|||||||
|
# sdm-xray: stealth transport — спецификация для интеграции
|
||||||
|
|
||||||
|
Версия: v0.2.2. Статус: работает (клиент/сервер — форк Xray-core).
|
||||||
|
|
||||||
|
## 1. Что это
|
||||||
|
|
||||||
|
`stealth` — кастомный сетевой транспорт для Xray-core (поле `network: "stealth"` в
|
||||||
|
`streamSettings`). Каждая туннельная сессия = **один долгоживущий HTTP/2 POST-стрим**
|
||||||
|
(ALPN `h2`): тело запроса несёт кадры клиент→сервер, тело ответа — кадры сервер→клиент
|
||||||
|
(двунаправленно). Снаружи выглядит как обычный POST-запрос к API реального сайта.
|
||||||
|
|
||||||
|
Для зондов/DPI: сервер отдаёт реальный сайт (зеркало) на GET/HEAD запросы.
|
||||||
|
Клиент автоматически ротирует пути, padding/pacing маскирует размеры и тайминги.
|
||||||
|
|
||||||
|
## 2. Quick-start
|
||||||
|
|
||||||
|
### Сервер (минимальный конфиг)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log": {"loglevel": "warning"},
|
||||||
|
"inbounds": [{
|
||||||
|
"listen": "0.0.0.0", "port": 443,
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {"clients": [{"id": "<UUID>", "level": 0}], "decryption": "none"},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "stealth",
|
||||||
|
"stealthSettings": {
|
||||||
|
"key": "< hex-ключ >", "padMax": 2048, "padProb": 50,
|
||||||
|
"paceMs": 0, "paceJitterMs": 0,
|
||||||
|
"paths": ["/api/t04d12b46/", "/api/v2/telemetry/", "/collect/", "/ingest/"],
|
||||||
|
"ports": [443], "bps": 0,
|
||||||
|
"target": "https://www.iana.org",
|
||||||
|
"padProfile": "browser", "retries": 3
|
||||||
|
},
|
||||||
|
"security": "tls",
|
||||||
|
"tlsSettings": {"certificates": [{"certificateFile": "fullchain.pem", "keyFile": "privkey.pem"}]}
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"outbounds": [{"protocol": "freedom", "tag": "direct"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Клиент (минимальный конфиг)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log": {"loglevel": "warning"},
|
||||||
|
"inbounds": [
|
||||||
|
{"listen": "127.0.0.1", "port": 10808, "protocol": "socks"},
|
||||||
|
{"listen": "127.0.0.1", "port": 10809, "protocol": "http"}
|
||||||
|
],
|
||||||
|
"outbounds": [{
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {"vnext": [{"address": "<домен или IP>", "port": 443,
|
||||||
|
"users": [{"id": "<UUID>", "encryption": "none"}]}]},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "stealth",
|
||||||
|
"stealthSettings": {
|
||||||
|
"key": "< hex-ключ >", "padMax": 2048, "padProb": 50,
|
||||||
|
"paceMs": 0, "paceJitterMs": 0,
|
||||||
|
"paths": ["/api/t04d12b46/", "/api/v2/telemetry/", "/collect/", "/ingest/"],
|
||||||
|
"ports": [443], "bps": 0
|
||||||
|
},
|
||||||
|
"security": "tls",
|
||||||
|
"tlsSettings": {"serverName": "<домен>", "fingerprint": "chrome"}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Конфигурация (streamSettings)
|
||||||
|
|
||||||
|
| Параметр | Тип | По умолчанию | Описание |
|
||||||
|
|----------|-----|-------------|----------|
|
||||||
|
| `key` | string | — | Общий секрет (hex). Клиент и сервер должны совпадать |
|
||||||
|
| `paths` | []string | `["/api/t04d12b46/"]` | Туннельные пути (ротация на каждое соединение) |
|
||||||
|
| `ports` | []uint32 | `[443]` | Порты (ротация, опция) |
|
||||||
|
| `padMax` | int64 | 2048 | Максимальный размер PAD-кадра |
|
||||||
|
| `padProb` | int64 | 50 | Вероятность PAD после DATA (%) |
|
||||||
|
| `paceMs` | int64 | 0 | Минимальный интервал между кадрами (мс) |
|
||||||
|
| `paceJitterMs` | int64 | 0 | Случайный довесок к paceMs (мс) |
|
||||||
|
| `bps` | int64 | 0 | Rate-limit (0 = выкл) |
|
||||||
|
| `target` | string | — | URL зеркала (сервер). Пустой = встроенная заглушка |
|
||||||
|
| `retries` | int64 | 3 | Количество ретраев при неудаче |
|
||||||
|
| `padProfile` | string | "browser" | "browser" (перекос к мелким) или "uniform" |
|
||||||
|
| `decoy` | bool | false | Фоновый трафик к CDN (клиент) |
|
||||||
|
| `decoyEverySecs` | int64 | 60 | Интервал decoy-запросов |
|
||||||
|
| `decoyTarget` | string | cloudflare CDN | URL для decoy |
|
||||||
|
| `path` | string | — | Один путь (fallback, если paths пуст) |
|
||||||
|
|
||||||
|
## 4. Wire-протокол (поверх TLS+HTTP/2)
|
||||||
|
|
||||||
|
### 4.1 Установка сессии
|
||||||
|
1. Клиент: TLS 1.3 (uTLS, fingerprint chrome, ALPN h2) к серверу:443.
|
||||||
|
2. HTTP/2: клиент открывает **один POST** на один из `paths`.
|
||||||
|
3. Тело запроса (клиент→сервер) — поток кадров. **Первый кадр = AUTH**:
|
||||||
|
`[len][0xFE][key]`.
|
||||||
|
4. Сервер читает AUTH:
|
||||||
|
- ключ верный → отвечает `200` (заголовки + flush), дальше тело ответа — поток кадров сервер→клиент;
|
||||||
|
- ключ неверный/нет → `404` + закрытие.
|
||||||
|
5. После 200 обе стороны обмениваются кадрами DATA/PAD двунаправленно.
|
||||||
|
|
||||||
|
### 4.2 Формат кадра
|
||||||
|
```
|
||||||
|
[4 байта, big-endian длина = 1 + len(payload)] [1 байт type] [payload]
|
||||||
|
```
|
||||||
|
|
||||||
|
| type | Значение | Смысл |
|
||||||
|
|------|----------|-------|
|
||||||
|
| DATA | 0x01 | Данные туннеля (VLESS-поток) |
|
||||||
|
| PAD | 0x02 | Padding (читатель пропускает) |
|
||||||
|
| AUTH | 0xFE | Первый кадр тела запроса (ключ) |
|
||||||
|
| CLOSE | 0xFF | Закрытие |
|
||||||
|
|
||||||
|
Лимит кадра: 64 КБ. PAD-кадры прозрачны для читателя (пропускаются).
|
||||||
|
|
||||||
|
### 4.3 Сервер: зеркало для зондов
|
||||||
|
Не-туннельные запросы (GET/HEAD) сервер реверсивно проксирует на `target`:
|
||||||
|
- Location переписывается на свой домен
|
||||||
|
- HTML href/src/content переписываются на свой домен
|
||||||
|
- CSS url() переписываются на свой домен
|
||||||
|
- POST на туннельные пути без AUTH → 404
|
||||||
|
- Пустой `target` → встроенная страница-заглушка
|
||||||
|
|
||||||
|
## 5. Безопасность
|
||||||
|
|
||||||
|
### TLS (по умолчанию)
|
||||||
|
Сертификат: Let's Encrypt (домен) или self-signed (IP).
|
||||||
|
|
||||||
|
### Self-signed режим
|
||||||
|
Для работы без домена: `install.sh --selfsigned` генерирует сертификат (RSA 2048, 10 лет).
|
||||||
|
Клиент доверяет по `pinnedPeerCertSha256` — hex SHA256 от DER-кода сертификата.
|
||||||
|
|
||||||
|
Клиентский конфиг:
|
||||||
|
```json
|
||||||
|
"tlsSettings": {
|
||||||
|
"serverName": "<IP VPS>",
|
||||||
|
"fingerprint": "chrome",
|
||||||
|
"pinnedPeerCertSha256": "<hex sha256 сертификата>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Важно: `pinnedPeerCertSha256` — **hex**, не base64. Поддерживает OpenSSL-колонки.
|
||||||
|
|
||||||
|
### REALITY
|
||||||
|
Опционально: `security: "reality"` вместо `security: "tls"`.
|
||||||
|
Сертификат не нужен — клиент доверяет по `publicKey` (X25519).
|
||||||
|
Таргет: `www.apple.com` (microsoft.com режет подключения).
|
||||||
|
|
||||||
|
## 6. Статистика
|
||||||
|
|
||||||
|
xray считает трафик по **email** пользователя. Если email пустой — счётчики не создаются.
|
||||||
|
По умолчанию панель присваивает email = UUID (уникальный для каждого клиента).
|
||||||
|
|
||||||
|
Для включения статистики на сервере (добавляется автоматически при первом запросе):
|
||||||
|
```json
|
||||||
|
"api": {"tag": "api", "listen": "127.0.0.1:10085", "services": ["StatsService"]},
|
||||||
|
"stats": {},
|
||||||
|
"policy": {"levels": {"0": {"statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Файлы транспорта
|
||||||
|
|
||||||
|
**Новые** (пакет транспорта):
|
||||||
|
- `transport/internet/stealth/config.proto` (+ `config.pb.go`)
|
||||||
|
- `transport/internet/stealth/config.go` — регистрация
|
||||||
|
- `transport/internet/stealth/framing.go` — кадры
|
||||||
|
- `transport/internet/stealth/conn.go` — net.Conn поверх HTTP/2 стрима
|
||||||
|
- `transport/internet/stealth/dialer.go` — клиент
|
||||||
|
- `transport/internet/stealth/hub.go` — сервер
|
||||||
|
|
||||||
|
**Изменённые** (в `infra/conf`):
|
||||||
|
- `infra/conf/transport_internet.go` — `case "stealth"`, REALITY-проверка
|
||||||
|
- `infra/conf/transport_method.go` — struct `StealthConfig` + `Build()`
|
||||||
|
|
||||||
|
## 8. Требования к сборке
|
||||||
|
|
||||||
|
- Go 1.26
|
||||||
|
- golang.org/x/net/http2
|
||||||
|
- golang.org/x/time/rate
|
||||||
|
- refraction-networking/utls
|
||||||
|
- protoc + protoc-gen-go (для config.proto)
|
||||||
|
|
||||||
|
Регенерация protobuf:
|
||||||
|
```bash
|
||||||
|
protoc -I. --go_out=. --go_opt=paths=source_relative transport/internet/stealth/config.proto
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Известные ограничения
|
||||||
|
|
||||||
|
- HTTP/2 — один поток, throughput ~140 Мбит (bulk), ~22 Мбит (веб)
|
||||||
|
- Клиент и сервер должны быть одной версии форка
|
||||||
|
- `pinnedPeerCertSha256` — нестандартный параметр (поддерживается только нашим форком)
|
||||||
|
- REALITY: таргет microsoft.com режет — использовать apple.com
|
||||||
|
|
||||||
|
## 10. Ссылки
|
||||||
|
|
||||||
|
- Репозиторий: https://git.my-handbook.ru/ded_ikar/sdm-xray
|
||||||
|
- Релизы: https://git.my-handbook.ru/ded_ikar/sdm-xray/releases
|
||||||
|
- Лицензия: MPL-2.0
|
||||||
@@ -1,50 +1,129 @@
|
|||||||
# sdm-xray
|
# sdm-xray
|
||||||
|
|
||||||
Собственный анти-DPI туннель: форк [Xray-core](https://github.com/XTLS/Xray-core) с кастомным
|
Собственный анти-DPI туннель: форк [Xray-core](https://github.com/XTLS/Xray-core) с кастомным
|
||||||
транспортом **`stealth`**. Клиент → своя VPS, трафик максимально похож на браузерный HTTPS.
|
транспортом **`stealth`**. Клиент → своя VPS, трафик выглядит как обычный HTTPS-запрос
|
||||||
|
к сайту.
|
||||||
|
|
||||||
## Принцип
|
## Как это работает
|
||||||
«Адаптивность, а не протокол»: транспорт меняет поведение (identity) быстрее, чем DPI успевает
|
|
||||||
его описывать. Всё в одном процессе, одним JSON-конфигом, без панелей и внешних сервисов.
|
|
||||||
|
|
||||||
Что умеет:
|
Каждая туннельная сессия — один HTTP/2 POST-стрим поверх TLS 1.3. Снаружи выглядит как
|
||||||
- Свой транспорт поверх HTTP/2 (TLS 1.3 + uTLS chrome), мультиплексирование на одном канале
|
обычный POST-запрос к API реального сайта. Для зондов/DPI — зеркало реального сайта
|
||||||
- Ротация туннельных путей на каждое соединение + ретрай со свежей identity при сбое
|
(по умолчанию iana.org): многостраничный сайт, ссылки переписаны на ваш домен.
|
||||||
- Анти-пробиг: сервер зеркалит реальный сайт (по умолчанию wikipedia) — зонд видит
|
|
||||||
настоящий многостраничный сайт на вашем домене (редиректы, HTML/CSS-ссылки переписаны)
|
|
||||||
- Padding/pacing под браузер, опции: ротация портов, rate-limit, decoy, REALITY
|
|
||||||
- По умолчанию только порт 443 (как браузер)
|
|
||||||
|
|
||||||
## Установка сервера (свежая Ubuntu VPS)
|
Ключевые возможности:
|
||||||
Домен должен указывать A-записью на IP VPS, порт 80 открыт.
|
- **Ротация путей** — каждый запрос идёт по случайному пути из списка
|
||||||
|
- **Ретрай** — при неудаче клиент автоматически пробует другой путь/порт
|
||||||
|
- **Padding/pacing** — маскирует размеры и тайминги под браузер
|
||||||
|
- **Зеркало** — зонд видит настоящий сайт, а не заглушку
|
||||||
|
- **REALITY** — опционально, для полного сокрытия (чужой домен)
|
||||||
|
|
||||||
|
## Установка на VPS
|
||||||
|
|
||||||
|
### С доменом (рекомендуется)
|
||||||
|
Домен должен указывать A-записью на IP VPS, порт 80 открыт:
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) ваш-домен.ru
|
bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) ваш-домен.ru
|
||||||
```
|
```
|
||||||
Скрипт: ставит бинарь + systemd, генерит UUID/ключ, получает Let's Encrypt, поднимает
|
Скрипт установит xray + панель, получит сертификат Let's Encrypt, напечатает
|
||||||
туннель на 443 с зеркалом. В конце печатает готовый клиентский конфиг.
|
клиентский конфиг и пароль панели.
|
||||||
|
|
||||||
|
### Без домена (self-signed)
|
||||||
|
Домен не нужен — сервер генерирует самоподписанный сертификат (10 лет), клиенты
|
||||||
|
доверяют по отпечатку:
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) --selfsigned
|
||||||
|
```
|
||||||
|
Адрес сервера в клиенте — IP VPS. Сертификат не требует домена/ACME.
|
||||||
|
|
||||||
|
### Удаление
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/uninstall.sh)
|
||||||
|
```
|
||||||
|
Полностью удаляет systemd-юниты, бинари, конфиги, nginx-сайт, LE-сертификат.
|
||||||
|
apt-пакеты не трогает.
|
||||||
|
|
||||||
|
## Панель управления
|
||||||
|
|
||||||
|
Go-бинарник на VPS, слушает `127.0.0.1:8080`. Доступ через SSH-туннель:
|
||||||
|
```bash
|
||||||
|
ssh -L 8080:127.0.0.1:8080 root@<vps-ip>
|
||||||
|
# затем http://localhost:8080 (login: admin / пароль из вывода install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Возможности:
|
||||||
|
- Статус сервера (активность, версия, сертификат)
|
||||||
|
- Управление клиентами (добавление/удаление/переименование, ротация UUID)
|
||||||
|
- Туннель (зеркало, маскировка, экспертные настройки, TLS/REALITY)
|
||||||
|
- Статистика (трафик по клиентам, онлайн-IP)
|
||||||
|
- Логи, рестарт, бэкап/восстановление конфига
|
||||||
|
- Логин + пароль для входа (по умолчанию admin / генерируется при установке)
|
||||||
|
|
||||||
## Клиенты
|
## Клиенты
|
||||||
- **Windows**: скачать `xray-stealth-client-windows-amd64.exe` из Releases, сохранить
|
|
||||||
`client-stealth.json` (из вывода install.sh), запустить `xray-stealth-client-windows-amd64.exe run -c client-stealth.json`,
|
|
||||||
прокси SOCKS на `127.0.0.1:10808`.
|
|
||||||
- **Роутер**: `xray-stealth-client-linux-arm64` — положить в `/tmp`, тот же конфиг,
|
|
||||||
SOCKS 127.0.0.1:10808 или TUN + policy routing.
|
|
||||||
|
|
||||||
## Параметры `stealthSettings`
|
### Windows
|
||||||
`key`, `padMax`, `padProb`, `paceMs`, `paceJitterMs`, `path`/`paths`, `ports` (опция),
|
Скачать `xray-stealth-client-windows-amd64.exe` из [Releases](https://git.my-handbook.ru/ded_ikar/sdm-xray/releases),
|
||||||
`bps` (опция), `target` (зеркало), `decoy`/`decoyEverySecs`/`decoyTarget` (опция),
|
сохранить `client-stealth.json` (из вывода install.sh), запустить:
|
||||||
`padProfile` ("browser"|"uniform"), `retries`.
|
```
|
||||||
|
xray-stealth-client-windows-amd64.exe run -c client-stealth.json
|
||||||
|
```
|
||||||
|
SOCKS-прокси: `127.0.0.1:10808`, HTTP-прокси: `127.0.0.1:10809`.
|
||||||
|
|
||||||
|
### Роутер (ARM64)
|
||||||
|
`xray-stealth-client-linux-arm64` — положить в `/tmp`, тот же конфиг.
|
||||||
|
SOCKS 127.0.0.1:10808 или TUN + policy routing.
|
||||||
|
|
||||||
|
## Параметры транспорта
|
||||||
|
|
||||||
|
| Параметр | По умолчанию | Описание |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `key` | — | Общий секрет (клиент и сервер) |
|
||||||
|
| `paths` | `["/api/t04d12b46/"]` | Туннельные пути (ротация) |
|
||||||
|
| `ports` | `[443]` | Порты (ротация, опция) |
|
||||||
|
| `padMax` | `2048` | Максимальный размер PAD-кадра |
|
||||||
|
| `padProb` | `50` | Вероятность PAD после DATA (%) |
|
||||||
|
| `paceMs` | `0` | Минимальный интервал между кадрами (мс) |
|
||||||
|
| `paceJitterMs` | `0` | Случайный довесок к paceMs (мс) |
|
||||||
|
| `bps` | `0` | Rate-limit (0 = выкл) |
|
||||||
|
| `target` | — | Зеркало (URL сайта для зондов) |
|
||||||
|
| `retries` | `3` | Количество ретраев |
|
||||||
|
| `padProfile` | `"browser"` | `"browser"` или `"uniform"` |
|
||||||
|
| `decoy` | `false` | Фоновый трафик к CDN |
|
||||||
|
|
||||||
## Сборка из исходников
|
## Сборка из исходников
|
||||||
Нужны Go 1.26, protoc, protoc-gen-go.
|
|
||||||
|
Нужны Go 1.26, protoc, protoc-gen-go:
|
||||||
```bash
|
```bash
|
||||||
GOOS=linux GOARCH=amd64 go build -o xray-server-amd64 ./main # сервер
|
GOOS=linux GOARCH=amd64 go build -o xray-server-amd64 ./main # сервер
|
||||||
GOOS=linux GOARCH=arm64 go build -o xray-client-arm64 ./main # роутер
|
GOOS=linux GOARCH=arm64 go build -o xray-client-arm64 ./main # роутер
|
||||||
GOOS=windows GOARCH=amd64 go build -o xray-client-win.exe ./main # Windows
|
GOOS=windows GOARCH=amd64 go build -o xray-client-win.exe ./main # Windows
|
||||||
```
|
```
|
||||||
Транспорт: `stealth/transport/internet/stealth/`. Изменённые файлы upstream:
|
|
||||||
`stealth/infra/conf/transport_internet.go`, `stealth/infra/conf/transport_method.go`.
|
|
||||||
|
|
||||||
## Лицензия и атрибуция
|
Панель:
|
||||||
MPL-2.0. Форк Xray-core (XTLS) — https://github.com/XTLS/Xray-core. Новые файлы транспорта —
|
```bash
|
||||||
наш код; изменённые файлы upstream распространяются под MPL-2.0 (исходники приложены в `stealth/`).
|
cd panel && go build -o sdm-panel-linux-amd64 .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
```
|
||||||
|
├── stealth/transport/internet/stealth/ # Код транспорта (новые файлы)
|
||||||
|
│ ├── config.proto + config.pb.go # Конфигурация (protobuf)
|
||||||
|
│ ├── framing.go # Формат кадров
|
||||||
|
│ ├── conn.go # net.Conn поверх HTTP/2 стрима
|
||||||
|
│ ├── dialer.go # Клиент (TLS, ротация, ретрай)
|
||||||
|
│ └── hub.go # Сервер (зеркало, AUTH)
|
||||||
|
├── panel/ # Панель управления (Go)
|
||||||
|
│ ├── main.go # HTTP-сервер + API
|
||||||
|
│ ├── ui.go # Встроенный HTML
|
||||||
|
│ └── stats/ # Генерированные gRPC-стабы
|
||||||
|
├── install.sh # Установка на VPS
|
||||||
|
├── uninstall.sh # Удаление
|
||||||
|
├── PROTOCOL.md # Спецификация wire-протокола
|
||||||
|
├── CHANGELOG.md # История изменений
|
||||||
|
└── LICENSE # MPL-2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MPL-2.0. Новые файлы транспорта — наш код; изменённые файлы upstream (Xray-core)
|
||||||
|
распространяются под MPL-2.0.
|
||||||
|
|||||||
+122
-27
@@ -2,26 +2,36 @@
|
|||||||
# sdm-xray — установка сервера stealth-туннеля на свежую Ubuntu VPS.
|
# sdm-xray — установка сервера stealth-туннеля на свежую Ubuntu VPS.
|
||||||
# Использование:
|
# Использование:
|
||||||
# bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) <domain>
|
# bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) <domain>
|
||||||
# где <domain> — ваш домен, A-запись которого указывает на IP этой VPS.
|
# — с доменом: Let's Encrypt-сертификат (A-запись домена должна указывать на IP VPS)
|
||||||
|
# bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) --selfsigned
|
||||||
|
# — БЕЗ домена: self-signed сертификат, клиент доверяет по pinnedPeerCertSha256
|
||||||
|
# (pin печатается в клиентском конфиге; панель сама подставляет его в ссылки/конфиги)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DOMAIN="${1:-}"
|
SELFSIGNED=0
|
||||||
if [ -z "$DOMAIN" ]; then
|
if [ -z "${1:-}" ] || [ "$1" = "--selfsigned" ]; then
|
||||||
echo "Usage: install.sh <domain>"
|
SELFSIGNED=1
|
||||||
echo " domain — домен, указывающий на IP этой VPS (нужен для Let's Encrypt)"
|
DOMAIN=""
|
||||||
exit 1
|
else
|
||||||
|
DOMAIN="$1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
GITEA="https://git.my-handbook.ru/ded_ikar/sdm-xray"
|
GITEA="https://git.my-handbook.ru/ded_ikar/sdm-xray"
|
||||||
VERSION="v0.1.0"
|
VERSION="v0.2.2"
|
||||||
BIN="xray-stealth-server-linux-amd64"
|
BIN="xray-stealth-server-linux-amd64"
|
||||||
|
PANEL_BIN="sdm-panel-linux-amd64"
|
||||||
INSTALL_DIR="/usr/local/xray"
|
INSTALL_DIR="/usr/local/xray"
|
||||||
DATA_DIR="/etc/xray"
|
DATA_DIR="/etc/xray"
|
||||||
WEBROOT="/var/www/html"
|
WEBROOT="/var/www/html"
|
||||||
VPS_IP="$(curl -fsSL --max-time 10 https://api.ipify.org 2>/dev/null || echo UNKNOWN)"
|
VPS_IP="$(curl -fsSL --max-time 10 https://api.ipify.org 2>/dev/null || echo UNKNOWN)"
|
||||||
|
|
||||||
echo "=== sdm-xray installer v${VERSION} ==="
|
if [ "$SELFSIGNED" -eq 1 ]; then
|
||||||
echo "domain: $DOMAIN vps-ip: $VPS_IP"
|
echo "=== sdm-xray installer ${VERSION} (SELF-SIGNED, без домена) ==="
|
||||||
|
echo "vps-ip: $VPS_IP (используй его как serverName/адрес в клиенте)"
|
||||||
|
else
|
||||||
|
echo "=== sdm-xray installer ${VERSION} ==="
|
||||||
|
echo "domain: $DOMAIN vps-ip: $VPS_IP"
|
||||||
|
fi
|
||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -35,7 +45,7 @@ curl -fsSL -o "$INSTALL_DIR/xray" "$GITEA/releases/download/$VERSION/$BIN"
|
|||||||
curl -fsSL -o "$INSTALL_DIR/geoip.dat" "$GITEA/releases/download/$VERSION/geoip.dat"
|
curl -fsSL -o "$INSTALL_DIR/geoip.dat" "$GITEA/releases/download/$VERSION/geoip.dat"
|
||||||
curl -fsSL -o "$INSTALL_DIR/geosite.dat" "$GITEA/releases/download/$VERSION/geosite.dat"
|
curl -fsSL -o "$INSTALL_DIR/geosite.dat" "$GITEA/releases/download/$VERSION/geosite.dat"
|
||||||
chmod 755 "$INSTALL_DIR/xray"
|
chmod 755 "$INSTALL_DIR/xray"
|
||||||
"$INSTALL_DIR/xray" version | head -1
|
"$INSTALL_DIR/xray" version | sed -n '1p'
|
||||||
|
|
||||||
echo "=== generate secrets ==="
|
echo "=== generate secrets ==="
|
||||||
UUID="$(cat /proc/sys/kernel/random/uuid)"
|
UUID="$(cat /proc/sys/kernel/random/uuid)"
|
||||||
@@ -43,9 +53,21 @@ KEY="$(openssl rand -hex 8)"
|
|||||||
echo "$UUID" > "$DATA_DIR/uuid"
|
echo "$UUID" > "$DATA_DIR/uuid"
|
||||||
echo "$KEY" > "$DATA_DIR/key"
|
echo "$KEY" > "$DATA_DIR/key"
|
||||||
|
|
||||||
echo "=== nginx on 80 (acme) ==="
|
# --- TLS: либо LE-серт (домен), либо self-signed (без домена) ---
|
||||||
mkdir -p "$WEBROOT/.well-known/acme-challenge"
|
if [ "$SELFSIGNED" -eq 1 ]; then
|
||||||
cat > /etc/nginx/sites-available/sdm <<'NGX'
|
echo "=== self-signed certificate (CN=$VPS_IP, 10 лет) ==="
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||||
|
-subj "/CN=$VPS_IP" \
|
||||||
|
-keyout "$DATA_DIR/server.key" -out "$DATA_DIR/server.crt" >/dev/null 2>&1
|
||||||
|
CERT_FILE="$DATA_DIR/server.crt"
|
||||||
|
KEY_FILE="$DATA_DIR/server.key"
|
||||||
|
PIN="$(openssl x509 -in "$CERT_FILE" -outform der | openssl dgst -sha256 -hex | awk '{print $NF}')"
|
||||||
|
echo "pinnedPeerCertSha256: $PIN"
|
||||||
|
echo " (панель подставит его в клиентские конфиги/ссылки автоматически)"
|
||||||
|
else
|
||||||
|
echo "=== nginx on 80 (acme) ==="
|
||||||
|
mkdir -p "$WEBROOT/.well-known/acme-challenge"
|
||||||
|
cat > /etc/nginx/sites-available/sdm <<'NGX'
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
@@ -60,19 +82,22 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
NGX
|
NGX
|
||||||
sed -i "s/DOMAIN/$DOMAIN/" /etc/nginx/sites-available/sdm
|
sed -i "s/DOMAIN/$DOMAIN/" /etc/nginx/sites-available/sdm
|
||||||
ln -sf /etc/nginx/sites-available/sdm /etc/nginx/sites-enabled/sdm
|
ln -sf /etc/nginx/sites-available/sdm /etc/nginx/sites-enabled/sdm
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx
|
nginx -t >/dev/null 2>&1 && systemctl reload nginx
|
||||||
|
|
||||||
echo "=== Let's Encrypt cert ==="
|
echo "=== Let's Encrypt cert ==="
|
||||||
certbot certonly --webroot -w "$WEBROOT" -d "$DOMAIN" \
|
certbot certonly --webroot -w "$WEBROOT" -d "$DOMAIN" \
|
||||||
--register-unsafely-without-email --agree-tos --no-eff-email -n \
|
--register-unsafely-without-email --agree-tos --no-eff-email -n \
|
||||||
--cert-name "$DOMAIN" >/dev/null 2>&1 || true
|
--cert-name "$DOMAIN" >/dev/null 2>&1 || true
|
||||||
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
|
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
|
||||||
if [ ! -f "$CERT_DIR/fullchain.pem" ]; then
|
if [ ! -f "$CERT_DIR/fullchain.pem" ]; then
|
||||||
echo "ERROR: сертификат не получен. Проверьте, что $DOMAIN -> $VPS_IP и порт 80 открыт."
|
echo "ERROR: сертификат не получен. Проверьте, что $DOMAIN -> $VPS_IP и порт 80 открыт."
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
CERT_FILE="$CERT_DIR/fullchain.pem"
|
||||||
|
KEY_FILE="$CERT_DIR/privkey.pem"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== xray config ==="
|
echo "=== xray config ==="
|
||||||
@@ -96,13 +121,13 @@ cat > "$DATA_DIR/config.json" <<EOF
|
|||||||
"stealthSettings": {
|
"stealthSettings": {
|
||||||
"key": "$KEY", "padMax": 2048, "padProb": 50, "paceMs": 0, "paceJitterMs": 0,
|
"key": "$KEY", "padMax": 2048, "padProb": 50, "paceMs": 0, "paceJitterMs": 0,
|
||||||
"paths": ["/api/t04d12b46/", "/api/v2/telemetry/", "/collect/", "/ingest/"],
|
"paths": ["/api/t04d12b46/", "/api/v2/telemetry/", "/collect/", "/ingest/"],
|
||||||
"ports": [443], "bps": 0, "target": "https://en.wikipedia.org",
|
"ports": [443], "bps": 0, "target": "https://www.iana.org",
|
||||||
"padProfile": "browser", "retries": 3
|
"padProfile": "browser", "retries": 3
|
||||||
},
|
},
|
||||||
"security": "tls",
|
"security": "tls",
|
||||||
"tlsSettings": {
|
"tlsSettings": {
|
||||||
"certificates": [
|
"certificates": [
|
||||||
{"certificateFile": "$CERT_DIR/fullchain.pem", "keyFile": "$CERT_DIR/privkey.pem"}
|
{"certificateFile": "$CERT_FILE", "keyFile": "$KEY_FILE"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -143,8 +168,76 @@ echo
|
|||||||
echo "=== INSTALLED OK ==="
|
echo "=== INSTALLED OK ==="
|
||||||
echo "uuid: $UUID"
|
echo "uuid: $UUID"
|
||||||
echo "key: $KEY"
|
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)"
|
||||||
|
PANEL_LOGIN="admin"
|
||||||
|
if [ "$SELFSIGNED" -eq 1 ]; then
|
||||||
|
PANEL_SERVER="$VPS_IP"
|
||||||
|
else
|
||||||
|
PANEL_SERVER="$DOMAIN"
|
||||||
|
fi
|
||||||
|
cat > "$DATA_DIR/panel.json" <<EOF
|
||||||
|
{
|
||||||
|
"bind": "127.0.0.1:8080",
|
||||||
|
"login": "$PANEL_LOGIN",
|
||||||
|
"password": "$PANEL_PASS",
|
||||||
|
"serverName": "$PANEL_SERVER"
|
||||||
|
}
|
||||||
|
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 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: $PANEL_LOGIN / пароль выше)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== client config (client-stealth.json) ==="
|
echo "=== client config (client-stealth.json) ==="
|
||||||
|
if [ "$SELFSIGNED" -eq 1 ]; then
|
||||||
|
cat <<CFG
|
||||||
|
{
|
||||||
|
"log": {"loglevel": "warning"},
|
||||||
|
"inbounds": [
|
||||||
|
{"listen": "127.0.0.1", "port": 10808, "protocol": "socks"},
|
||||||
|
{"listen": "127.0.0.1", "port": 10809, "protocol": "http"}
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {"vnext": [{"address": "$VPS_IP", "port": 443, "users": [{"id": "$UUID", "encryption": "none"}]}]},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "stealth",
|
||||||
|
"stealthSettings": {"key": "$KEY", "padMax": 2048, "padProb": 50, "paceMs": 0, "paceJitterMs": 0, "paths": ["/api/t04d12b46/", "/api/v2/telemetry/", "/collect/", "/ingest/"], "ports": [443], "bps": 0},
|
||||||
|
"security": "tls",
|
||||||
|
"tlsSettings": {"serverName": "$VPS_IP", "fingerprint": "chrome", "pinnedPeerCertSha256": "$PIN"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"protocol": "freedom", "tag": "direct"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
CFG
|
||||||
|
else
|
||||||
cat <<CFG
|
cat <<CFG
|
||||||
{
|
{
|
||||||
"log": {"loglevel": "warning"},
|
"log": {"loglevel": "warning"},
|
||||||
@@ -167,7 +260,9 @@ cat <<CFG
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
CFG
|
CFG
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
echo "=== client binaries ==="
|
echo "=== client binaries ==="
|
||||||
echo " Windows: $GITEA/releases/download/$VERSION/xray-stealth-client-windows-amd64.exe"
|
echo " Windows: $GITEA/releases/download/$VERSION/xray-stealth-client-windows-amd64.exe"
|
||||||
echo " Router: $GITEA/releases/download/$VERSION/xray-stealth-client-linux-arm64"
|
echo " Router: $GITEA/releases/download/$VERSION/xray-stealth-client-linux-arm64"
|
||||||
|
echo " Panel: $GITEA/releases/download/$VERSION/sdm-panel-linux-amd64"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module sdm-panel
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
google.golang.org/grpc v1.83.0
|
||||||
|
google.golang.org/protobuf v1.36.12
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||||
|
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
+1261
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHexKey(t *testing.T) {
|
||||||
|
key := hexKey(8)
|
||||||
|
if len(key) != 16 {
|
||||||
|
t.Errorf("hexKey(8) length = %d, want 16", len(key))
|
||||||
|
}
|
||||||
|
key2 := hexKey(8)
|
||||||
|
if key == key2 {
|
||||||
|
t.Error("hexKey should produce different values on each call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUUID(t *testing.T) {
|
||||||
|
id := uuid()
|
||||||
|
if len(id) != 36 {
|
||||||
|
t.Errorf("uuid() length = %d, want 36", len(id))
|
||||||
|
}
|
||||||
|
if id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
|
||||||
|
t.Errorf("uuid() format wrong: %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTlsCertFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ts map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with cert",
|
||||||
|
ts: map[string]any{
|
||||||
|
"certificates": []any{
|
||||||
|
map[string]any{"certificateFile": "/etc/xray/server.crt"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: "/etc/xray/server.crt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no certs",
|
||||||
|
ts: map[string]any{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty certs",
|
||||||
|
ts: map[string]any{"certificates": []any{}},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type",
|
||||||
|
ts: map[string]any{"certificates": "not a list"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tlsCertFile(tt.ts)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("tlsCertFile() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelfSignedPin(t *testing.T) {
|
||||||
|
// Create a temp self-signed cert
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath := filepath.Join(dir, "test.crt")
|
||||||
|
keyPath := filepath.Join(dir, "test.key")
|
||||||
|
|
||||||
|
// Generate self-signed cert using openssl
|
||||||
|
out, err := sh("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-days", "1", "-subj", "/CN=127.0.0.1",
|
||||||
|
"-keyout", keyPath, "-out", certPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate cert: %v, out: %s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pin := selfSignedPin(certPath)
|
||||||
|
if pin == "" {
|
||||||
|
t.Fatal("selfSignedPin returned empty for self-signed cert")
|
||||||
|
}
|
||||||
|
if len(pin) != 64 {
|
||||||
|
t.Errorf("pin length = %d, want 64 (hex sha256)", len(pin))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same cert should produce same pin
|
||||||
|
pin2 := selfSignedPin(certPath)
|
||||||
|
if pin != pin2 {
|
||||||
|
t.Errorf("selfSignedPin not deterministic: %q != %q", pin, pin2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-existent file should return ""
|
||||||
|
bad := selfSignedPin("/nonexistent/path.crt")
|
||||||
|
if bad != "" {
|
||||||
|
t.Errorf("selfSignedPin for nonexistent file = %q, want empty", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelfSignedPinNotForLE(t *testing.T) {
|
||||||
|
// Create a cert with different issuer and subject (simulating LE)
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath := filepath.Join(dir, "le.crt")
|
||||||
|
|
||||||
|
// We can't easily generate a cert with different issuer/subject via openssl CLI,
|
||||||
|
// so test that the function handles the parsing correctly
|
||||||
|
out, _ := sh("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-days", "1", "-subj", "/CN=test",
|
||||||
|
"-keyout", filepath.Join(dir, "k"), "-out", certPath)
|
||||||
|
|
||||||
|
// The function should return the pin (it's self-signed since issuer==subject)
|
||||||
|
pin := selfSignedPin(certPath)
|
||||||
|
if pin == "" {
|
||||||
|
t.Errorf("selfSignedPin should return pin for self-signed cert, out: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsInboundPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
c map[string]any
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with listen",
|
||||||
|
c: map[string]any{"api": map[string]any{"listen": "127.0.0.1:10085"}},
|
||||||
|
want: 10085,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no api",
|
||||||
|
c: map[string]any{},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no listen",
|
||||||
|
c: map[string]any{"api": map[string]any{}},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad format",
|
||||||
|
c: map[string]any{"api": map[string]any{"listen": "bad"}},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := statsInboundPort(tt.c)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("statsInboundPort() = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStats(t *testing.T) {
|
||||||
|
// Empty config — should add api, stats, policy
|
||||||
|
c := map[string]any{}
|
||||||
|
changed := ensureStats(c)
|
||||||
|
if !changed {
|
||||||
|
t.Error("ensureStats should return true for empty config")
|
||||||
|
}
|
||||||
|
api, ok := c["api"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ensureStats did not add api section")
|
||||||
|
}
|
||||||
|
if api["tag"] != "api" {
|
||||||
|
t.Errorf("api.tag = %v, want 'api'", api["tag"])
|
||||||
|
}
|
||||||
|
if api["listen"] != "127.0.0.1:10085" {
|
||||||
|
t.Errorf("api.listen = %v, want '127.0.0.1:10085'", api["listen"])
|
||||||
|
}
|
||||||
|
if c["stats"] == nil {
|
||||||
|
t.Error("ensureStats did not add stats section")
|
||||||
|
}
|
||||||
|
pol, ok := c["policy"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ensureStats did not add policy section")
|
||||||
|
}
|
||||||
|
levels, ok := pol["levels"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ensureStats did not add levels")
|
||||||
|
}
|
||||||
|
lv0, ok := levels["0"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ensureStats did not add level 0")
|
||||||
|
}
|
||||||
|
if lv0["statsUserUplink"] != true {
|
||||||
|
t.Error("statsUserUplink should be true")
|
||||||
|
}
|
||||||
|
if lv0["statsUserDownlink"] != true {
|
||||||
|
t.Error("statsUserDownlink should be true")
|
||||||
|
}
|
||||||
|
if lv0["statsUserOnline"] != true {
|
||||||
|
t.Error("statsUserOnline should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStatsIdempotent(t *testing.T) {
|
||||||
|
c := map[string]any{
|
||||||
|
"api": map[string]any{"tag": "api", "listen": "127.0.0.1:10085", "services": []any{"StatsService"}},
|
||||||
|
"stats": map[string]any{},
|
||||||
|
"policy": map[string]any{
|
||||||
|
"levels": map[string]any{
|
||||||
|
"0": map[string]any{
|
||||||
|
"statsUserUplink": true,
|
||||||
|
"statsUserDownlink": true,
|
||||||
|
"statsUserOnline": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
changed := ensureStats(c)
|
||||||
|
if changed {
|
||||||
|
t.Error("ensureStats should return false when already configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStealthInbounds(t *testing.T) {
|
||||||
|
c := map[string]any{
|
||||||
|
"inbounds": []any{
|
||||||
|
map[string]any{
|
||||||
|
"protocol": "vless",
|
||||||
|
"streamSettings": map[string]any{
|
||||||
|
"network": "stealth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"protocol": "dokodemo-door",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ibs := stealthInbounds(c)
|
||||||
|
if len(ibs) != 1 {
|
||||||
|
t.Errorf("stealthInbounds returned %d, want 1", len(ibs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenX25519(t *testing.T) {
|
||||||
|
priv, pub := genX25519()
|
||||||
|
if priv == "" || pub == "" {
|
||||||
|
t.Error("genX25519 returned empty keys")
|
||||||
|
}
|
||||||
|
if priv == pub {
|
||||||
|
t.Error("private and public keys should differ")
|
||||||
|
}
|
||||||
|
// Keys should be valid base64url
|
||||||
|
if _, err := base64.RawURLEncoding.DecodeString(priv); err != nil {
|
||||||
|
t.Errorf("private key is not valid base64url: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := base64.RawURLEncoding.DecodeString(pub); err != nil {
|
||||||
|
t.Errorf("public key is not valid base64url: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteReadConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
orig := defaultConfigPath
|
||||||
|
|
||||||
|
// Temporarily override
|
||||||
|
// We can't easily override the const, so test with a real file
|
||||||
|
cfg := map[string]any{
|
||||||
|
"log": map[string]any{"loglevel": "warning"},
|
||||||
|
"inbounds": []any{
|
||||||
|
map[string]any{
|
||||||
|
"listen": "0.0.0.0",
|
||||||
|
"port": float64(443),
|
||||||
|
"protocol": "vless",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cfgPath, b, 0644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back
|
||||||
|
data, err := os.ReadFile(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
var read map[string]any
|
||||||
|
if err := json.Unmarshal(data, &read); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if read["log"] == nil {
|
||||||
|
t.Error("log section missing after round-trip")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = orig // suppress unused
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPanelConfig(t *testing.T) {
|
||||||
|
// Test PanelConfig JSON round-trip
|
||||||
|
pc := PanelConfig{
|
||||||
|
Bind: "127.0.0.1:8080",
|
||||||
|
Password: "test123",
|
||||||
|
Config: "/etc/xray/config.json",
|
||||||
|
ServerName: "example.com",
|
||||||
|
Fingerprint: "chrome",
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(pc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var read PanelConfig
|
||||||
|
if err := json.Unmarshal(b, &read); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if read.Bind != pc.Bind {
|
||||||
|
t.Errorf("bind = %q, want %q", read.Bind, pc.Bind)
|
||||||
|
}
|
||||||
|
if read.ServerName != pc.ServerName {
|
||||||
|
t.Errorf("serverName = %q, want %q", read.ServerName, pc.ServerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteJSON(t *testing.T) {
|
||||||
|
// Just verify it doesn't panic
|
||||||
|
w := &fakeResponseWriter{}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
if w.code != 200 {
|
||||||
|
t.Errorf("code = %d, want 200", w.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrStr(t *testing.T) {
|
||||||
|
if errStr(nil) != "" {
|
||||||
|
t.Error("errStr(nil) should be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailDefaultToUUID(t *testing.T) {
|
||||||
|
// When adding a client with empty name, email should be set to UUID
|
||||||
|
id := uuid()
|
||||||
|
email := ""
|
||||||
|
if email == "" {
|
||||||
|
email = id
|
||||||
|
}
|
||||||
|
if email != id {
|
||||||
|
t.Errorf("email = %q, want %q (UUID)", email, id)
|
||||||
|
}
|
||||||
|
if len(email) != 36 {
|
||||||
|
t.Errorf("email length = %d, want 36 (UUID format)", len(email))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeResponseWriter for testing
|
||||||
|
type fakeResponseWriter struct {
|
||||||
|
code int
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResponseWriter) Header() http.Header { return http.Header{} }
|
||||||
|
func (f *fakeResponseWriter) Write(b []byte) (int, error) { f.body = append(f.body, b...); return len(b), nil }
|
||||||
|
func (f *fakeResponseWriter) WriteHeader(code int) { f.code = code }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package xray.app.stats.command;
|
||||||
|
option csharp_namespace = "Xray.App.Stats.Command";
|
||||||
|
option go_package = "github.com/xtls/xray-core/app/stats/command";
|
||||||
|
option java_package = "com.xray.app.stats.command";
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
message GetStatsRequest {
|
||||||
|
// Name of the stat counter.
|
||||||
|
string name = 1;
|
||||||
|
// Whether or not to reset the counter to fetching its value.
|
||||||
|
bool reset = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Stat {
|
||||||
|
string name = 1;
|
||||||
|
int64 value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetStatsResponse {
|
||||||
|
Stat stat = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryStatsRequest {
|
||||||
|
string pattern = 1;
|
||||||
|
bool reset = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryStatsResponse {
|
||||||
|
repeated Stat stat = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SysStatsRequest {}
|
||||||
|
|
||||||
|
message SysStatsResponse {
|
||||||
|
uint32 NumGoroutine = 1;
|
||||||
|
uint32 NumGC = 2;
|
||||||
|
uint64 Alloc = 3;
|
||||||
|
uint64 TotalAlloc = 4;
|
||||||
|
uint64 Sys = 5;
|
||||||
|
uint64 Mallocs = 6;
|
||||||
|
uint64 Frees = 7;
|
||||||
|
uint64 LiveObjects = 8;
|
||||||
|
uint64 PauseTotalNs = 9;
|
||||||
|
uint32 Uptime = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetStatsOnlineIpListResponse {
|
||||||
|
string name = 1;
|
||||||
|
map<string, int64> ips = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetAllOnlineUsersRequest {}
|
||||||
|
|
||||||
|
message GetAllOnlineUsersResponse {
|
||||||
|
repeated string users = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OnlineIPEntry {
|
||||||
|
string ip = 1;
|
||||||
|
int64 last_seen = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TrafficUserStat {
|
||||||
|
int64 uplink = 1;
|
||||||
|
int64 downlink = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserStat {
|
||||||
|
string email = 1;
|
||||||
|
repeated OnlineIPEntry ips = 2;
|
||||||
|
TrafficUserStat traffic = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUsersStatsRequest {
|
||||||
|
bool include_traffic = 1;
|
||||||
|
bool reset = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetUsersStatsResponse {
|
||||||
|
repeated UserStat users = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
service StatsService {
|
||||||
|
rpc GetStats(GetStatsRequest) returns (GetStatsResponse) {}
|
||||||
|
rpc GetStatsOnline(GetStatsRequest) returns (GetStatsResponse) {}
|
||||||
|
rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {}
|
||||||
|
rpc GetSysStats(SysStatsRequest) returns (SysStatsResponse) {}
|
||||||
|
rpc GetStatsOnlineIpList(GetStatsRequest) returns (GetStatsOnlineIpListResponse) {}
|
||||||
|
rpc GetAllOnlineUsers(GetAllOnlineUsersRequest) returns (GetAllOnlineUsersResponse) {}
|
||||||
|
rpc GetUsersStats(GetUsersStatsRequest) returns (GetUsersStatsResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Config {}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
|
// - protoc v3.21.12
|
||||||
|
// source: stats/command.proto
|
||||||
|
|
||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatsService_GetStats_FullMethodName = "/xray.app.stats.command.StatsService/GetStats"
|
||||||
|
StatsService_GetStatsOnline_FullMethodName = "/xray.app.stats.command.StatsService/GetStatsOnline"
|
||||||
|
StatsService_QueryStats_FullMethodName = "/xray.app.stats.command.StatsService/QueryStats"
|
||||||
|
StatsService_GetSysStats_FullMethodName = "/xray.app.stats.command.StatsService/GetSysStats"
|
||||||
|
StatsService_GetStatsOnlineIpList_FullMethodName = "/xray.app.stats.command.StatsService/GetStatsOnlineIpList"
|
||||||
|
StatsService_GetAllOnlineUsers_FullMethodName = "/xray.app.stats.command.StatsService/GetAllOnlineUsers"
|
||||||
|
StatsService_GetUsersStats_FullMethodName = "/xray.app.stats.command.StatsService/GetUsersStats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsServiceClient is the client API for StatsService service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
type StatsServiceClient interface {
|
||||||
|
GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error)
|
||||||
|
GetStatsOnline(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error)
|
||||||
|
QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error)
|
||||||
|
GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error)
|
||||||
|
GetStatsOnlineIpList(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsOnlineIpListResponse, error)
|
||||||
|
GetAllOnlineUsers(ctx context.Context, in *GetAllOnlineUsersRequest, opts ...grpc.CallOption) (*GetAllOnlineUsersResponse, error)
|
||||||
|
GetUsersStats(ctx context.Context, in *GetUsersStatsRequest, opts ...grpc.CallOption) (*GetUsersStatsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type statsServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatsServiceClient(cc grpc.ClientConnInterface) StatsServiceClient {
|
||||||
|
return &statsServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetStatsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, StatsService_GetStats_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsServiceClient) GetStatsOnline(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetStatsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, StatsService_GetStatsOnline_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsServiceClient) QueryStats(ctx context.Context, in *QueryStatsRequest, opts ...grpc.CallOption) (*QueryStatsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(QueryStatsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, StatsService_QueryStats_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsServiceClient) GetSysStats(ctx context.Context, in *SysStatsRequest, opts ...grpc.CallOption) (*SysStatsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(SysStatsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, StatsService_GetSysStats_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsServiceClient) GetStatsOnlineIpList(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsOnlineIpListResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetStatsOnlineIpListResponse)
|
||||||
|
err := c.cc.Invoke(ctx, StatsService_GetStatsOnlineIpList_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsServiceClient) GetAllOnlineUsers(ctx context.Context, in *GetAllOnlineUsersRequest, opts ...grpc.CallOption) (*GetAllOnlineUsersResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetAllOnlineUsersResponse)
|
||||||
|
err := c.cc.Invoke(ctx, StatsService_GetAllOnlineUsers_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsServiceClient) GetUsersStats(ctx context.Context, in *GetUsersStatsRequest, opts ...grpc.CallOption) (*GetUsersStatsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetUsersStatsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, StatsService_GetUsersStats_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsServiceServer is the server API for StatsService service.
|
||||||
|
// All implementations must embed UnimplementedStatsServiceServer
|
||||||
|
// for forward compatibility.
|
||||||
|
type StatsServiceServer interface {
|
||||||
|
GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error)
|
||||||
|
GetStatsOnline(context.Context, *GetStatsRequest) (*GetStatsResponse, error)
|
||||||
|
QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error)
|
||||||
|
GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error)
|
||||||
|
GetStatsOnlineIpList(context.Context, *GetStatsRequest) (*GetStatsOnlineIpListResponse, error)
|
||||||
|
GetAllOnlineUsers(context.Context, *GetAllOnlineUsersRequest) (*GetAllOnlineUsersResponse, error)
|
||||||
|
GetUsersStats(context.Context, *GetUsersStatsRequest) (*GetUsersStatsResponse, error)
|
||||||
|
mustEmbedUnimplementedStatsServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedStatsServiceServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedStatsServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedStatsServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetStats not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedStatsServiceServer) GetStatsOnline(context.Context, *GetStatsRequest) (*GetStatsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetStatsOnline not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedStatsServiceServer) QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method QueryStats not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedStatsServiceServer) GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetSysStats not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedStatsServiceServer) GetStatsOnlineIpList(context.Context, *GetStatsRequest) (*GetStatsOnlineIpListResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetStatsOnlineIpList not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedStatsServiceServer) GetAllOnlineUsers(context.Context, *GetAllOnlineUsersRequest) (*GetAllOnlineUsersResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetAllOnlineUsers not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedStatsServiceServer) GetUsersStats(context.Context, *GetUsersStatsRequest) (*GetUsersStatsResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method GetUsersStats not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedStatsServiceServer) mustEmbedUnimplementedStatsServiceServer() {}
|
||||||
|
func (UnimplementedStatsServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeStatsServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to StatsServiceServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeStatsServiceServer interface {
|
||||||
|
mustEmbedUnimplementedStatsServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterStatsServiceServer(s grpc.ServiceRegistrar, srv StatsServiceServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedStatsServiceServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&StatsService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _StatsService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetStatsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(StatsServiceServer).GetStats(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: StatsService_GetStats_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(StatsServiceServer).GetStats(ctx, req.(*GetStatsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _StatsService_GetStatsOnline_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetStatsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(StatsServiceServer).GetStatsOnline(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: StatsService_GetStatsOnline_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(StatsServiceServer).GetStatsOnline(ctx, req.(*GetStatsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _StatsService_QueryStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(QueryStatsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(StatsServiceServer).QueryStats(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: StatsService_QueryStats_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(StatsServiceServer).QueryStats(ctx, req.(*QueryStatsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _StatsService_GetSysStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SysStatsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(StatsServiceServer).GetSysStats(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: StatsService_GetSysStats_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(StatsServiceServer).GetSysStats(ctx, req.(*SysStatsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _StatsService_GetStatsOnlineIpList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetStatsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(StatsServiceServer).GetStatsOnlineIpList(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: StatsService_GetStatsOnlineIpList_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(StatsServiceServer).GetStatsOnlineIpList(ctx, req.(*GetStatsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _StatsService_GetAllOnlineUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetAllOnlineUsersRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(StatsServiceServer).GetAllOnlineUsers(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: StatsService_GetAllOnlineUsers_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(StatsServiceServer).GetAllOnlineUsers(ctx, req.(*GetAllOnlineUsersRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _StatsService_GetUsersStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetUsersStatsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(StatsServiceServer).GetUsersStats(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: StatsService_GetUsersStats_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(StatsServiceServer).GetUsersStats(ctx, req.(*GetUsersStatsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsService_ServiceDesc is the grpc.ServiceDesc for StatsService service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var StatsService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "xray.app.stats.command.StatsService",
|
||||||
|
HandlerType: (*StatsServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "GetStats",
|
||||||
|
Handler: _StatsService_GetStats_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetStatsOnline",
|
||||||
|
Handler: _StatsService_GetStatsOnline_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "QueryStats",
|
||||||
|
Handler: _StatsService_QueryStats_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetSysStats",
|
||||||
|
Handler: _StatsService_GetSysStats_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetStatsOnlineIpList",
|
||||||
|
Handler: _StatsService_GetStatsOnlineIpList_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetAllOnlineUsers",
|
||||||
|
Handler: _StatsService_GetAllOnlineUsers_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetUsersStats",
|
||||||
|
Handler: _StatsService_GetUsersStats_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "stats/command.proto",
|
||||||
|
}
|
||||||
+381
@@ -0,0 +1,381 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
const ui = `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>sdm-panel</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--side:#0a0e13;--card:#151b23;--line:#1f2833;--txt:#dfe6ee;--mut:#8b98a5;--acc:#4f8ef7;--ok:#3fb97f;--err:#ff6b6b;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;font-family:-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--txt);font-size:14px}
|
||||||
|
.hidden{display:none!important}
|
||||||
|
button{background:#1d2735;color:var(--txt);border:1px solid #2a3546;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:13px;transition:background .15s}
|
||||||
|
button:hover{background:#273449}
|
||||||
|
button.primary{background:var(--acc);border-color:var(--acc);color:#fff}
|
||||||
|
button.primary:hover{background:#3d7ee6}
|
||||||
|
button.ghost{background:transparent;border-color:transparent;color:var(--mut);padding:6px 10px;font-size:12px}
|
||||||
|
button.ghost:hover{background:#1a222c;color:var(--txt)}
|
||||||
|
button.danger{background:#2a1820;border-color:#4a2330;color:#ff9d9d}
|
||||||
|
button.icon{font-size:14px;padding:6px 9px}
|
||||||
|
input[type=text],input[type=password],input[type=number],textarea{width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-family:inherit;font-size:13px}
|
||||||
|
input:focus,textarea:focus{outline:none;border-color:var(--acc)}
|
||||||
|
label{display:block;color:var(--mut);font-size:12px;margin:0 0 5px}
|
||||||
|
/* login */
|
||||||
|
#login{height:100vh;display:flex;align-items:center;justify-content:center}
|
||||||
|
.login-card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:36px;width:340px;box-sizing:border-box}
|
||||||
|
.login-card h1{margin:0 0 4px;font-size:22px}
|
||||||
|
.login-card .sub{color:var(--mut);margin:0 0 24px;font-size:13px}
|
||||||
|
.login-card label{display:block;color:var(--mut);font-size:12px;margin:0 0 5px}
|
||||||
|
.login-card input{height:auto !important}
|
||||||
|
/* layout */
|
||||||
|
#app{display:flex;min-height:100vh}
|
||||||
|
aside{width:220px;background:var(--side);border-right:1px solid var(--line);padding:20px 12px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
|
||||||
|
aside .brand{display:flex;align-items:center;gap:10px;padding:4px 10px 18px;font-weight:600;font-size:16px}
|
||||||
|
aside .brand .dot{width:10px;height:10px;border-radius:50%;background:var(--acc);box-shadow:0 0 10px var(--acc)}
|
||||||
|
aside nav{display:flex;flex-direction:column;gap:2px;flex:1}
|
||||||
|
aside nav button{text-align:left;background:transparent;border:1px solid transparent;border-radius:8px;padding:10px 12px;color:var(--mut);font-size:13px}
|
||||||
|
aside nav button:hover{background:#151b23;color:var(--txt)}
|
||||||
|
aside nav button.on{background:var(--card);color:var(--txt);border-color:var(--line)}
|
||||||
|
aside nav button .glyph{margin-right:8px;opacity:.7}
|
||||||
|
aside .logout{background:transparent;border:none;color:var(--mut);text-align:left;padding:10px 12px;cursor:pointer;font-size:13px}
|
||||||
|
aside .logout:hover{color:var(--err)}
|
||||||
|
main{flex:1;padding:28px 32px;max-width:1040px}
|
||||||
|
main h2{margin:0 0 4px;font-size:20px}
|
||||||
|
main .crumb{color:var(--mut);font-size:12px;margin:0 0 20px}
|
||||||
|
/* cards */
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px;margin-bottom:20px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}
|
||||||
|
.stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.6px}
|
||||||
|
.stat .v{font-size:15px;margin-top:6px;word-break:break-all;line-height:1.4}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:20px;font-size:12px;font-weight:500}
|
||||||
|
.badge::before{content:"";width:8px;height:8px;border-radius:50%}
|
||||||
|
.badge.ok{background:rgba(63,185,127,.12);color:var(--ok)}.badge.ok::before{background:var(--ok);box-shadow:0 0 8px var(--ok)}
|
||||||
|
.badge.bad{background:rgba(255,107,107,.12);color:var(--err)}.badge.bad::before{background:var(--err)}
|
||||||
|
.section{margin:22px 0 8px;color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:1px}
|
||||||
|
/* clients */
|
||||||
|
.cli{display:flex;align-items:center;gap:14px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin-bottom:10px}
|
||||||
|
.cli .nm{font-weight:500;min-width:120px}
|
||||||
|
.cli .uid{font-family:var(--mono);font-size:12px;color:var(--mut);flex:1;word-break:break-all}
|
||||||
|
.cli .acts{display:flex;gap:6px;flex-shrink:0}
|
||||||
|
/* forms */
|
||||||
|
.row{display:flex;gap:12px;flex-wrap:wrap}
|
||||||
|
.row > div{flex:1;min-width:150px}
|
||||||
|
textarea{min-height:110px;font-family:var(--mono);font-size:12px;line-height:1.5}
|
||||||
|
.mut{color:var(--mut);font-size:12px}
|
||||||
|
pre{background:#0b1016;border:1px solid var(--line);border-radius:12px;padding:14px;overflow:auto;max-height:440px;font-family:var(--mono);font-size:12px;white-space:pre-wrap;line-height:1.5}
|
||||||
|
.actions{display:flex;gap:10px;align-items:center;margin-top:16px}
|
||||||
|
#toast{position:fixed;top:18px;right:18px;background:#1d2735;border:1px solid var(--line);border-radius:10px;padding:12px 18px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:50;max-width:380px;font-size:13px}
|
||||||
|
#toast.show{opacity:1}
|
||||||
|
#toast.ok{border-color:var(--ok)}#toast.err{border-color:var(--err)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="login"><div class="login-card">
|
||||||
|
<h1>sdm-panel</h1>
|
||||||
|
<p class="sub">stealth tunnel · управление сервером</p>
|
||||||
|
<label>Логин</label>
|
||||||
|
<input type="text" id="user" 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>
|
||||||
|
<button class="primary" style="width:100%" onclick="login()">Войти</button>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div id="app" class="hidden">
|
||||||
|
<aside>
|
||||||
|
<div class="brand"><span class="dot"></span>sdm-panel</div>
|
||||||
|
<nav>
|
||||||
|
<button class="on" data-t="status" onclick="tab('status')"><span class="glyph">▤</span>Статус</button>
|
||||||
|
<button data-t="clients" onclick="tab('clients')"><span class="glyph">👥</span>Клиенты</button>
|
||||||
|
<button data-t="tunnel" onclick="tab('tunnel')"><span class="glyph">⚙</span>Туннель</button>
|
||||||
|
<button data-t="panel" onclick="tab('panel')"><span class="glyph">🛠</span>Панель</button>
|
||||||
|
<button data-t="logs" onclick="tab('logs')"><span class="glyph">≡</span>Логи</button>
|
||||||
|
</nav>
|
||||||
|
<button class="logout" onclick="logout()">⏻ Выйти</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="t-status">
|
||||||
|
<h2>Статус</h2><p class="crumb">Общее состояние сервера и туннеля</p>
|
||||||
|
<div class="grid" id="statusGrid"></div>
|
||||||
|
<div class="card actions">
|
||||||
|
<button class="primary" onclick="restart()">Перезапустить xray</button>
|
||||||
|
<button onclick="loadStatus()">Обновить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="t-clients" class="hidden">
|
||||||
|
<h2>Клиенты</h2><p class="crumb">Устройства, которым разрешено подключаться</p>
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<div class="row">
|
||||||
|
<div style="flex:3"><label>Имя клиента</label><input type="text" id="newName" placeholder="например: iphone, ноутбук, роутер"></div>
|
||||||
|
<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">
|
||||||
|
<div style="flex:2"><label>target — выбери или впиши свой</label>
|
||||||
|
<input type="text" id="target" list="targetList" placeholder="https://www.iana.org">
|
||||||
|
<datalist id="targetList">
|
||||||
|
<option value="https://www.iana.org">IANA (нейтральный, рекомендуется)</option>
|
||||||
|
<option value="https://www.apple.com">Apple</option>
|
||||||
|
<option value="https://www.openbsd.org">OpenBSD</option>
|
||||||
|
<option value="https://www.debian.org">Debian</option>
|
||||||
|
<option value="https://www.gnu.org">GNU</option>
|
||||||
|
<option value="https://www.mozilla.org">Mozilla</option>
|
||||||
|
<option value="https://www.kernel.org">kernel.org</option>
|
||||||
|
<option value="https://www.cloudflare.com">Cloudflare</option>
|
||||||
|
<option value="">— без зеркала (встроенная страница) —</option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<textarea id="paths"></textarea>
|
||||||
|
<div class="section">Транспорт</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Порты (через запятую)</label><input type="text" id="ports"></div>
|
||||||
|
<div><label>padMax</label><input type="number" id="padMax"></div>
|
||||||
|
<div><label>padProb (%)</label><input type="number" id="padProb"></div>
|
||||||
|
<div><label>paceMs</label><input type="number" id="paceMs"></div>
|
||||||
|
<div><label>paceJitterMs</label><input type="number" id="paceJitterMs"></div>
|
||||||
|
<div><label>bps (0 = выкл)</label><input type="number" id="bps"></div>
|
||||||
|
<div><label>retries</label><input type="number" id="retries"></div>
|
||||||
|
<div><label>padProfile</label><input type="text" id="padProfile"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section">Decoy (фоновый трафик)</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>decoy (true/false)</label><input type="text" id="decoy"></div>
|
||||||
|
<div><label>decoyEverySecs</label><input type="number" id="decoyEverySecs"></div>
|
||||||
|
<div><label>decoyTarget</label><input type="text" id="decoyTarget"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="card actions">
|
||||||
|
<button class="primary" onclick="saveStealth()">Сохранить и перезапустить</button>
|
||||||
|
<button onclick="rotStealthKey()">Сменить stealth-ключ</button>
|
||||||
|
<span class="mut">После сохранения xray перезапустится; клиентам понадобятся обновлённые конфиги.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="t-panel" class="hidden">
|
||||||
|
<h2>Панель</h2><p class="crumb">Настройки самой панели</p>
|
||||||
|
<div class="card">
|
||||||
|
<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">
|
||||||
|
<h2>Логи</h2><p class="crumb">Последние записи xray</p>
|
||||||
|
<div class="card">
|
||||||
|
<button onclick="loadLogs()">Обновить</button>
|
||||||
|
<pre id="logs" style="margin-top:12px"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let TOK = localStorage.getItem('tok') || '';
|
||||||
|
function api(p, opt){ opt=opt||{};
|
||||||
|
opt.headers = Object.assign({'Authorization':'Bearer '+TOK}, opt.headers||{});
|
||||||
|
return fetch(p,opt).then(async r=>{const j=await r.json().catch(()=>({}));
|
||||||
|
if(r.status===401){logout();throw new Error('unauthorized')}
|
||||||
|
if(!r.ok) throw new Error(j.error||('HTTP '+r.status));
|
||||||
|
return j;});
|
||||||
|
}
|
||||||
|
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({login:document.getElementById('user').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();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>';
|
||||||
|
const g=document.getElementById('statusGrid');g.innerHTML='';
|
||||||
|
[['Сервис',badge],['Порты',(s.ports||[]).join(', ')||'-'],['Пути',(s.paths||[]).join(', ')||'-'],['Зеркало',esc(s.target||'-')],['Клиентов',s.clientCount||0]].forEach(([k,v])=>{
|
||||||
|
const d=document.createElement('div');d.className='card stat';d.innerHTML='<div class="k">'+k+'</div><div class="v">'+v+'</div>';g.appendChild(d)})}catch(e){toast(e.message,false)}}
|
||||||
|
|
||||||
|
async function loadClients(){try{const j=await api('api/clients');const L=document.getElementById('clientsList');L.innerHTML='';
|
||||||
|
if(!j.clients.length){L.innerHTML='<div class="card mut">Клиентов пока нет — добавьте первого.</div>';return}
|
||||||
|
j.clients.forEach(cl=>{const d=document.createElement('div');d.className='cli';
|
||||||
|
d.innerHTML='<div class="nm">'+esc(cl.name||'—')+'</div><div class="uid">'+esc(cl.id||'')+'</div>'+
|
||||||
|
'<div class="acts">'+
|
||||||
|
'<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)}}
|
||||||
|
async function copyConfig(id){try{const r=await fetch('api/clients/'+id+'/config',{headers:{'Authorization':'Bearer '+TOK}});const txt=await r.text();await navigator.clipboard.writeText(txt);toast('конфиг client-stealth.json скопирован',true)}catch(e){toast('скопировать не удалось: '+e.message,false)}}
|
||||||
|
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);
|
||||||
|
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: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: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;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>
|
||||||
|
`
|
||||||
Executable
+1372
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -185,14 +185,14 @@ func ListenStealth(ctx context.Context, address xnet.Address, port xnet.Port, st
|
|||||||
} else {
|
} else {
|
||||||
nb = rewriteBodyRefs(body, target.Host, host)
|
nb = rewriteBodyRefs(body, target.Host, host)
|
||||||
}
|
}
|
||||||
if !bytes.Equal(nb, body) {
|
// always re-inject the body: io.ReadAll drained the
|
||||||
resp.Body.Close()
|
// original, so even an unchanged body must be re-wrapped
|
||||||
resp.Body = io.NopCloser(bytes.NewReader(nb))
|
resp.Body.Close()
|
||||||
resp.ContentLength = int64(len(nb))
|
resp.Body = io.NopCloser(bytes.NewReader(nb))
|
||||||
resp.Header.Set("Content-Length", strconv.Itoa(len(nb)))
|
resp.ContentLength = int64(len(nb))
|
||||||
resp.Header.Del("Content-Encoding")
|
resp.Header.Set("Content-Length", strconv.Itoa(len(nb)))
|
||||||
resp.Header.Del("Transfer-Encoding")
|
resp.Header.Del("Content-Encoding")
|
||||||
}
|
resp.Header.Del("Transfer-Encoding")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# sdm-xray — полная деинсталляция сервера stealth-туннеля.
|
||||||
|
# Зеркало install.sh: убирает ровно то, что он ставил.
|
||||||
|
# Использование:
|
||||||
|
# sudo bash uninstall.sh [domain]
|
||||||
|
# где [domain] — домен, для которого выпущен Let's Encrypt-сертификат
|
||||||
|
# (если не передан, домен автоопределяется из nginx-конфига /etc/nginx/sites-available/sdm).
|
||||||
|
set -u
|
||||||
|
|
||||||
|
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||||
|
sed -n '2,9p' "$0"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOMAIN="${1:-}"
|
||||||
|
INSTALL_DIR="/usr/local/xray"
|
||||||
|
DATA_DIR="/etc/xray"
|
||||||
|
|
||||||
|
if [ -z "$DOMAIN" ] && [ -f /etc/nginx/sites-available/sdm ]; then
|
||||||
|
DOMAIN="$(grep -m1 'server_name' /etc/nginx/sites-available/sdm 2>/dev/null | awk '{print $2}' | tr -d ';')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== sdm-xray uninstall ==="
|
||||||
|
[ -n "$DOMAIN" ] && echo "domain: $DOMAIN" || echo "domain: (не определён)"
|
||||||
|
|
||||||
|
echo "--- stop & disable systemd services ---"
|
||||||
|
for svc in sdm-panel xray; do
|
||||||
|
if [ -f "/etc/systemd/system/$svc.service" ]; then
|
||||||
|
systemctl stop "$svc" 2>/dev/null || true
|
||||||
|
systemctl disable "$svc" 2>/dev/null || true
|
||||||
|
rm -f "/etc/systemd/system/$svc.service"
|
||||||
|
echo "removed $svc.service"
|
||||||
|
else
|
||||||
|
echo "$svc.service not present"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "--- remove binaries & data ---"
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
rm -rf "$DATA_DIR"
|
||||||
|
echo "removed $INSTALL_DIR"
|
||||||
|
echo "removed $DATA_DIR (config.json, panel.json, uuid, key)"
|
||||||
|
|
||||||
|
echo "--- nginx site ---"
|
||||||
|
rm -f /etc/nginx/sites-enabled/sdm
|
||||||
|
rm -f /etc/nginx/sites-available/sdm
|
||||||
|
if [ -f /etc/nginx/sites-available/default ] && [ ! -e /etc/nginx/sites-enabled/default ]; then
|
||||||
|
ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
|
||||||
|
echo "restored default nginx site"
|
||||||
|
fi
|
||||||
|
if nginx -t >/dev/null 2>&1; then
|
||||||
|
systemctl reload nginx 2>/dev/null || true
|
||||||
|
echo "nginx reloaded"
|
||||||
|
else
|
||||||
|
echo "WARN: nginx config is broken — fix manually (nginx -t)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- letsencrypt certificate ---"
|
||||||
|
if [ -n "$DOMAIN" ]; then
|
||||||
|
if [ -d "/etc/letsencrypt/live/$DOMAIN" ]; then
|
||||||
|
if command -v certbot >/dev/null 2>&1; then
|
||||||
|
certbot delete --cert-name "$DOMAIN" --non-interactive >/dev/null 2>&1 \
|
||||||
|
|| echo "WARN: certbot delete failed — удалите серт вручную: sudo certbot delete --cert-name $DOMAIN"
|
||||||
|
fi
|
||||||
|
rm -f "/etc/letsencrypt/renewal/$DOMAIN.conf"
|
||||||
|
rm -rf "/etc/letsencrypt/live/$DOMAIN" "/etc/letsencrypt/archive/$DOMAIN"
|
||||||
|
echo "removed LE cert for $DOMAIN"
|
||||||
|
else
|
||||||
|
echo "no cert for $DOMAIN"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "domain not given — LE cert NOT removed. Вручную: sudo certbot delete --cert-name <domain>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== UNINSTALLED ==="
|
||||||
|
echo "Остались apt-пакеты (не удаляю — их могут использовать другие сервисы):"
|
||||||
|
echo " curl openssl jq nginx certbot"
|
||||||
|
echo " полностью снести: sudo apt-get purge -y --auto-remove nginx certbot"
|
||||||
|
echo "Остался файл сертификата: /var/www/html/.well-known/acme-challenge (можно удалить вручную)"
|
||||||
Reference in New Issue
Block a user