From a3a8e951c5effc423c6ff5d3937d00b17801df54 Mon Sep 17 00:00:00 2001 From: ded_ikar Date: Sun, 16 Aug 2026 02:08:24 +0500 Subject: [PATCH] sdm-xray v0.1.0: stealth transport server installer + clients --- LICENSE | 360 ++++++++ README.md | 50 + install.sh | 173 ++++ stealth/README.md | 9 + stealth/infra/conf/transport_internet.go | 341 +++++++ stealth/infra/conf/transport_method.go | 856 ++++++++++++++++++ stealth/transport/internet/stealth/config.go | 175 ++++ .../transport/internet/stealth/config.pb.go | 268 ++++++ .../transport/internet/stealth/config.proto | 40 + stealth/transport/internet/stealth/conn.go | 160 ++++ stealth/transport/internet/stealth/dialer.go | 205 +++++ stealth/transport/internet/stealth/framing.go | 59 ++ stealth/transport/internet/stealth/hub.go | 317 +++++++ 13 files changed, 3013 insertions(+) create mode 100755 LICENSE create mode 100755 README.md create mode 100755 install.sh create mode 100644 stealth/README.md create mode 100644 stealth/infra/conf/transport_internet.go create mode 100644 stealth/infra/conf/transport_method.go create mode 100644 stealth/transport/internet/stealth/config.go create mode 100644 stealth/transport/internet/stealth/config.pb.go create mode 100644 stealth/transport/internet/stealth/config.proto create mode 100644 stealth/transport/internet/stealth/conn.go create mode 100644 stealth/transport/internet/stealth/dialer.go create mode 100644 stealth/transport/internet/stealth/framing.go create mode 100644 stealth/transport/internet/stealth/hub.go diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..50085e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,360 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means (a) that the initial Contributor has attached the notice + described in Exhibit B to the Covered Software; or (b) that the + Covered Software was made available under the terms of version 1.1 + or earlier of the License, but not also under the terms of a + Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: +(a) for any code that a Contributor has removed from Covered Software; or +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under this +License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100755 index 0000000..60b0ace --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# sdm-xray + +Собственный анти-DPI туннель: форк [Xray-core](https://github.com/XTLS/Xray-core) с кастомным +транспортом **`stealth`**. Клиент → своя VPS, трафик максимально похож на браузерный HTTPS. + +## Принцип +«Адаптивность, а не протокол»: транспорт меняет поведение (identity) быстрее, чем DPI успевает +его описывать. Всё в одном процессе, одним JSON-конфигом, без панелей и внешних сервисов. + +Что умеет: +- Свой транспорт поверх HTTP/2 (TLS 1.3 + uTLS chrome), мультиплексирование на одном канале +- Ротация туннельных путей на каждое соединение + ретрай со свежей identity при сбое +- Анти-пробиг: сервер зеркалит реальный сайт (по умолчанию wikipedia) — зонд видит + настоящий многостраничный сайт на вашем домене (редиректы, HTML/CSS-ссылки переписаны) +- Padding/pacing под браузер, опции: ротация портов, rate-limit, decoy, REALITY +- По умолчанию только порт 443 (как браузер) + +## Установка сервера (свежая Ubuntu VPS) +Домен должен указывать A-записью на IP VPS, порт 80 открыт. +```bash +bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) ваш-домен.ru +``` +Скрипт: ставит бинарь + systemd, генерит UUID/ключ, получает Let's Encrypt, поднимает +туннель на 443 с зеркалом. В конце печатает готовый клиентский конфиг. + +## Клиенты +- **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` +`key`, `padMax`, `padProb`, `paceMs`, `paceJitterMs`, `path`/`paths`, `ports` (опция), +`bps` (опция), `target` (зеркало), `decoy`/`decoyEverySecs`/`decoyTarget` (опция), +`padProfile` ("browser"|"uniform"), `retries`. + +## Сборка из исходников +Нужны Go 1.26, protoc, protoc-gen-go. +```bash +GOOS=linux GOARCH=amd64 go build -o xray-server-amd64 ./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 +``` +Транспорт: `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. Новые файлы транспорта — +наш код; изменённые файлы upstream распространяются под MPL-2.0 (исходники приложены в `stealth/`). diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a0fe624 --- /dev/null +++ b/install.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# sdm-xray — установка сервера stealth-туннеля на свежую Ubuntu VPS. +# Использование: +# bash <(curl -fsSL https://git.my-handbook.ru/ded_ikar/sdm-xray/raw/main/install.sh) +# где — ваш домен, A-запись которого указывает на IP этой VPS. +set -euo pipefail + +DOMAIN="${1:-}" +if [ -z "$DOMAIN" ]; then + echo "Usage: install.sh " + echo " domain — домен, указывающий на IP этой VPS (нужен для Let's Encrypt)" + exit 1 +fi + +GITEA="https://git.my-handbook.ru/ded_ikar/sdm-xray" +VERSION="v0.1.0" +BIN="xray-stealth-server-linux-amd64" +INSTALL_DIR="/usr/local/xray" +DATA_DIR="/etc/xray" +WEBROOT="/var/www/html" +VPS_IP="$(curl -fsSL --max-time 10 https://api.ipify.org 2>/dev/null || echo UNKNOWN)" + +echo "=== sdm-xray installer v${VERSION} ===" +echo "domain: $DOMAIN vps-ip: $VPS_IP" + +export DEBIAN_FRONTEND=noninteractive + +echo "=== apt: deps ===" +apt-get update -qq +apt-get install -y -qq curl openssl jq nginx certbot >/dev/null 2>&1 + +echo "=== download server binary + geo files ===" +mkdir -p "$INSTALL_DIR" "$DATA_DIR" "$WEBROOT" +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/geosite.dat" "$GITEA/releases/download/$VERSION/geosite.dat" +chmod 755 "$INSTALL_DIR/xray" +"$INSTALL_DIR/xray" version | head -1 + +echo "=== generate secrets ===" +UUID="$(cat /proc/sys/kernel/random/uuid)" +KEY="$(openssl rand -hex 8)" +echo "$UUID" > "$DATA_DIR/uuid" +echo "$KEY" > "$DATA_DIR/key" + +echo "=== nginx on 80 (acme) ===" +mkdir -p "$WEBROOT/.well-known/acme-challenge" +cat > /etc/nginx/sites-available/sdm <<'NGX' +server { + listen 80; + listen [::]:80; + server_name DOMAIN; + root /var/www/html; + index index.html; + location /.well-known/acme-challenge/ { + alias /var/www/html/.well-known/acme-challenge/; + } + location / { + return 301 https://$host$request_uri; + } +} +NGX +sed -i "s/DOMAIN/$DOMAIN/" /etc/nginx/sites-available/sdm +ln -sf /etc/nginx/sites-available/sdm /etc/nginx/sites-enabled/sdm +rm -f /etc/nginx/sites-enabled/default +nginx -t >/dev/null 2>&1 && systemctl reload nginx + +echo "=== Let's Encrypt cert ===" +certbot certonly --webroot -w "$WEBROOT" -d "$DOMAIN" \ + --register-unsafely-without-email --agree-tos --no-eff-email -n \ + --cert-name "$DOMAIN" >/dev/null 2>&1 || true +CERT_DIR="/etc/letsencrypt/live/$DOMAIN" +if [ ! -f "$CERT_DIR/fullchain.pem" ]; then + echo "ERROR: сертификат не получен. Проверьте, что $DOMAIN -> $VPS_IP и порт 80 открыт." + exit 1 +fi + +echo "=== xray config ===" +cat > "$DATA_DIR/config.json" < /etc/systemd/system/xray.service <<'UNIT' +[Unit] +Description=sdm-xray stealth tunnel +After=network.target nss-lookup.target + +[Service] +ExecStart=/usr/local/xray/xray run -c /etc/xray/config.json +Restart=on-failure +RestartPreventExitStatus=23 +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl enable xray >/dev/null 2>&1 || true +systemctl restart xray +sleep 1 +if ! systemctl is-active xray >/dev/null 2>&1; then + echo "ERROR: xray не запустился:"; journalctl -u xray -n 20 --no-pager || true; exit 1 +fi + +echo +echo "=== INSTALLED OK ===" +echo "uuid: $UUID" +echo "key: $KEY" +echo +echo "=== client config (client-stealth.json) ===" +cat < 0 && up < 65536 { + return nil, errors.New("BrutalUp must be at least 65536 bytes per second") + } + if down > 0 && down < 65536 { + return nil, errors.New("BrutalDown must be at least 65536 bytes per second") + } + + c.FinalMask.QuicParams.Congestion = strings.ToLower(c.FinalMask.QuicParams.Congestion) + switch c.FinalMask.QuicParams.Congestion { + case "", "brutal", "reno", "bbr": + case "force-brutal": + if up == 0 { + return nil, errors.New("force-brutal requires up") + } + default: + return nil, errors.New("unknown congestion control: ", c.FinalMask.QuicParams.Congestion, ", valid values: reno, bbr, brutal, force-brutal") + } + + if (c.FinalMask.QuicParams.UdpHop.Interval.From != 0 && c.FinalMask.QuicParams.UdpHop.Interval.From < 5) || (c.FinalMask.QuicParams.UdpHop.Interval.To != 0 && c.FinalMask.QuicParams.UdpHop.Interval.To < 5) { + return nil, errors.New("Interval must be at least 5") + } + + if c.FinalMask.QuicParams.InitStreamReceiveWindow > 0 && c.FinalMask.QuicParams.InitStreamReceiveWindow < 16384 { + return nil, errors.New("InitStreamReceiveWindow must be at least 16384") + } + if c.FinalMask.QuicParams.MaxStreamReceiveWindow > 0 && c.FinalMask.QuicParams.MaxStreamReceiveWindow < 16384 { + return nil, errors.New("MaxStreamReceiveWindow must be at least 16384") + } + if c.FinalMask.QuicParams.InitConnectionReceiveWindow > 0 && c.FinalMask.QuicParams.InitConnectionReceiveWindow < 16384 { + return nil, errors.New("InitConnectionReceiveWindow must be at least 16384") + } + if c.FinalMask.QuicParams.MaxConnectionReceiveWindow > 0 && c.FinalMask.QuicParams.MaxConnectionReceiveWindow < 16384 { + return nil, errors.New("MaxConnectionReceiveWindow must be at least 16384") + } + if c.FinalMask.QuicParams.MaxIdleTimeout != 0 && (c.FinalMask.QuicParams.MaxIdleTimeout < 4 || c.FinalMask.QuicParams.MaxIdleTimeout > 120) { + return nil, errors.New("MaxIdleTimeout must be between 4 and 120") + } + if c.FinalMask.QuicParams.KeepAlivePeriod != 0 && (c.FinalMask.QuicParams.KeepAlivePeriod < 2 || c.FinalMask.QuicParams.KeepAlivePeriod > 60) { + return nil, errors.New("KeepAlivePeriod must be between 2 and 60") + } + if c.FinalMask.QuicParams.MaxIncomingStreams != 0 && c.FinalMask.QuicParams.MaxIncomingStreams < 8 { + return nil, errors.New("MaxIncomingStreams must be at least 8") + } + + if c.FinalMask.QuicParams.Debug { + os.Setenv("HYSTERIA_BBR_DEBUG", "true") + os.Setenv("HYSTERIA_BRUTAL_DEBUG", "true") + } + + config.QuicParams = &internet.QuicParams{ + Congestion: c.FinalMask.QuicParams.Congestion, + BbrProfile: profile, + BrutalUp: up, + BrutalDown: down, + UdpHop: &internet.UdpHop{ + Ports: c.FinalMask.QuicParams.UdpHop.PortList.Build().Ports(), + IntervalMin: int64(c.FinalMask.QuicParams.UdpHop.Interval.From), + IntervalMax: int64(c.FinalMask.QuicParams.UdpHop.Interval.To), + }, + InitStreamReceiveWindow: c.FinalMask.QuicParams.InitStreamReceiveWindow, + MaxStreamReceiveWindow: c.FinalMask.QuicParams.MaxStreamReceiveWindow, + InitConnReceiveWindow: c.FinalMask.QuicParams.InitConnectionReceiveWindow, + MaxConnReceiveWindow: c.FinalMask.QuicParams.MaxConnectionReceiveWindow, + MaxIdleTimeout: c.FinalMask.QuicParams.MaxIdleTimeout, + KeepAlivePeriod: c.FinalMask.QuicParams.KeepAlivePeriod, + DisablePathMtuDiscovery: c.FinalMask.QuicParams.DisablePathMTUDiscovery, + MaxIncomingStreams: c.FinalMask.QuicParams.MaxIncomingStreams, + } + } + } + + return config, nil +} + +type ProxyConfig struct { + Tag string `json:"tag"` + + // TransportLayerProxy: For compatibility. + TransportLayerProxy bool `json:"transportLayer"` +} + +// Build implements Buildable. +func (v *ProxyConfig) Build() (*internet.ProxyConfig, error) { + if v.Tag == "" { + return nil, errors.New("Proxy tag is not set.") + } + return &internet.ProxyConfig{ + Tag: v.Tag, + TransportLayerProxy: v.TransportLayerProxy, + }, nil +} diff --git a/stealth/infra/conf/transport_method.go b/stealth/infra/conf/transport_method.go new file mode 100644 index 0000000..d6db814 --- /dev/null +++ b/stealth/infra/conf/transport_method.go @@ -0,0 +1,856 @@ +package conf + +import ( + "context" + "encoding/json" + "math/big" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/common/errors" + "github.com/xtls/xray-core/common/platform/filesystem" + "github.com/xtls/xray-core/common/serial" + "github.com/xtls/xray-core/common/utils" + "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/grpc" + "github.com/xtls/xray-core/transport/internet/headers/http" + "github.com/xtls/xray-core/transport/internet/headers/noop" + "github.com/xtls/xray-core/transport/internet/httpupgrade" + "github.com/xtls/xray-core/transport/internet/hysteria" + "github.com/xtls/xray-core/transport/internet/kcp" + "github.com/xtls/xray-core/transport/internet/splithttp" + "github.com/xtls/xray-core/transport/internet/stealth" + "github.com/xtls/xray-core/transport/internet/tcp" + "github.com/xtls/xray-core/transport/internet/websocket" + "google.golang.org/protobuf/proto" +) + +type NoOpConnectionAuthenticator struct{} + +func (NoOpConnectionAuthenticator) Build() (proto.Message, error) { + return new(noop.ConnectionConfig), nil +} + +type AuthenticatorRequest struct { + Version string `json:"version"` + Method string `json:"method"` + Path StringList `json:"path"` + Headers map[string]*StringList `json:"headers"` +} + +func sortMapKeys(m map[string]*StringList) []string { + var keys []string + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func (v *AuthenticatorRequest) Build() (*http.RequestConfig, error) { + config := &http.RequestConfig{ + Uri: []string{"/"}, + Header: []*http.Header{ + { + Name: "Host", + Value: []string{"www.baidu.com", "www.bing.com"}, + }, + { + Name: "User-Agent", + Value: []string{utils.ChromeUA}, + }, + { + Name: "Sec-CH-UA", + Value: []string{utils.ChromeUACH}, + }, + { + Name: "Sec-CH-UA-Mobile", + Value: []string{"?0"}, + }, + { + Name: "Sec-CH-UA-Platform", + Value: []string{"Windows"}, + }, + { + Name: "Sec-Fetch-Mode", + Value: []string{"no-cors", "cors", "same-origin"}, + }, + { + Name: "Sec-Fetch-Dest", + Value: []string{"empty"}, + }, + { + Name: "Sec-Fetch-Site", + Value: []string{"none"}, + }, + { + Name: "Sec-Fetch-User", + Value: []string{"?1"}, + }, + { + Name: "Accept-Encoding", + Value: []string{"gzip, deflate"}, + }, + { + Name: "Connection", + Value: []string{"keep-alive"}, + }, + { + Name: "Pragma", + Value: []string{"no-cache"}, + }, + }, + } + + if len(v.Version) > 0 { + config.Version = &http.Version{Value: v.Version} + } + + if len(v.Method) > 0 { + config.Method = &http.Method{Value: v.Method} + } + + if len(v.Path) > 0 { + config.Uri = append([]string(nil), v.Path...) + } + + if len(v.Headers) > 0 { + config.Header = make([]*http.Header, 0, len(v.Headers)) + headerNames := sortMapKeys(v.Headers) + for _, key := range headerNames { + value := v.Headers[key] + if value == nil { + return nil, errors.New("empty HTTP header value: " + key).AtError() + } + config.Header = append(config.Header, &http.Header{ + Name: key, + Value: append([]string(nil), (*value)...), + }) + } + } + + return config, nil +} + +type AuthenticatorResponse struct { + Version string `json:"version"` + Status string `json:"status"` + Reason string `json:"reason"` + Headers map[string]*StringList `json:"headers"` +} + +func (v *AuthenticatorResponse) Build() (*http.ResponseConfig, error) { + config := &http.ResponseConfig{ + Header: []*http.Header{ + { + Name: "Content-Type", + Value: []string{"application/octet-stream", "video/mpeg"}, + }, + { + Name: "Transfer-Encoding", + Value: []string{"chunked"}, + }, + { + Name: "Connection", + Value: []string{"keep-alive"}, + }, + { + Name: "Pragma", + Value: []string{"no-cache"}, + }, + { + Name: "Cache-Control", + Value: []string{"private", "no-cache"}, + }, + }, + } + + if len(v.Version) > 0 { + config.Version = &http.Version{Value: v.Version} + } + + if len(v.Status) > 0 || len(v.Reason) > 0 { + config.Status = &http.Status{ + Code: "200", + Reason: "OK", + } + if len(v.Status) > 0 { + config.Status.Code = v.Status + } + if len(v.Reason) > 0 { + config.Status.Reason = v.Reason + } + } + + if len(v.Headers) > 0 { + config.Header = make([]*http.Header, 0, len(v.Headers)) + headerNames := sortMapKeys(v.Headers) + for _, key := range headerNames { + value := v.Headers[key] + if value == nil { + return nil, errors.New("empty HTTP header value: " + key).AtError() + } + config.Header = append(config.Header, &http.Header{ + Name: key, + Value: append([]string(nil), (*value)...), + }) + } + } + + return config, nil +} + +type Authenticator struct { + Request AuthenticatorRequest `json:"request"` + Response AuthenticatorResponse `json:"response"` +} + +func (v *Authenticator) Build() (proto.Message, error) { + config := new(http.Config) + requestConfig, err := v.Request.Build() + if err != nil { + return nil, err + } + config.Request = requestConfig + + responseConfig, err := v.Response.Build() + if err != nil { + return nil, err + } + config.Response = responseConfig + + return config, nil +} + +var tcpHeaderLoader = NewJSONConfigLoader(ConfigCreatorCache{ + "none": func() interface{} { return new(NoOpConnectionAuthenticator) }, + "http": func() interface{} { return new(Authenticator) }, +}, "type", "") + +type TCPConfig struct { + HeaderConfig json.RawMessage `json:"header"` + AcceptProxyProtocol bool `json:"acceptProxyProtocol"` +} + +// Build implements Buildable. +func (c *TCPConfig) Build() (proto.Message, error) { + config := new(tcp.Config) + if len(c.HeaderConfig) > 0 { + headerConfig, _, err := tcpHeaderLoader.Load(c.HeaderConfig) + if err != nil { + return nil, errors.New("invalid TCP header config").Base(err).AtError() + } + ts, err := headerConfig.(Buildable).Build() + if err != nil { + return nil, errors.New("invalid TCP header config").Base(err).AtError() + } + config.HeaderSettings = serial.ToTypedMessage(ts) + } + if c.AcceptProxyProtocol { + config.AcceptProxyProtocol = c.AcceptProxyProtocol + } + return config, nil +} + +type SplitHTTPConfig struct { + Host string `json:"host"` + Path string `json:"path"` + Mode string `json:"mode"` + Headers map[string]string `json:"headers"` + XPaddingBytes Int32Range `json:"xPaddingBytes"` + XPaddingObfsMode bool `json:"xPaddingObfsMode"` + XPaddingKey string `json:"xPaddingKey"` + XPaddingHeader string `json:"xPaddingHeader"` + XPaddingPlacement string `json:"xPaddingPlacement"` + XPaddingMethod string `json:"xPaddingMethod"` + UplinkHTTPMethod string `json:"uplinkHTTPMethod"` + SessionIDPlacement string `json:"sessionIDPlacement"` + SessionIDKey string `json:"sessionIDKey"` + SessionIDTable string `json:"sessionIDTable"` + SessionIDLength Int32Range `json:"sessionIDLength"` + SeqPlacement string `json:"seqPlacement"` + SeqKey string `json:"seqKey"` + UplinkDataPlacement string `json:"uplinkDataPlacement"` + UplinkDataKey string `json:"uplinkDataKey"` + UplinkChunkSize Int32Range `json:"uplinkChunkSize"` + NoGRPCHeader bool `json:"noGRPCHeader"` + NoSSEHeader bool `json:"noSSEHeader"` + ScMaxEachPostBytes Int32Range `json:"scMaxEachPostBytes"` + ScMinPostsIntervalMs Int32Range `json:"scMinPostsIntervalMs"` + ScMaxBufferedPosts int64 `json:"scMaxBufferedPosts"` + ScStreamUpServerSecs Int32Range `json:"scStreamUpServerSecs"` + ServerMaxHeaderBytes int32 `json:"serverMaxHeaderBytes"` + Xmux XmuxConfig `json:"xmux"` + DownloadSettings *StreamConfig `json:"downloadSettings"` + Extra json.RawMessage `json:"extra"` +} + +type XmuxConfig struct { + MaxConcurrency Int32Range `json:"maxConcurrency"` + MaxConnections Int32Range `json:"maxConnections"` + CMaxReuseTimes Int32Range `json:"cMaxReuseTimes"` + HMaxRequestTimes Int32Range `json:"hMaxRequestTimes"` + HMaxReusableSecs Int32Range `json:"hMaxReusableSecs"` + HKeepAlivePeriod int64 `json:"hKeepAlivePeriod"` +} + +func newRangeConfig(input Int32Range) *splithttp.RangeConfig { + return &splithttp.RangeConfig{ + From: input.From, + To: input.To, + } +} + +// Build implements Buildable. +func (c *SplitHTTPConfig) Build() (proto.Message, error) { + if c.Extra != nil { + var extra SplitHTTPConfig + if err := json.Unmarshal(c.Extra, &extra); err != nil { + return nil, errors.New(`Failed to unmarshal "extra".`).Base(err) + } + extra.Host = c.Host + extra.Path = c.Path + extra.Mode = c.Mode + c = &extra + } + + switch c.Mode { + case "": + c.Mode = "auto" + case "auto", "packet-up", "stream-up", "stream-one": + default: + return nil, errors.New("unsupported mode: " + c.Mode) + } + + // Priority (client): host > serverName > address + for k := range c.Headers { + if strings.ToLower(k) == "host" { + return nil, errors.New(`"headers" can't contain "host"`) + } + } + + if c.XPaddingBytes != (Int32Range{}) && (c.XPaddingBytes.From <= 0 || c.XPaddingBytes.To <= 0) { + return nil, errors.New("xPaddingBytes cannot be disabled") + } + + if c.XPaddingKey == "" { + c.XPaddingKey = "x_padding" + } + + if c.XPaddingHeader == "" { + c.XPaddingHeader = "X-Padding" + } + + switch c.XPaddingPlacement { + case "": + c.XPaddingPlacement = "queryInHeader" + case "cookie", "header", "query", "queryInHeader": + default: + return nil, errors.New("unsupported padding placement: " + c.XPaddingPlacement) + } + + switch c.XPaddingMethod { + case "": + c.XPaddingMethod = "repeat-x" + case "repeat-x", "tokenish": + default: + return nil, errors.New("unsupported padding method: " + c.XPaddingMethod) + } + + switch c.UplinkDataPlacement { + case "": + c.UplinkDataPlacement = splithttp.PlacementAuto + case splithttp.PlacementAuto, splithttp.PlacementBody: + case splithttp.PlacementCookie, splithttp.PlacementHeader: + if c.Mode != "packet-up" { + return nil, errors.New("UplinkDataPlacement can be " + c.UplinkDataPlacement + " only in packet-up mode") + } + default: + return nil, errors.New("unsupported uplink data placement: " + c.UplinkDataPlacement) + } + + if c.UplinkHTTPMethod == "" { + c.UplinkHTTPMethod = "POST" + } + c.UplinkHTTPMethod = strings.ToUpper(c.UplinkHTTPMethod) + + if c.UplinkHTTPMethod == "GET" && c.Mode != "packet-up" { + return nil, errors.New("uplinkHTTPMethod can be GET only in packet-up mode") + } + + switch c.SessionIDPlacement { + case "": + c.SessionIDPlacement = "path" + case "path", "cookie", "header", "query": + default: + return nil, errors.New("unsupported session placement: " + c.SessionIDPlacement) + } + + switch c.SeqPlacement { + case "": + c.SeqPlacement = "path" + case "path", "cookie", "header", "query": + default: + return nil, errors.New("unsupported seq placement: " + c.SeqPlacement) + } + + if c.SessionIDPlacement != "path" && c.SessionIDKey == "" { + switch c.SessionIDPlacement { + case "cookie", "query": + c.SessionIDKey = "x_session" + case "header": + c.SessionIDKey = "X-Session" + } + } + + if c.SessionIDTable != "" { + if predefined, ok := splithttp.PredefinedTable[c.SessionIDTable]; ok { + c.SessionIDTable = predefined + } + room := roomSize(len(c.SessionIDTable), c.SessionIDLength.From, c.SessionIDLength.To) + // 2.1B possiblities should be enough + if room.Cmp(big.NewInt(2<<30)) < 0 { + return nil, errors.New("sessionIDTable or sessionIDLength is too small") + } + if c.SessionIDLength.From <= 0 { + return nil, errors.New("sessionIDLength.from must be greater than 0") + } + for i := 0; i < len(c.SessionIDTable); i++ { + if c.SessionIDTable[i] >= 0x80 { + return nil, errors.New("sessionIDTable must contain only ASCII characters") + } + } + } + + if c.SeqPlacement != "path" && c.SeqKey == "" { + switch c.SeqPlacement { + case "cookie", "query": + c.SeqKey = "x_seq" + case "header": + c.SeqKey = "X-Seq" + } + } + + if c.UplinkDataPlacement != splithttp.PlacementBody && c.UplinkDataKey == "" { + switch c.UplinkDataPlacement { + case splithttp.PlacementCookie: + c.UplinkDataKey = "x_data" + case splithttp.PlacementAuto, splithttp.PlacementHeader: + c.UplinkDataKey = "X-Data" + } + } + + if c.ServerMaxHeaderBytes < 0 { + return nil, errors.New("invalid negative value of maxHeaderBytes") + } + + if c.Xmux.MaxConnections.To > 0 && c.Xmux.MaxConcurrency.To > 0 { + return nil, errors.New("maxConnections cannot be specified together with maxConcurrency") + } + if c.Xmux == (XmuxConfig{}) { + c.Xmux.MaxConnections.From = 3 + c.Xmux.MaxConnections.To = 3 + c.Xmux.HMaxRequestTimes.From = 600 + c.Xmux.HMaxRequestTimes.To = 900 + c.Xmux.HMaxReusableSecs.From = 1800 + c.Xmux.HMaxReusableSecs.To = 3000 + } + + config := &splithttp.Config{ + Host: c.Host, + Path: c.Path, + Mode: c.Mode, + Headers: c.Headers, + XPaddingBytes: newRangeConfig(c.XPaddingBytes), + XPaddingObfsMode: c.XPaddingObfsMode, + XPaddingKey: c.XPaddingKey, + XPaddingHeader: c.XPaddingHeader, + XPaddingPlacement: c.XPaddingPlacement, + XPaddingMethod: c.XPaddingMethod, + UplinkHTTPMethod: c.UplinkHTTPMethod, + SessionIDPlacement: c.SessionIDPlacement, + SeqPlacement: c.SeqPlacement, + SessionIDKey: c.SessionIDKey, + SeqKey: c.SeqKey, + UplinkDataPlacement: c.UplinkDataPlacement, + UplinkDataKey: c.UplinkDataKey, + UplinkChunkSize: newRangeConfig(c.UplinkChunkSize), + NoGRPCHeader: c.NoGRPCHeader, + NoSSEHeader: c.NoSSEHeader, + ScMaxEachPostBytes: newRangeConfig(c.ScMaxEachPostBytes), + ScMinPostsIntervalMs: newRangeConfig(c.ScMinPostsIntervalMs), + ScMaxBufferedPosts: c.ScMaxBufferedPosts, + ScStreamUpServerSecs: newRangeConfig(c.ScStreamUpServerSecs), + ServerMaxHeaderBytes: c.ServerMaxHeaderBytes, + SessionIDTable: c.SessionIDTable, + SessionIDLength: newRangeConfig(c.SessionIDLength), + Xmux: &splithttp.XmuxConfig{ + MaxConcurrency: newRangeConfig(c.Xmux.MaxConcurrency), + MaxConnections: newRangeConfig(c.Xmux.MaxConnections), + CMaxReuseTimes: newRangeConfig(c.Xmux.CMaxReuseTimes), + HMaxRequestTimes: newRangeConfig(c.Xmux.HMaxRequestTimes), + HMaxReusableSecs: newRangeConfig(c.Xmux.HMaxReusableSecs), + HKeepAlivePeriod: c.Xmux.HKeepAlivePeriod, + }, + } + + if c.DownloadSettings != nil { + if c.Mode == "stream-one" { + return nil, errors.New(`Can not use "downloadSettings" in "stream-one" mode.`) + } + var err error + if config.DownloadSettings, err = c.DownloadSettings.Build(); err != nil { + return nil, errors.New(`Failed to build "downloadSettings".`).Base(err) + } + } + + return config, nil +} + +func roomSize(tableSize int, min, max int32) *big.Int { + base := big.NewInt(int64(tableSize)) + sum := new(big.Int) + term := new(big.Int) + for k := min; k <= max; k++ { + term.Exp(base, big.NewInt(int64(k)), nil) + sum.Add(sum, term) + } + return sum +} + +type KCPConfig struct { + Mtu *uint32 `json:"mtu"` + Tti *uint32 `json:"tti"` + UpCap *uint32 `json:"uplinkCapacity"` + DownCap *uint32 `json:"downlinkCapacity"` + CwndMultiplier *uint32 `json:"cwndMultiplier"` + MaxSendingWindow *uint32 `json:"maxSendingWindow"` + + HeaderConfig json.RawMessage `json:"header"` + Seed *string `json:"seed"` +} + +// Build implements Buildable. +func (c *KCPConfig) Build() (proto.Message, error) { + if c.HeaderConfig != nil || c.Seed != nil { + return nil, errors.PrintRemovedFeatureError("mkcp header & seed", "finalmask/udp header-* & mkcp-original & mkcp-aes128gcm") + } + + config := common.Must2(internet.CreateTransportConfig(kcp.ProtocolName)).(*kcp.Config) + + if c.Mtu != nil { + config.Mtu = *c.Mtu + } + if c.Tti != nil { + config.Tti = *c.Tti + } + if c.UpCap != nil { + config.UplinkCapacity = *c.UpCap + } + if c.DownCap != nil { + config.DownlinkCapacity = *c.DownCap + } + if c.CwndMultiplier != nil { + config.CwndMultiplier = *c.CwndMultiplier + } + if c.MaxSendingWindow != nil { + config.MaxSendingWindow = *c.MaxSendingWindow + } + + if config.Mtu < 21 { + return nil, errors.New("Mtu must be at least 21").AtError() + } + if config.Tti < 10 || config.Tti > 1000 { + return nil, errors.New("invalid mKCP TTI: ", c.Tti).AtError() + } + if config.CwndMultiplier < 1 { + return nil, errors.New("CwndMultiplier must be at least 1").AtError() + } + if config.GetSendingBufferSize() == 0 { + return nil, errors.New("MaxSendingWindow must be >= Mtu").AtError() + } + + return config, nil +} + +type GRPCConfig struct { + Authority string `json:"authority"` + ServiceName string `json:"serviceName"` + MultiMode bool `json:"multiMode"` + IdleTimeout int32 `json:"idle_timeout"` + HealthCheckTimeout int32 `json:"health_check_timeout"` + PermitWithoutStream bool `json:"permit_without_stream"` + InitialWindowsSize int32 `json:"initial_windows_size"` + UserAgent string `json:"user_agent"` +} + +func (g *GRPCConfig) Build() (proto.Message, error) { + if g.IdleTimeout <= 0 { + g.IdleTimeout = 0 + } + if g.HealthCheckTimeout <= 0 { + g.HealthCheckTimeout = 0 + } + if g.InitialWindowsSize < 0 { + // default window size of gRPC-go + g.InitialWindowsSize = 0 + } + + return &grpc.Config{ + Authority: g.Authority, + ServiceName: g.ServiceName, + MultiMode: g.MultiMode, + IdleTimeout: g.IdleTimeout, + HealthCheckTimeout: g.HealthCheckTimeout, + PermitWithoutStream: g.PermitWithoutStream, + InitialWindowsSize: g.InitialWindowsSize, + UserAgent: g.UserAgent, + }, nil +} + +type WebSocketConfig struct { + Host string `json:"host"` + Path string `json:"path"` + Headers map[string]string `json:"headers"` + AcceptProxyProtocol bool `json:"acceptProxyProtocol"` + HeartbeatPeriod uint32 `json:"heartbeatPeriod"` +} + +// Build implements Buildable. +func (c *WebSocketConfig) Build() (proto.Message, error) { + path := c.Path + var ed uint32 + if u, err := url.Parse(path); err == nil { + if q := u.Query(); q.Get("ed") != "" { + Ed, _ := strconv.Atoi(q.Get("ed")) + ed = uint32(Ed) + q.Del("ed") + u.RawQuery = q.Encode() + path = u.String() + } + } + // Priority (client): host > serverName > address + for k, v := range c.Headers { + if strings.ToLower(k) == "host" { + errors.PrintDeprecatedFeatureWarning(`"host" in "headers"`, `independent "host"`) + if c.Host == "" { + c.Host = v + } + delete(c.Headers, k) + } + } + config := &websocket.Config{ + Path: path, + Host: c.Host, + Header: c.Headers, + AcceptProxyProtocol: c.AcceptProxyProtocol, + Ed: ed, + HeartbeatPeriod: c.HeartbeatPeriod, + } + return config, nil +} + +type HttpUpgradeConfig struct { + Host string `json:"host"` + Path string `json:"path"` + Headers map[string]string `json:"headers"` + AcceptProxyProtocol bool `json:"acceptProxyProtocol"` +} + +// Build implements Buildable. +func (c *HttpUpgradeConfig) Build() (proto.Message, error) { + path := c.Path + var ed uint32 + if u, err := url.Parse(path); err == nil { + if q := u.Query(); q.Get("ed") != "" { + Ed, _ := strconv.Atoi(q.Get("ed")) + ed = uint32(Ed) + q.Del("ed") + u.RawQuery = q.Encode() + path = u.String() + } + } + // Priority (client): host > serverName > address + for k := range c.Headers { + if strings.ToLower(k) == "host" { + return nil, errors.New(`"headers" can't contain "host"`) + } + } + config := &httpupgrade.Config{ + Path: path, + Host: c.Host, + Header: c.Headers, + AcceptProxyProtocol: c.AcceptProxyProtocol, + Ed: ed, + } + return config, nil +} + +const ( + Byte = 1 + Kilobyte = 1024 * Byte + Megabyte = 1024 * Kilobyte + Gigabyte = 1024 * Megabyte + Terabyte = 1024 * Gigabyte +) + +type Bandwidth string + +func (b Bandwidth) Bps() (uint64, error) { + s := strings.TrimSpace(strings.ToLower(string(b))) + if s == "" { + return 0, nil + } + + idx := len(s) + for i, c := range s { + if (c < '0' || c > '9') && c != '.' { + idx = i + break + } + } + + numStr := s[:idx] + unit := strings.TrimSpace(s[idx:]) + + val, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, err + } + + mul := uint64(1) + switch unit { + case "", "b", "bps": + mul = Byte + case "k", "kb", "kbps": + mul = Kilobyte + case "m", "mb", "mbps": + mul = Megabyte + case "g", "gb", "gbps": + mul = Gigabyte + case "t", "tb", "tbps": + mul = Terabyte + default: + return 0, errors.New("unsupported unit: " + unit) + } + + return uint64(val*float64(mul)) / 8, nil +} + +type UdpHop struct { + PortList PortList `json:"ports"` + Interval Int32Range `json:"interval"` +} + +type Masquerade struct { + Type string `json:"type"` + + Dir string `json:"dir"` + + Url string `json:"url"` + RewriteHost bool `json:"rewriteHost"` + Insecure bool `json:"insecure"` + + Content string `json:"content"` + Headers map[string]string `json:"headers"` + StatusCode int32 `json:"statusCode"` +} + +type HysteriaConfig struct { + Version int32 `json:"version"` + Auth string `json:"auth"` + + Congestion *string `json:"congestion"` + Up *Bandwidth `json:"up"` + Down *Bandwidth `json:"down"` + UdpHop *UdpHop `json:"udphop"` + + UdpIdleTimeout int64 `json:"udpIdleTimeout"` + Masquerade Masquerade `json:"masquerade"` +} + +func (c *HysteriaConfig) Build() (proto.Message, error) { + if c.Version != 2 { + return nil, errors.New("version != 2") + } + + if c.Congestion != nil || c.Up != nil || c.Down != nil || c.UdpHop != nil { + errors.LogWarning(context.Background(), "congestion & up & down & udphop move to finalmask/quicParams") + } + + if c.UdpIdleTimeout != 0 && (c.UdpIdleTimeout < 2 || c.UdpIdleTimeout > 600) { + return nil, errors.New("UdpIdleTimeout must be between 2 and 600") + } + + config := &hysteria.Config{} + config.Auth = c.Auth + config.UdpIdleTimeout = c.UdpIdleTimeout + config.MasqType = c.Masquerade.Type + config.MasqFile = c.Masquerade.Dir + config.MasqUrl = c.Masquerade.Url + config.MasqUrlRewriteHost = c.Masquerade.RewriteHost + config.MasqUrlInsecure = c.Masquerade.Insecure + config.MasqString = c.Masquerade.Content + config.MasqStringHeaders = c.Masquerade.Headers + config.MasqStringStatusCode = c.Masquerade.StatusCode + + if config.UdpIdleTimeout == 0 { + config.UdpIdleTimeout = 60 + } + + return config, nil +} + +func readFileOrString(f string, s []string) ([]byte, error) { + if len(f) > 0 { + return filesystem.ReadCert(f) + } + if len(s) > 0 { + return []byte(strings.Join(s, "\n")), nil + } + return nil, errors.New("both file and bytes are empty.") +} + + +// StealthConfig configures the stealth transport. +type StealthConfig struct { + Key string `json:"key"` + PadMax int64 `json:"padMax"` + PadProb int64 `json:"padProb"` + PaceMs int64 `json:"paceMs"` + PaceJitterMs int64 `json:"paceJitterMs"` + Path string `json:"path"` + Paths []string `json:"paths"` + Ports []uint32 `json:"ports"` + Bps int64 `json:"bps"` + Target string `json:"target"` + Decoy bool `json:"decoy"` + DecoyEveryS int64 `json:"decoyEverySecs"` + DecoyTarget string `json:"decoyTarget"` + PadProfile string `json:"padProfile"` + Retries int64 `json:"retries"` +} + +// Build implements Buildable. +func (c *StealthConfig) Build() (proto.Message, error) { + return &stealth.Config{ + Key: c.Key, + PadMax: c.PadMax, + PadProb: c.PadProb, + PaceMs: c.PaceMs, + PaceJitterMs: c.PaceJitterMs, + Path: c.Path, + Paths: c.Paths, + Ports: c.Ports, + Bps: c.Bps, + Target: c.Target, + Decoy: c.Decoy, + DecoyTarget: c.DecoyTarget, + DecoyEverySecs: c.DecoyEveryS, + PadProfile: c.PadProfile, + Retries: c.Retries, + }, nil +} diff --git a/stealth/transport/internet/stealth/config.go b/stealth/transport/internet/stealth/config.go new file mode 100644 index 0000000..396a04a --- /dev/null +++ b/stealth/transport/internet/stealth/config.go @@ -0,0 +1,175 @@ +package stealth + +import ( + "net/url" + "strings" + "time" + + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/transport/internet" +) + +const protocolName = "stealth" + +const authOK = "ok" + +const chromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" + +func init() { + common.Must(internet.RegisterProtocolConfigCreator(protocolName, func() interface{} { + return new(Config) + })) +} + + +func (c *Config) GetNormalizedPaths() []string { + if len(c.Paths) > 0 { + out := make([]string, 0, len(c.Paths)) + for _, p := range c.Paths { + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + out = append(out, p) + } + return out + } + return []string{c.GetNormalizedPath()} +} + +func (c *Config) NormalizedPadProb() int { + if c.PadProb <= 0 { + return 50 + } + if c.PadProb > 100 { + return 100 + } + return int(c.PadProb) +} + + +func (c *Config) GetNormalizedPorts() []uint32 { + if len(c.Ports) == 0 { + return nil + } + return c.Ports +} + +func (c *Config) NormalizedBps() int { + if c.Bps <= 0 { + return 0 + } + if c.Bps > 1<<30 { + return 1 << 30 + } + return int(c.Bps) +} + + + +func (c *Config) DecoyEnabled() bool { + return c.Decoy +} + +func (c *Config) DecoyEvery() time.Duration { + n := c.DecoyEverySecs + if n <= 0 { + n = 60 + } + if n > 86400 { + n = 86400 + } + return time.Duration(n) * time.Second +} + +func (c *Config) NormalizedDecoyTarget() string { + t := strings.TrimSpace(c.DecoyTarget) + if t == "" { + return "https://www.cloudflare.com/cdn-cgi/trace" + } + if !strings.Contains(t, "://") { + t = "https://" + t + } + return t +} + +func (c *Config) GetNormalizedTarget() *url.URL { + t := strings.TrimSpace(c.Target) + if t == "" { + return nil + } + if !strings.Contains(t, "://") { + t = "https://" + t + } + u, err := url.Parse(t) + if err != nil || u.Host == "" { + return nil + } + return u +} + +func (c *Config) GetNormalizedPath() string { + path := c.Path + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + + + +func (c *Config) GetNormalizedRetries() int { + n := c.Retries + if n == 0 { + return 3 + } + if n < 0 { + return 0 + } + if n > 10 { + return 10 + } + return int(n) +} + +func (c *Config) NormalizedPadProfile() string { + switch c.PadProfile { + case "uniform": + return "uniform" + default: + return "browser" + } +} + +func (c *Config) NormalizedPadMax() int { + if c.PadMax <= 0 { + return 0 + } + if c.PadMax > 4096 { + return 4096 + } + return int(c.PadMax) +} + + +func (c *Config) NormalizedPaceJitterMs() time.Duration { + if c.PaceJitterMs <= 0 { + return 0 + } + if c.PaceJitterMs > 5000 { + return 5000 + } + return time.Duration(c.PaceJitterMs) * time.Millisecond +} + +func (c *Config) NormalizedPaceMs() time.Duration { + if c.PaceMs <= 0 { + return 0 + } + if c.PaceMs > 5000 { + return 5000 + } + return time.Duration(c.PaceMs) * time.Millisecond +} diff --git a/stealth/transport/internet/stealth/config.pb.go b/stealth/transport/internet/stealth/config.pb.go new file mode 100644 index 0000000..289aa60 --- /dev/null +++ b/stealth/transport/internet/stealth/config.pb.go @@ -0,0 +1,268 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v3.21.12 +// source: transport/internet/stealth/config.proto + +package stealth + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Shared auth secret (both sides must match). + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // Max random padding bytes appended after each data frame (0 = disabled). + PadMax int64 `protobuf:"varint,2,opt,name=pad_max,json=padMax,proto3" json:"pad_max,omitempty"` + // Minimum interval between frame writes in ms (0 = disabled). + PaceMs int64 `protobuf:"varint,3,opt,name=pace_ms,json=paceMs,proto3" json:"pace_ms,omitempty"` + // HTTP/2 request path (default "/"). + Path string `protobuf:"bytes,4,opt,name=path,proto3" json:"path,omitempty"` + // Max random extra delay added to pacing (ms, 0 = disabled). + PaceJitterMs int64 `protobuf:"varint,5,opt,name=pace_jitter_ms,json=paceJitterMs,proto3" json:"pace_jitter_ms,omitempty"` + // Multiple tunnel paths for identity rotation; falls back to [path]. + Paths []string `protobuf:"bytes,6,rep,name=paths,proto3" json:"paths,omitempty"` + // Percent chance (0-100) to append a PAD frame after a data frame (default 50). + PadProb int64 `protobuf:"varint,7,opt,name=pad_prob,json=padProb,proto3" json:"pad_prob,omitempty"` + // Candidate ports for identity rotation (client picks one per connection). + // Overrides the outbound port; the server must listen on these ports. + Ports []uint32 `protobuf:"varint,8,rep,packed,name=ports,proto3" json:"ports,omitempty"` + // Optional upload rate limit in bytes/sec (0 = unlimited). + Bps int64 `protobuf:"varint,9,opt,name=bps,proto3" json:"bps,omitempty"` + // Reverse-proxy target for non-tunnel requests (a real website to mirror). + // Empty = serve the built-in landing page only. + Target string `protobuf:"bytes,10,opt,name=target,proto3" json:"target,omitempty"` + // Client-side decoy: periodically make a real request to a benign CDN + // (direct, not through the tunnel) to mask aggregated activity. Default off. + Decoy bool `protobuf:"varint,11,opt,name=decoy,proto3" json:"decoy,omitempty"` + // Decoy interval in seconds (default 60). + DecoyEverySecs int64 `protobuf:"varint,12,opt,name=decoy_every_secs,json=decoyEverySecs,proto3" json:"decoy_every_secs,omitempty"` + // Decoy target URL (default Cloudflare trace endpoint). + DecoyTarget string `protobuf:"bytes,13,opt,name=decoy_target,json=decoyTarget,proto3" json:"decoy_target,omitempty"` + // Padding profile: "browser" (default, biased toward small pads) or "uniform". + PadProfile string `protobuf:"bytes,14,opt,name=pad_profile,json=padProfile,proto3" json:"pad_profile,omitempty"` + // Dial retries with a fresh identity (new path/port) on failure (default 3). + Retries int64 `protobuf:"varint,15,opt,name=retries,proto3" json:"retries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_transport_internet_stealth_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_stealth_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_transport_internet_stealth_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *Config) GetPadMax() int64 { + if x != nil { + return x.PadMax + } + return 0 +} + +func (x *Config) GetPaceMs() int64 { + if x != nil { + return x.PaceMs + } + return 0 +} + +func (x *Config) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *Config) GetPaceJitterMs() int64 { + if x != nil { + return x.PaceJitterMs + } + return 0 +} + +func (x *Config) GetPaths() []string { + if x != nil { + return x.Paths + } + return nil +} + +func (x *Config) GetPadProb() int64 { + if x != nil { + return x.PadProb + } + return 0 +} + +func (x *Config) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *Config) GetBps() int64 { + if x != nil { + return x.Bps + } + return 0 +} + +func (x *Config) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *Config) GetDecoy() bool { + if x != nil { + return x.Decoy + } + return false +} + +func (x *Config) GetDecoyEverySecs() int64 { + if x != nil { + return x.DecoyEverySecs + } + return 0 +} + +func (x *Config) GetDecoyTarget() string { + if x != nil { + return x.DecoyTarget + } + return "" +} + +func (x *Config) GetPadProfile() string { + if x != nil { + return x.PadProfile + } + return "" +} + +func (x *Config) GetRetries() int64 { + if x != nil { + return x.Retries + } + return 0 +} + +var File_transport_internet_stealth_config_proto protoreflect.FileDescriptor + +const file_transport_internet_stealth_config_proto_rawDesc = "" + + "\n" + + "'transport/internet/stealth/config.proto\x12\x1fxray.transport.internet.stealth\"\x95\x03\n" + + "\x06Config\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x17\n" + + "\apad_max\x18\x02 \x01(\x03R\x06padMax\x12\x17\n" + + "\apace_ms\x18\x03 \x01(\x03R\x06paceMs\x12\x12\n" + + "\x04path\x18\x04 \x01(\tR\x04path\x12$\n" + + "\x0epace_jitter_ms\x18\x05 \x01(\x03R\fpaceJitterMs\x12\x14\n" + + "\x05paths\x18\x06 \x03(\tR\x05paths\x12\x19\n" + + "\bpad_prob\x18\a \x01(\x03R\apadProb\x12\x14\n" + + "\x05ports\x18\b \x03(\rR\x05ports\x12\x10\n" + + "\x03bps\x18\t \x01(\x03R\x03bps\x12\x16\n" + + "\x06target\x18\n" + + " \x01(\tR\x06target\x12\x14\n" + + "\x05decoy\x18\v \x01(\bR\x05decoy\x12(\n" + + "\x10decoy_every_secs\x18\f \x01(\x03R\x0edecoyEverySecs\x12!\n" + + "\fdecoy_target\x18\r \x01(\tR\vdecoyTarget\x12\x1f\n" + + "\vpad_profile\x18\x0e \x01(\tR\n" + + "padProfile\x12\x18\n" + + "\aretries\x18\x0f \x01(\x03R\aretriesB6Z4github.com/xtls/xray-core/transport/internet/stealthb\x06proto3" + +var ( + file_transport_internet_stealth_config_proto_rawDescOnce sync.Once + file_transport_internet_stealth_config_proto_rawDescData []byte +) + +func file_transport_internet_stealth_config_proto_rawDescGZIP() []byte { + file_transport_internet_stealth_config_proto_rawDescOnce.Do(func() { + file_transport_internet_stealth_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_internet_stealth_config_proto_rawDesc), len(file_transport_internet_stealth_config_proto_rawDesc))) + }) + return file_transport_internet_stealth_config_proto_rawDescData +} + +var file_transport_internet_stealth_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_transport_internet_stealth_config_proto_goTypes = []any{ + (*Config)(nil), // 0: xray.transport.internet.stealth.Config +} +var file_transport_internet_stealth_config_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_transport_internet_stealth_config_proto_init() } +func file_transport_internet_stealth_config_proto_init() { + if File_transport_internet_stealth_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_stealth_config_proto_rawDesc), len(file_transport_internet_stealth_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_transport_internet_stealth_config_proto_goTypes, + DependencyIndexes: file_transport_internet_stealth_config_proto_depIdxs, + MessageInfos: file_transport_internet_stealth_config_proto_msgTypes, + }.Build() + File_transport_internet_stealth_config_proto = out.File + file_transport_internet_stealth_config_proto_goTypes = nil + file_transport_internet_stealth_config_proto_depIdxs = nil +} diff --git a/stealth/transport/internet/stealth/config.proto b/stealth/transport/internet/stealth/config.proto new file mode 100644 index 0000000..e367189 --- /dev/null +++ b/stealth/transport/internet/stealth/config.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package xray.transport.internet.stealth; +option go_package = "github.com/xtls/xray-core/transport/internet/stealth"; + +message Config { + // Shared auth secret (both sides must match). + string key = 1; + // Max random padding bytes appended after each data frame (0 = disabled). + int64 pad_max = 2; + // Minimum interval between frame writes in ms (0 = disabled). + int64 pace_ms = 3; + // HTTP/2 request path (default "/"). + string path = 4; + // Max random extra delay added to pacing (ms, 0 = disabled). + int64 pace_jitter_ms = 5; + // Multiple tunnel paths for identity rotation; falls back to [path]. + repeated string paths = 6; + // Percent chance (0-100) to append a PAD frame after a data frame (default 50). + int64 pad_prob = 7; + // Candidate ports for identity rotation (client picks one per connection). + // Overrides the outbound port; the server must listen on these ports. + repeated uint32 ports = 8; + // Optional upload rate limit in bytes/sec (0 = unlimited). + int64 bps = 9; + // Reverse-proxy target for non-tunnel requests (a real website to mirror). + // Empty = serve the built-in landing page only. + string target = 10; + // Client-side decoy: periodically make a real request to a benign CDN + // (direct, not through the tunnel) to mask aggregated activity. Default off. + bool decoy = 11; + // Decoy interval in seconds (default 60). + int64 decoy_every_secs = 12; + // Decoy target URL (default Cloudflare trace endpoint). + string decoy_target = 13; + // Padding profile: "browser" (default, biased toward small pads) or "uniform". + string pad_profile = 14; + // Dial retries with a fresh identity (new path/port) on failure (default 3). + int64 retries = 15; +} diff --git a/stealth/transport/internet/stealth/conn.go b/stealth/transport/internet/stealth/conn.go new file mode 100644 index 0000000..88fb99d --- /dev/null +++ b/stealth/transport/internet/stealth/conn.go @@ -0,0 +1,160 @@ +package stealth + +import ( + "context" + "crypto/rand" + "io" + mathrand "math/rand" + "net" + "sync" + "time" + + "golang.org/x/time/rate" +) + +type h2Conn struct { + reader io.Reader + writer io.Writer + cancel context.CancelFunc + flush func() + done chan struct{} + padMax int + padProb int + padUni bool + paceMs time.Duration + paceJit time.Duration + limiter *rate.Limiter + mu sync.Mutex + lastWrite time.Time + pending []byte + localAddr net.Addr + remote net.Addr +} + +func newH2Conn(reader io.Reader, writer io.Writer, cancel context.CancelFunc, flush func(), cfg *Config, local, remote net.Addr) *h2Conn { + return &h2Conn{ + reader: reader, + writer: writer, + cancel: cancel, + flush: flush, + done: make(chan struct{}), + padMax: cfg.NormalizedPadMax(), + padProb: cfg.NormalizedPadProb(), + padUni: cfg.NormalizedPadProfile() == "uniform", + paceMs: cfg.NormalizedPaceMs(), + paceJit: cfg.NormalizedPaceJitterMs(), + limiter: newLimiter(cfg), + localAddr: local, + remote: remote, + } +} + +func newLimiter(cfg *Config) *rate.Limiter { + if bps := cfg.NormalizedBps(); bps > 0 { + return rate.NewLimiter(rate.Limit(bps), bps) + } + return nil +} + +// padSize returns a browser-like (mostly small, occasional large) or uniform size. +func (c *h2Conn) padSize() int { + if c.padMax <= 0 { + return 0 + } + if c.padUni { + return mathrand.Intn(c.padMax + 1) + } + if mathrand.Intn(100) < 80 { + return mathrand.Intn(c.padMax/8 + 1) + } + return mathrand.Intn(c.padMax + 1) +} + +func (c *h2Conn) Read(p []byte) (int, error) { + for { + if len(c.pending) > 0 { + n := copy(p, c.pending) + c.pending = c.pending[n:] + return n, nil + } + ftype, payload, err := readFrame(c.reader) + if err != nil { + return 0, err + } + switch ftype { + case frameTypeData: + n := copy(p, payload) + if n < len(payload) { + c.pending = append(c.pending[:0], payload[n:]...) + } + return n, nil + case frameTypeClose: + return 0, io.EOF + default: + // padding and unknown frames are skipped + } + } +} + +func (c *h2Conn) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + select { + case <-c.done: + return 0, net.ErrClosed + default: + } + if c.limiter != nil { + if err := c.limiter.WaitN(context.Background(), len(p)); err != nil { + return 0, err + } + } + if c.paceMs > 0 { + d := c.lastWrite.Add(c.paceMs).Sub(time.Now()) + if c.paceJit > 0 { + d += time.Duration(mathrand.Int63n(int64(c.paceJit))) + } + if d > 0 { + time.Sleep(d) + } + } + if err := writeFrame(c.writer, frameTypeData, p); err != nil { + return 0, err + } + if c.padMax > 0 && mathrand.Intn(100) < c.padProb { + pad := make([]byte, c.padSize()) + _, _ = rand.Read(pad) + if err := writeFrame(c.writer, frameTypePad, pad); err != nil { + return 0, err + } + } + if c.flush != nil { + c.flush() + } + c.lastWrite = time.Now() + return len(p), nil +} + +func (c *h2Conn) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + select { + case <-c.done: + return nil + default: + close(c.done) + } + if c.cancel != nil { + c.cancel() + } + return nil +} + +// Done returns a channel closed when the connection is closed. +func (c *h2Conn) Done() <-chan struct{} { return c.done } + +func (c *h2Conn) LocalAddr() net.Addr { return c.localAddr } +func (c *h2Conn) RemoteAddr() net.Addr { return c.remote } +func (c *h2Conn) SetDeadline(t time.Time) error { return nil } +func (c *h2Conn) SetReadDeadline(t time.Time) error { return nil } +func (c *h2Conn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/stealth/transport/internet/stealth/dialer.go b/stealth/transport/internet/stealth/dialer.go new file mode 100644 index 0000000..50a6e38 --- /dev/null +++ b/stealth/transport/internet/stealth/dialer.go @@ -0,0 +1,205 @@ +package stealth + +import ( + "context" + gotls "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptrace" + mathrand "math/rand" + "sync" + "time" + + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/common/errors" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/reality" + "github.com/xtls/xray-core/transport/internet/stat" + "github.com/xtls/xray-core/transport/internet/tls" + "golang.org/x/net/http2" +) + +var ( + clientCache = make(map[string]*http.Client) + clientCacheMu sync.Mutex + clientCacheAt = make(map[string]time.Time) + + decoyOnce sync.Once +) + +func dialTLS(ctx context.Context, dest xnet.Destination, streamSettings *internet.MemoryStreamConfig) (net.Conn, error) { + conn, err := internet.DialSystem(ctx, dest, streamSettings.SocketSettings) + if err != nil { + return nil, err + } + if config := reality.ConfigFromStreamSettings(streamSettings); config != nil { + return reality.UClient(conn, config, ctx, dest) + } + if config := tls.ConfigFromStreamSettings(streamSettings); config != nil { + tlsConfig := config.GetTLSConfig(tls.WithDestination(dest)) + if fingerprint := tls.GetFingerprint(config.Fingerprint); fingerprint != nil { + uconn := tls.UClient(conn, tlsConfig, fingerprint) + if err := uconn.(*tls.UConn).HandshakeContext(ctx); err != nil { + conn.Close() + return nil, errors.New("stealth: TLS handshake failed").Base(err) + } + conn = uconn + } else { + tconn := tls.Client(conn, tlsConfig) + if err := tconn.(*tls.Conn).HandshakeContext(ctx); err != nil { + conn.Close() + return nil, errors.New("stealth: TLS handshake failed").Base(err) + } + conn = tconn + } + } + return conn, nil +} + +func getHTTPClient(dest xnet.Destination, streamSettings *internet.MemoryStreamConfig) *http.Client { + cfg := streamSettings.ProtocolSettings.(*Config) + fingerprint := "" + if tc := tls.ConfigFromStreamSettings(streamSettings); tc != nil { + fingerprint = tc.Fingerprint + } + key := dest.NetAddr() + "|" + cfg.Key + "|" + cfg.GetNormalizedPath() + "|" + fingerprint + + clientCacheMu.Lock() + defer clientCacheMu.Unlock() + if c, ok := clientCache[key]; ok { + return c + } + transport := &http2.Transport{ + DialTLSContext: func(ctxInner context.Context, network string, addr string, tlsCfg *gotls.Config) (net.Conn, error) { + return dialTLS(ctxInner, dest, streamSettings) + }, + IdleConnTimeout: xnet.ConnIdleTimeout, + } + client := &http.Client{Transport: transport} + clientCache[key] = client + clientCacheAt[key] = time.Now() + return client +} + +func openStream(ctx context.Context, dest xnet.Destination, streamSettings *internet.MemoryStreamConfig, client *http.Client) (stat.Connection, error) { + cfg := streamSettings.ProtocolSettings.(*Config) + paths := cfg.GetNormalizedPaths() + path := paths[mathrand.Intn(len(paths))] + + streamCtx, cancel := context.WithCancel(ctx) + pr, pw := io.Pipe() + + var localAddr, remoteAddr net.Addr + streamCtx = httptrace.WithClientTrace(streamCtx, &httptrace.ClientTrace{ + GotConn: func(ci httptrace.GotConnInfo) { + if ci.Conn != nil { + localAddr = ci.Conn.LocalAddr() + remoteAddr = ci.Conn.RemoteAddr() + } + }, + }) + + target := "https://" + dest.NetAddr() + path + req, err := http.NewRequestWithContext(streamCtx, http.MethodPost, target, pr) + if err != nil { + cancel() + pw.Close() + return nil, err + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("User-Agent", chromeUA) + req.ContentLength = -1 + + // the auth frame must reach the server before it replies 200 + go func() { + _ = writeFrame(pw, frameTypeAuth, []byte(cfg.Key)) + }() + + resp, err := client.Do(req) + if err != nil { + cancel() + pw.Close() + return nil, errors.New("stealth: request failed").Base(err) + } + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + pw.Close() + cancel() + return nil, fmt.Errorf("stealth: auth rejected (status %d)", resp.StatusCode) + } + + if localAddr == nil { + localAddr = &net.TCPAddr{IP: net.IPv4zero} + } + if remoteAddr == nil { + remoteAddr = localAddr + } + cancelStream := func() { + pw.Close() + cancel() + } + c := newH2Conn(resp.Body, pw, cancelStream, nil, cfg, localAddr, remoteAddr) + return stat.Connection(c), nil +} + +func startDecoy(cfg *Config) { + decoyOnce.Do(func() { + if !cfg.DecoyEnabled() { + return + } + interval := cfg.DecoyEvery() + target := cfg.NormalizedDecoyTarget() + client := &http.Client{Timeout: 15 * time.Second} + go func() { + for { + time.Sleep(interval) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err == nil { + req.Header.Set("User-Agent", chromeUA) + resp, err := client.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + } + cancel() + } + }() + }) +} + +func Dial(ctx context.Context, dest xnet.Destination, streamSettings *internet.MemoryStreamConfig) (stat.Connection, error) { + cfg, _ := streamSettings.ProtocolSettings.(*Config) + retries := 3 + if cfg != nil { + retries = cfg.GetNormalizedRetries() + startDecoy(cfg) + } + var lastErr error + for attempt := 0; attempt <= retries; attempt++ { + effDest := dest + if cfg != nil { + if ports := cfg.GetNormalizedPorts(); len(ports) > 0 { + effDest.Port = xnet.Port(ports[mathrand.Intn(len(ports))]) + } + } + errors.LogInfo(ctx, "stealth dialing to ", effDest, " (attempt ", attempt+1, ")") + client := getHTTPClient(effDest, streamSettings) + conn, err := openStream(ctx, effDest, streamSettings, client) + if err == nil { + return conn, nil + } + lastErr = err + errors.LogInfo(ctx, "stealth attempt failed, retrying with a new identity: ", err) + } + return nil, lastErr +} + +func init() { + common.Must(internet.RegisterTransportDialer(protocolName, Dial)) +} diff --git a/stealth/transport/internet/stealth/framing.go b/stealth/transport/internet/stealth/framing.go new file mode 100644 index 0000000..dbf572b --- /dev/null +++ b/stealth/transport/internet/stealth/framing.go @@ -0,0 +1,59 @@ +package stealth + +import ( + "encoding/binary" + "errors" + "io" +) + +const ( + frameTypeData byte = 0x01 + frameTypePad byte = 0x02 + frameTypeAuth byte = 0xFE + frameTypeClose byte = 0xFF +) + +const maxFrameSize = 64 * 1024 + +func writeFrame(w io.Writer, ftype byte, payload []byte) error { + n := 1 + len(payload) + if n > maxFrameSize { + return errors.New("stealth: frame too large") + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(n)) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + if _, err := w.Write([]byte{ftype}); err != nil { + return err + } + if len(payload) > 0 { + if _, err := w.Write(payload); err != nil { + return err + } + } + return nil +} + +func readFrame(r io.Reader) (byte, []byte, error) { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return 0, nil, err + } + n := binary.BigEndian.Uint32(hdr[:]) + if n == 0 || n > maxFrameSize { + return 0, nil, errors.New("stealth: bad frame length") + } + var ftype [1]byte + if _, err := io.ReadFull(r, ftype[:]); err != nil { + return 0, nil, err + } + payload := make([]byte, n-1) + if len(payload) > 0 { + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, err + } + } + return ftype[0], payload, nil +} diff --git a/stealth/transport/internet/stealth/hub.go b/stealth/transport/internet/stealth/hub.go new file mode 100644 index 0000000..1a83177 --- /dev/null +++ b/stealth/transport/internet/stealth/hub.go @@ -0,0 +1,317 @@ +package stealth + +import ( + "bytes" + "context" + gotls "crypto/tls" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + goreality "github.com/xtls/reality" + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/common/errors" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/reality" + "github.com/xtls/xray-core/transport/internet/stat" + "github.com/xtls/xray-core/transport/internet/tls" + "golang.org/x/net/http2" +) + +const landingPage = ` + + + + +dedikar.online + + + +
+

dedikar.online

+

Personal infrastructure and utility services.

+
+

Status: operational.

+

This is a private host. If you reached this page by accident, nothing to see here.

+
+
+ + +` + +type mirrorHostKey struct{} + + +var urlAttrRe = regexp.MustCompile(`(?i)(href|src|action|poster|data-src|data-lazy-src|srcset|content)\s*=\s*("([^"]*)"|'([^']*)')`) + +func isCSSResponse(resp *http.Response) bool { + return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/css") +} + +func isHTMLResponse(resp *http.Response) bool { + return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") +} + +func rewriteURLRef(v, targetHost, ourHost string) string { + v = strings.ReplaceAll(v, "https://"+targetHost, "https://"+ourHost) + v = strings.ReplaceAll(v, "http://"+targetHost, "https://"+ourHost) + v = strings.ReplaceAll(v, "//"+targetHost, "//"+ourHost) + return v +} + +func rewriteHTMLRefs(body []byte, targetHost, ourHost string) []byte { + if targetHost == "" || ourHost == "" || strings.EqualFold(targetHost, ourHost) { + return body + } + return urlAttrRe.ReplaceAllFunc(body, func(m []byte) []byte { + sm := urlAttrRe.FindSubmatch(m) + var val []byte + if len(sm) > 3 && sm[3] != nil { + val = sm[3] + } else if len(sm) > 4 && sm[4] != nil { + val = sm[4] + } + if val == nil { + return m + } + nv := []byte(rewriteURLRef(string(val), targetHost, ourHost)) + return bytes.Replace(m, val, nv, 1) + }) +} + +var cssURLRe = regexp.MustCompile(`(?i)url\s*\(\s*(?:'([^']*)'|"([^"]*)"|([^'")\s]+))\s*\)`) + +func rewriteCSSRefs(body []byte, targetHost, ourHost string) []byte { + if targetHost == "" || ourHost == "" || strings.EqualFold(targetHost, ourHost) { + return body + } + return cssURLRe.ReplaceAllFunc(body, func(m []byte) []byte { + sm := cssURLRe.FindSubmatch(m) + var val []byte + if len(sm) > 1 && sm[1] != nil { + val = sm[1] + } else if len(sm) > 2 && sm[2] != nil { + val = sm[2] + } else if len(sm) > 3 && sm[3] != nil { + val = sm[3] + } + if val == nil { + return m + } + nv := []byte(rewriteURLRef(string(val), targetHost, ourHost)) + return bytes.Replace(m, val, nv, 1) + }) +} + +func rewriteBodyRefs(body []byte, targetHost, ourHost string) []byte { + return rewriteCSSRefs(rewriteHTMLRefs(body, targetHost, ourHost), targetHost, ourHost) +} + +type Listener struct { + server *http.Server + listener net.Listener + config *Config + paths []string + addConn internet.ConnHandler + proxy *httputil.ReverseProxy + target *url.URL +} + +func ListenStealth(ctx context.Context, address xnet.Address, port xnet.Port, streamSettings *internet.MemoryStreamConfig, handler internet.ConnHandler) (internet.Listener, error) { + errors.LogInfo(ctx, "stealth listening on ", address, ":", port) + cfg, ok := streamSettings.ProtocolSettings.(*Config) + if !ok { + return nil, errors.New("stealth: invalid stream settings") + } + + listener, err := internet.ListenSystem(ctx, &net.TCPAddr{IP: address.IP(), Port: int(port)}, streamSettings.SocketSettings) + if err != nil { + return nil, err + } + + l := &Listener{ + listener: listener, + config: cfg, + paths: cfg.GetNormalizedPaths(), + addConn: handler, + } + if target := cfg.GetNormalizedTarget(); target != nil { + // custom director: NewSingleHostReverseProxy in go>=1.26 does not + // rewrite req.Host, which makes real sites reject the request + rp := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + req.Header.Set("Accept-Encoding", "identity") + }, + ModifyResponse: func(resp *http.Response) error { + host, _ := resp.Request.Context().Value(mirrorHostKey{}).(string) + if host == "" { + return nil + } + loc := resp.Header.Get("Location") + if loc != "" { + u, err := url.Parse(loc) + if err == nil && u.Host != "" { + // keep redirects inside our domain so the mirror feels seamless + if strings.EqualFold(u.Host, target.Host) { + u.Scheme = "https" + u.Host = host + resp.Header.Set("Location", u.String()) + } + } + } + // rewrite absolute links in HTML/CSS so browsing stays on our domain + if isHTMLResponse(resp) || isCSSResponse(resp) { + if body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)); err == nil && len(body) < 8<<20 { + var nb []byte + if isCSSResponse(resp) { + nb = rewriteCSSRefs(body, target.Host, host) + } else { + nb = rewriteBodyRefs(body, target.Host, host) + } + if !bytes.Equal(nb, body) { + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(nb)) + resp.ContentLength = int64(len(nb)) + resp.Header.Set("Content-Length", strconv.Itoa(len(nb))) + resp.Header.Del("Content-Encoding") + resp.Header.Del("Transfer-Encoding") + } + } + } + return nil + }, + } + l.proxy = rp + l.target = target + errors.LogInfo(ctx, "stealth mirroring real site at ", target.String()) + } + + // wrap the raw listener with TLS or REALITY; the inner stream is served as + // plaintext HTTP/1.1 + h2c (same pattern as XHTTP) + if config := tls.ConfigFromStreamSettings(streamSettings); config != nil { + if tlsConfig := config.GetTLSConfig(); tlsConfig != nil { + tlsConfig.NextProtos = []string{"h2"} + if len(tlsConfig.Certificates) > 0 { + certs := tlsConfig.Certificates + tlsConfig.GetCertificate = func(*gotls.ClientHelloInfo) (*gotls.Certificate, error) { + return &certs[0], nil + } + } + listener = gotls.NewListener(listener, tlsConfig) + errors.LogInfo(ctx, "stealth TLS listener enabled") + } + } + if config := reality.ConfigFromStreamSettings(streamSettings); config != nil { + listener = goreality.NewListener(listener, config.GetREALITYConfig()) + errors.LogInfo(ctx, "stealth REALITY listener enabled") + } + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Handler: l, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 0, + Protocols: protocols, + } + _ = http2.ConfigureServer(server, &http2.Server{}) + l.server = server + + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + errors.LogWarningInner(context.Background(), err, "stealth: http server stopped") + } + }() + return l, nil +} + +func (l *Listener) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !containsString(l.paths, r.URL.Path) { + // probes and normal browsing see a real website (reverse-proxied), + // falling back to a built-in landing page when no target is set + if l.proxy != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + l.proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), mirrorHostKey{}, r.Host))) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, landingPage) + return + } + + ftype, payload, err := readFrame(r.Body) + if err != nil || ftype != frameTypeAuth || string(payload) != l.config.Key { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + var flush func() + if flusher, ok := w.(http.Flusher); ok { + flush = flusher.Flush + flusher.Flush() + } + + var local, remote net.Addr + if v, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok { + local = v + } + if raddr, err := net.ResolveTCPAddr("tcp", r.RemoteAddr); err == nil { + remote = raddr + } + if local == nil { + local = &net.TCPAddr{IP: net.IPv4zero} + } + if remote == nil { + remote = local + } + conn := newH2Conn(r.Body, w, nil, flush, l.config, local, remote) + l.addConn(stat.Connection(conn)) + + select { + case <-conn.Done(): + case <-r.Context().Done(): + } + _ = conn.Close() +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func (l *Listener) Addr() net.Addr { + return l.listener.Addr() +} + +func (l *Listener) Close() error { + _ = l.server.Close() + return l.listener.Close() +} + +func init() { + common.Must(internet.RegisterTransportListener(protocolName, ListenStealth)) +}