feat: support xhttp transport

This commit is contained in:
parhelia512 2025-11-18 11:42:45 +08:00
parent 7a32e4144c
commit 2e0556ec81
7 changed files with 353 additions and 3 deletions

View File

@ -19,7 +19,7 @@ require (
google.golang.org/protobuf v1.36.10
)
replace github.com/sagernet/sing-box => github.com/throneproj/sing-box v1.11.16-0.20251027170654-efe4b5f5b1e4
replace github.com/sagernet/sing-box => github.com/throneproj/sing-box v1.11.16-0.20251117211316-75fea0c5db6d
replace github.com/sagernet/wireguard-go => github.com/throneproj/wireguard-go v0.0.1-beta.7.0.20250728063157-408bba78ad26
@ -82,6 +82,7 @@ require (
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect
github.com/sagernet/cors v1.2.1 // indirect

View File

@ -141,6 +141,8 @@ github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyf
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
@ -216,8 +218,8 @@ github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tevino/abool/v2 v2.1.0 h1:7w+Vf9f/5gmKT4m4qkayb33/92M+Um45F2BkHOR+L/c=
github.com/tevino/abool/v2 v2.1.0/go.mod h1:+Lmlqk6bHDWHqN1cbxqhwEAwMPXgc8I1SDEamtseuXY=
github.com/throneproj/sing-box v1.11.16-0.20251027170654-efe4b5f5b1e4 h1:tQumipJlxqgMXvbuOJR8qMU4HoM0qbcU6RPA0DeAV9M=
github.com/throneproj/sing-box v1.11.16-0.20251027170654-efe4b5f5b1e4/go.mod h1:4hUwNgXeaqRWAuYxixxVBOEGRFIamyw12lrpx8hbZBc=
github.com/throneproj/sing-box v1.11.16-0.20251117211316-75fea0c5db6d h1:GqxlDvZI+Fd/sQHywvxZ8Bz9Sh+leK5HZb6aEWQ4SJo=
github.com/throneproj/sing-box v1.11.16-0.20251117211316-75fea0c5db6d/go.mod h1:o6kl5QH2V1yzOS0P95jVaVH7hv2sa5QURHs1Ha6O6No=
github.com/throneproj/wireguard-go v0.0.1-beta.7.0.20250728063157-408bba78ad26 h1:bBzqh7xTshvPjTFz4URNj/xbPA/d0BOwUM2R83FEMGU=
github.com/throneproj/wireguard-go v0.0.1-beta.7.0.20250728063157-408bba78ad26/go.mod h1:akc2Wh+rX9bFFNnHJGsQ8VIV3eJI1LXJYgx2Y+8lcW8=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
@ -247,6 +249,8 @@ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=

View File

@ -23,6 +23,10 @@ namespace Configs
// gRPC
QString service_name;
// xhttp
QString xhttp_mode;
QString xhttp_extra;
Transport()
{
_add(new configItem("type", &type, string));
@ -35,6 +39,8 @@ namespace Configs
_add(new configItem("max_early_data", &max_early_data, integer));
_add(new configItem("early_data_header_name", &early_data_header_name, string));
_add(new configItem("service_name", &service_name, string));
_add(new configItem("xhttp_mode", &xhttp_mode, string));
_add(new configItem("xhttp_extra", &xhttp_extra, string));
}
QString getHeadersString();

View File

@ -0,0 +1,256 @@
#pragma once
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QString>
class XhttpExtraConverter
{
public:
static void mergeQJsonObject(QJsonObject &obj1, const QJsonObject &obj2)
{
for (auto it = obj2.constBegin(); it != obj2.constEnd(); it++) {
obj1.insert(it.key(), it.value());
}
}
static QJsonObject xrayToSingBox(const QString &xrayExtra)
{
if (xrayExtra.trimmed().isEmpty()) return {};
QJsonParseError err{};
const auto doc = QJsonDocument::fromJson(xrayExtra.toUtf8(), &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
return {};
}
const QJsonObject xray = doc.object();
if (isSingBoxFormat(xray)) return xray;
QJsonObject singBox;
convertField(xray, singBox, "xPaddingBytes", "x_padding_bytes");
convertField(xray, singBox, "scMaxEachPostBytes", "sc_max_each_post_bytes");
convertField(xray, singBox, "scMinPostsIntervalMs", "sc_min_posts_interval_ms");
convertField(xray, singBox, "noGRPCHeader", "no_grpc_header");
// xmux
if (xray.contains("xmux") && xray["xmux"].isObject()) {
singBox["xmux"] = convertXrayXmux(xray["xmux"].toObject());
}
// downloadSettings → download
if (xray.contains("downloadSettings") && xray["downloadSettings"].isObject()) {
const QJsonObject xDown = xray["downloadSettings"].toObject();
QJsonObject sDown;
// xhttpSettings (mode/host/path)
if (xDown.contains("xhttpSettings") && xDown["xhttpSettings"].isObject()) {
const QJsonObject xhttp = xDown["xhttpSettings"].toObject();
convertField(xhttp, sDown, "mode", "mode");
convertField(xhttp, sDown, "host", "host");
convertField(xhttp, sDown, "path", "path");
}
convertField(xDown, sDown, "address", "server");
convertField(xDown, sDown, "port", "server_port");
// TLS / Reality
if (xDown.contains("security") && xDown["security"].isString()) {
QJsonObject tls{ {"enabled", true} };
const QString security = xDown["security"].toString();
if (security == "tls") {
if (xDown.contains("tlsSettings") && xDown["tlsSettings"].isObject()) {
const QJsonObject tlsSet = xDown["tlsSettings"].toObject();
convertField(tlsSet, tls, "serverName", "server_name");
convertField(tlsSet, tls, "alpn", "alpn");
convertField(tlsSet, tls, "allowInsecure", "insecure");
if (tlsSet.contains("fingerprint") && !tlsSet["fingerprint"].toString().isEmpty()) {
QJsonObject utls{ {"enabled", true}, {"fingerprint", tlsSet["fingerprint"]} };
tls["utls"] = utls;
}
}
} else if (security == "reality") {
if (xDown.contains("realitySettings") && xDown["realitySettings"].isObject()) {
const QJsonObject realSet = xDown["realitySettings"].toObject();
convertField(realSet, tls, "serverName", "server_name");
QJsonObject reality{ {"enabled", true} };
convertField(realSet, reality, "publicKey", "public_key");
convertField(realSet, reality, "shortId", "short_id");
tls["reality"] = reality;
if (realSet.contains("fingerprint") && !realSet["fingerprint"].toString().isEmpty()) {
QJsonObject utls{ {"enabled", true}, {"fingerprint", realSet["fingerprint"]} };
tls["utls"] = utls;
}
}
}
sDown["tls"] = tls;
}
// downloadSettings.xhttpSettings.extra → download + xmux
if (xDown.contains("xhttpSettings") && xDown["xhttpSettings"].isObject()) {
const QJsonObject xhttp = xDown["xhttpSettings"].toObject();
if (xhttp.contains("extra") && xhttp["extra"].isObject()) {
const QJsonObject extra = xhttp["extra"].toObject();
convertField(extra, sDown, "xPaddingBytes", "x_padding_bytes");
convertField(extra, sDown, "scMaxEachPostBytes", "sc_max_each_post_bytes");
convertField(extra, sDown, "scMinPostsIntervalMs", "sc_min_posts_interval_ms");
convertField(extra, sDown, "noGRPCHeader", "no_grpc_header");
if (extra.contains("xmux") && extra["xmux"].isObject()) {
sDown["xmux"] = convertXrayXmux(extra["xmux"].toObject());
}
}
}
if (!sDown.isEmpty()) {
singBox["download"] = sDown;
}
}
return singBox;
}
static QString singBoxToXray(const QJsonObject &sing)
{
if (isXrayFormat(sing)) return QJsonDocument(sing).toJson(QJsonDocument::Indented).replace("\\/", "/");
QJsonObject xray;
convertField(sing, xray, "x_padding_bytes", "xPaddingBytes");
convertField(sing, xray, "sc_max_each_post_bytes", "scMaxEachPostBytes");
convertField(sing, xray, "sc_min_posts_interval_ms", "scMinPostsIntervalMs");
convertField(sing, xray, "no_grpc_header", "noGRPCHeader");
if (sing.contains("xmux") && sing["xmux"].isObject()) {
xray["xmux"] = convertSingBoxXmux(sing["xmux"].toObject());
}
// download → downloadSettings
if (sing.contains("download") && sing["download"].isObject()) {
const QJsonObject sDown = sing["download"].toObject();
QJsonObject xDown;
convertField(sDown, xDown, "server", "address");
convertField(sDown, xDown, "server_port", "port");
xDown["network"] = "xhttp";
// TLS / Reality
if (sDown.contains("tls") && sDown["tls"].isObject()) {
const QJsonObject tls = sDown["tls"].toObject();
if (tls.contains("reality") && tls["reality"].isObject() &&
tls["reality"].toObject().value("enabled").toBool(false)) {
xDown["security"] = "reality";
QJsonObject realitySettings;
convertField(tls, realitySettings, "server_name", "serverName");
const QJsonObject reality = tls["reality"].toObject();
convertField(reality, realitySettings, "public_key", "publicKey");
convertField(reality, realitySettings, "short_id", "shortId");
if (tls.contains("utls") && tls["utls"].isObject()) {
convertField(tls["utls"].toObject(), realitySettings, "fingerprint", "fingerprint");
}
xDown["realitySettings"] = realitySettings;
} else {
xDown["security"] = "tls";
QJsonObject tlsSettings;
convertField(tls, tlsSettings, "server_name", "serverName");
convertField(tls, tlsSettings, "alpn", "alpn");
convertField(tls, tlsSettings, "insecure", "allowInsecure");
if (tls.contains("utls") && tls["utls"].isObject()) {
convertField(tls["utls"].toObject(), tlsSettings, "fingerprint", "fingerprint");
}
xDown["tlsSettings"] = tlsSettings;
}
}
// mode/host/path + extra
QJsonObject xhttpSettings;
convertField(sDown, xhttpSettings, "mode", "mode");
convertField(sDown, xhttpSettings, "host", "host");
convertField(sDown, xhttpSettings, "path", "path");
QJsonObject xhttpExtra;
convertField(sDown, xhttpExtra, "x_padding_bytes", "xPaddingBytes");
convertField(sDown, xhttpExtra, "sc_max_each_post_bytes", "scMaxEachPostBytes");
convertField(sDown, xhttpExtra, "sc_min_posts_interval_ms", "scMinPostsIntervalMs");
convertField(sDown, xhttpExtra, "no_grpc_header", "noGRPCHeader");
if (sDown.contains("xmux") && sDown["xmux"].isObject()) {
xhttpExtra["xmux"] = convertSingBoxXmux(sDown["xmux"].toObject());
}
if (!xhttpExtra.isEmpty()) {
xhttpSettings["extra"] = xhttpExtra;
}
xDown["xhttpSettings"] = xhttpSettings;
if (!xDown.isEmpty()) {
xray["downloadSettings"] = xDown;
}
}
return QJsonDocument(xray).toJson(QJsonDocument::Indented).replace("\\/", "/");
}
private:
static bool isSingBoxFormat(const QJsonObject &json)
{
return json.contains("x_padding_bytes") ||
json.contains("sc_max_each_post_bytes") ||
json.contains("sc_min_posts_interval_ms") ||
json.contains("sc_stream_up_server_secs") ||
json.contains("download");
}
static bool isXrayFormat(const QJsonObject &json)
{
return json.contains("xPaddingBytes") ||
json.contains("scMaxEachPostBytes") ||
json.contains("scMinPostsIntervalMs") ||
json.contains("scStreamUpServerSecs") ||
json.contains("downloadSettings");
}
static void convertField(const QJsonObject &from, QJsonObject &to,
const QString &fromKey, const QString &toKey)
{
if (from.contains(fromKey)) {
to[toKey] = from[fromKey];
}
}
static QJsonObject convertXrayXmux(const QJsonObject &xrayXmux)
{
QJsonObject sing;
convertField(xrayXmux, sing, "maxConcurrency", "max_concurrency");
convertField(xrayXmux, sing, "maxConnections", "max_connections");
convertField(xrayXmux, sing, "cMaxReuseTimes", "c_max_reuse_times");
convertField(xrayXmux, sing, "hMaxRequestTimes", "h_max_request_times");
convertField(xrayXmux, sing, "hMaxReusableSecs", "h_max_reusable_secs");
convertField(xrayXmux, sing, "hKeepAlivePeriod", "h_keep_alive_period");
return sing;
}
static QJsonObject convertSingBoxXmux(const QJsonObject &singXmux)
{
QJsonObject xray;
convertField(singXmux, xray, "max_concurrency", "maxConcurrency");
convertField(singXmux, xray, "max_connections", "maxConnections");
convertField(singXmux, xray, "c_max_reuse_times", "cMaxReuseTimes");
convertField(singXmux, xray, "h_max_request_times","hMaxRequestTimes");
convertField(singXmux, xray, "h_max_reusable_secs","hMaxReusableSecs");
convertField(singXmux, xray, "h_keep_alive_period","hKeepAlivePeriod");
return xray;
}
};

View File

@ -247,6 +247,11 @@
<string notr="true">quic</string>
</property>
</item>
<item>
<property name="text">
<string notr="true">xhttp</string>
</property>
</item>
</widget>
</item>
</layout>
@ -434,6 +439,47 @@
<item row="5" column="1">
<widget class="MyLineEdit" name="ws_early_data_name"/>
</item>
<item row="6" column="0">
<widget class="QLabel" name="xhttp_mode_l">
<property name="text">
<string notr="true">Mode</string>
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QComboBox" name="xhttp_mode">
<item>
<property name="text">
<string notr="true">auto</string>
</property>
</item>
<item>
<property name="text">
<string notr="true">packet-up</string>
</property>
</item>
<item>
<property name="text">
<string notr="true">stream-up</string>
</property>
</item>
<item>
<property name="text">
<string notr="true">stream-one</string>
</property>
</item>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="xhttp_extra_l">
<property name="text">
<string notr="true">Extra</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="MyLineEdit" name="xhttp_extra"/>
</item>
</layout>
</widget>
</item>
@ -713,6 +759,8 @@
<tabstop>host</tabstop>
<tabstop>ws_early_data_length</tabstop>
<tabstop>ws_early_data_name</tabstop>
<tabstop>xhttp_mode</tabstop>
<tabstop>xhttp_extra</tabstop>
<tabstop>insecure</tabstop>
<tabstop>certificate_edit</tabstop>
<tabstop>sni</tabstop>

View File

@ -3,6 +3,7 @@
#include <QJsonArray>
#include <QUrlQuery>
#include <include/global/Utils.hpp>
#include <include/global/XhttpExtraConverter.hpp>
#include "include/configs/common/utils.h"
@ -81,6 +82,8 @@ namespace Configs {
if (query.hasQueryItem("max_early_data")) max_early_data = query.queryItemValue("max_early_data").toInt();
if (query.hasQueryItem("early_data_header_name")) early_data_header_name = query.queryItemValue("early_data_header_name");
if (query.hasQueryItem("serviceName")) service_name = query.queryItemValue("serviceName");
if (query.hasQueryItem("mode")) xhttp_mode = query.queryItemValue("mode");
if (query.hasQueryItem("extra")) xhttp_extra = query.queryItemValue("extra");
return true;
}
bool Transport::ParseFromJson(const QJsonObject& object)
@ -98,6 +101,10 @@ namespace Configs {
if (object.contains("max_early_data")) max_early_data = object["max_early_data"].toInt();
if (object.contains("early_data_header_name")) early_data_header_name = object["early_data_header_name"].toString();
if (object.contains("service_name")) service_name = object["service_name"].toString();
if (object.contains("mode")) {
xhttp_mode = object["mode"].toString();
xhttp_extra = XhttpExtraConverter::singBoxToXray(object);
}
return true;
}
QString Transport::ExportToLink()
@ -113,6 +120,8 @@ namespace Configs {
if (max_early_data > 0) query.addQueryItem("max_early_data", QString::number(max_early_data));
if (!early_data_header_name.isEmpty()) query.addQueryItem("early_data_header_name", early_data_header_name);
if (!service_name.isEmpty()) query.addQueryItem("serviceName", service_name);
if (!xhttp_mode.isEmpty()) query.addQueryItem("mode", xhttp_mode);
if (!xhttp_extra.isEmpty()) query.addQueryItem("extra", xhttp_extra);
return query.toString();
}
QJsonObject Transport::ExportToJson()
@ -130,6 +139,8 @@ namespace Configs {
if (max_early_data > 0) object["max_early_data"] = max_early_data;
if (!early_data_header_name.isEmpty()) object["early_data_header_name"] = early_data_header_name;
if (!service_name.isEmpty()) object["service_name"] = service_name;
if (!xhttp_mode.isEmpty()) object["mode"] = xhttp_mode;
if (!xhttp_extra.isEmpty()) XhttpExtraConverter::mergeQJsonObject(object, XhttpExtraConverter::xrayToSingBox(xhttp_extra));
return object;
}
BuildResult Transport::Build()

View File

@ -66,6 +66,15 @@ DialogEditProfile::DialogEditProfile(const QString &_type, int profileOrGroupId,
ui->path_l->setVisible(true);
ui->host->setVisible(true);
ui->host_l->setVisible(true);
} else if (txt == "xhttp") {
ui->headers->setVisible(false);
ui->headers_l->setVisible(false);
ui->method->setVisible(false);
ui->method_l->setVisible(false);
ui->path->setVisible(true);
ui->path_l->setVisible(true);
ui->host->setVisible(true);
ui->host_l->setVisible(true);
} else {
ui->headers->setVisible(false);
ui->headers_l->setVisible(false);
@ -87,6 +96,17 @@ DialogEditProfile::DialogEditProfile(const QString &_type, int profileOrGroupId,
ui->ws_early_data_name->setVisible(false);
ui->ws_early_data_name_l->setVisible(false);
}
if (txt == "xhttp") {
ui->xhttp_mode->setVisible(true);
ui->xhttp_mode_l->setVisible(true);
ui->xhttp_extra->setVisible(true);
ui->xhttp_extra_l->setVisible(true);
} else {
ui->xhttp_mode->setVisible(false);
ui->xhttp_mode_l->setVisible(false);
ui->xhttp_extra->setVisible(false);
ui->xhttp_extra_l->setVisible(false);
}
if (!ui->utlsFingerprint->count()) ui->utlsFingerprint->addItems(Preset::SingBox::UtlsFingerPrint);
int networkBoxVisible = 0;
for (auto label: ui->network_box->findChildren<QLabel *>()) {
@ -302,6 +322,8 @@ void DialogEditProfile::typeSelected(const QString &newType) {
ui->headers->setText(transport->getHeadersString());
ui->ws_early_data_name->setText(transport->early_data_header_name);
ui->ws_early_data_length->setText(Int2String(transport->max_early_data));
ui->xhttp_mode->setCurrentText(transport->xhttp_mode);
ui->xhttp_extra->setText(transport->xhttp_extra);
ui->reality_pbk->setText(tls->reality->public_key);
ui->reality_sid->setText(tls->reality->short_id);
CACHE.certificate = tls->certificate;
@ -412,6 +434,8 @@ bool DialogEditProfile::onEnd() {
transport->method = ui->method->text();
transport->early_data_header_name = ui->ws_early_data_name->text();
transport->max_early_data = ui->ws_early_data_length->text().toInt();
transport->xhttp_mode = ui->xhttp_mode->currentText();
transport->xhttp_extra = ui->xhttp_extra->text();
tls->reality->public_key = ui->reality_pbk->text();
tls->reality->short_id = ui->reality_sid->text();
tls->certificate = CACHE.certificate;