diff --git a/CMakeLists.txt b/CMakeLists.txt index 60c9746..6f9993c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -144,6 +144,10 @@ set(PROJECT_SOURCES include/ui/profile/edit_anytls.h src/ui/profile/edit_anytls.cpp include/ui/profile/edit_anytls.ui + include/ui/profile/edit_tailscale.h + src/ui/profile/edit_tailscale.cpp + include/ui/profile/edit_tailscale.ui + include/configs/proxy/Tailscale.hpp include/ui/profile/edit_quic.h src/ui/profile/edit_quic.cpp diff --git a/include/configs/proxy/AbstractBean.hpp b/include/configs/proxy/AbstractBean.hpp index 09ea408..2f73dbb 100644 --- a/include/configs/proxy/AbstractBean.hpp +++ b/include/configs/proxy/AbstractBean.hpp @@ -50,6 +50,8 @@ namespace Configs { virtual CoreObjOutboundBuildResult BuildCoreObjSingBox() { return {}; }; virtual QString ToShareLink() { return {}; }; + + virtual bool IsEndpoint() { return false; }; }; } // namespace Configs diff --git a/include/configs/proxy/Tailscale.hpp b/include/configs/proxy/Tailscale.hpp new file mode 100644 index 0000000..11e3a41 --- /dev/null +++ b/include/configs/proxy/Tailscale.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "AbstractBean.hpp" + +namespace Configs { + class TailscaleBean : public AbstractBean { + public: + QString state_directory = "$HOME/.tailscale"; + QString auth_key; + QString control_url = "https://controlplane.tailscale.com"; + bool ephemeral = false; + QString hostname; + bool accept_routes = false; + QString exit_node; + bool exit_node_allow_lan_access = false; + QStringList advertise_routes; + bool advertise_exit_node = false; + bool globalDNS = false; + + explicit TailscaleBean() : AbstractBean(0) { + _add(new configItem("state_directory", &state_directory, itemType::string)); + _add(new configItem("auth_key", &auth_key, itemType::string)); + _add(new configItem("control_url", &control_url, itemType::string)); + _add(new configItem("ephemeral", &ephemeral, itemType::boolean)); + _add(new configItem("hostname", &hostname, itemType::string)); + _add(new configItem("accept_routes", &accept_routes, itemType::boolean)); + _add(new configItem("exit_node", &exit_node, itemType::string)); + _add(new configItem("exit_node_allow_lan_access", &exit_node_allow_lan_access, itemType::boolean)); + _add(new configItem("advertise_routes", &advertise_routes, itemType::stringList)); + _add(new configItem("advertise_exit_node", &advertise_exit_node, itemType::boolean)); + _add(new configItem("globalDNS", &globalDNS, itemType::boolean)); + }; + + QString DisplayType() override { return "Tailscale"; } + + QString DisplayAddress() override {return control_url; } + + CoreObjOutboundBuildResult BuildCoreObjSingBox() override; + + bool TryParseLink(const QString &link); + + bool TryParseJson(const QJsonObject &obj); + + QString ToShareLink() override; + + bool IsEndpoint() override {return true;} + }; +} // namespace Configs diff --git a/include/configs/proxy/WireguardBean.h b/include/configs/proxy/WireguardBean.h index e3dea4e..a180ddb 100644 --- a/include/configs/proxy/WireguardBean.h +++ b/include/configs/proxy/WireguardBean.h @@ -70,5 +70,7 @@ namespace Configs { bool TryParseJson(const QJsonObject &obj); QString ToShareLink() override; + + bool IsEndpoint() override {return true;} }; } // namespace Configs diff --git a/include/configs/proxy/includes.h b/include/configs/proxy/includes.h index 61ba112..ede0253 100644 --- a/include/configs/proxy/includes.h +++ b/include/configs/proxy/includes.h @@ -8,6 +8,7 @@ #include "QUICBean.hpp" #include "AnyTLSBean.hpp" #include "WireguardBean.h" +#include "Tailscale.hpp" #include "SSHBean.h" #include "CustomBean.hpp" #include "ExtraCore.h" diff --git a/include/dataStore/ProxyEntity.hpp b/include/dataStore/ProxyEntity.hpp index a0eb629..4b02e32 100644 --- a/include/dataStore/ProxyEntity.hpp +++ b/include/dataStore/ProxyEntity.hpp @@ -22,6 +22,8 @@ namespace Configs { class WireguardBean; + class TailscaleBean; + class SSHBean; class CustomBean; @@ -86,6 +88,11 @@ namespace Configs { return (Configs::WireguardBean *) bean.get(); }; + [[nodiscard]] Configs::TailscaleBean *TailscaleBean() const + { + return (Configs::TailscaleBean *) bean.get(); + } + [[nodiscard]] Configs::SSHBean *SSHBean() const { return (Configs::SSHBean *) bean.get(); }; diff --git a/include/ui/profile/edit_tailscale.h b/include/ui/profile/edit_tailscale.h new file mode 100644 index 0000000..6b634de --- /dev/null +++ b/include/ui/profile/edit_tailscale.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include "profile_editor.h" +#include "ui_edit_tailscale.h" + +QT_BEGIN_NAMESPACE +namespace Ui { + class EditTailScale; +} +QT_END_NAMESPACE + +class EditTailScale : public QWidget, public ProfileEditor { + Q_OBJECT + +public: + explicit EditTailScale(QWidget *parent = nullptr); + + ~EditTailScale() override; + + void onStart(std::shared_ptr _ent) override; + + bool onEnd() override; + +private: + Ui::EditTailScale *ui; + std::shared_ptr ent; +}; diff --git a/include/ui/profile/edit_tailscale.ui b/include/ui/profile/edit_tailscale.ui new file mode 100644 index 0000000..afe0c7f --- /dev/null +++ b/include/ui/profile/edit_tailscale.ui @@ -0,0 +1,122 @@ + + + EditTailScale + + + + 0 + 0 + 400 + 385 + + + + Form + + + + + + State directory + + + + + + + + + + Auth key + + + + + + + + + + Control URL + + + + + + + + + + Hostname + + + + + + + + + + Accept routes + + + + + + + Ephemeral + + + + + + + Exit node allow lan access + + + + + + + Advertise exit node + + + + + + + Exit node + + + + + + + + + + <html><head/><body><p>comma seperated list of subnets</p></body></html> + + + Advertise routes + + + + + + + + + + <html><head/><body><p>use tailscale dns as the remote dns</p></body></html> + + + Global DNS + + + + + + + + diff --git a/script/build_go.sh b/script/build_go.sh index faac7a4..ef33027 100755 --- a/script/build_go.sh +++ b/script/build_go.sh @@ -42,5 +42,5 @@ pushd gen protoc -I . --go_out=. --protorpc_out=. libcore.proto popd VERSION_SINGBOX=$(go list -m -f '{{.Version}}' github.com/sagernet/sing-box) -$GOCMD build -v -o $DEST -trimpath -ldflags "-w -s -X 'github.com/sagernet/sing-box/constant.Version=${VERSION_SINGBOX}'" -tags "with_clash_api,with_gvisor,with_quic,with_wireguard,with_utls,with_dhcp" +$GOCMD build -v -o $DEST -trimpath -ldflags "-w -s -X 'github.com/sagernet/sing-box/constant.Version=${VERSION_SINGBOX}'" -tags "with_clash_api,with_gvisor,with_quic,with_wireguard,with_utls,with_dhcp,with_tailscale" popd diff --git a/src/configs/ConfigBuilder.cpp b/src/configs/ConfigBuilder.cpp index 663846e..6daae9a 100644 --- a/src/configs/ConfigBuilder.cpp +++ b/src/configs/ConfigBuilder.cpp @@ -97,7 +97,7 @@ namespace Configs { { auto out = ent->bean->BuildCoreObjSingBox(); auto outArr = QJsonArray{out.outbound}; - auto key = ent->type == "wireguard" ? "endpoints" : "outbounds"; + auto key = ent->bean->IsEndpoint() ? "endpoints" : "outbounds"; conf = { {key, outArr}, }; @@ -178,7 +178,7 @@ namespace Configs { QString tag = "proxy"; if (index > 1) tag += Int2String(index); outbound.insert("tag", tag); - if (outbound["type"] == "wireguard") + if (outbound["type"] == "wireguard" || outbound["type"] == "tailscale") { endpointArray.append(outbound); } else @@ -328,7 +328,7 @@ namespace Configs { status->domainListDNSDirect += serverAddress; } - if (ent->type == "wireguard") + if (ent->bean->IsEndpoint()) { status->endpoints += outbound; lastWasEndpoint = true; @@ -791,11 +791,23 @@ namespace Configs { QJsonArray dnsRules; // Remote - auto remoteDnsObj = BuildDnsObject(dataStore->routing->remote_dns, dataStore->spmode_vpn); - remoteDnsObj["tag"] = "dns-remote"; - remoteDnsObj["domain_resolver"] = "dns-local"; - remoteDnsObj["detour"] = tagProxy; - dnsServers += remoteDnsObj; + if (status->ent->type == "tailscale") + { + auto tailDns = QJsonObject{ + {"type", "tailscale"}, + {"tag", "dns-remote"}, + {"endpoint", "proxy"}, + {"accept_default_resolvers", status->ent->TailscaleBean()->globalDNS}, + }; + dnsServers += tailDns; + } else + { + auto remoteDnsObj = BuildDnsObject(dataStore->routing->remote_dns, dataStore->spmode_vpn); + remoteDnsObj["tag"] = "dns-remote"; + remoteDnsObj["domain_resolver"] = "dns-local"; + remoteDnsObj["detour"] = tagProxy; + dnsServers += remoteDnsObj; + } // Direct auto directDNSAddress = dataStore->routing->direct_dns; diff --git a/src/configs/proxy/Bean2CoreObj_box.cpp b/src/configs/proxy/Bean2CoreObj_box.cpp index e81e421..3339035 100644 --- a/src/configs/proxy/Bean2CoreObj_box.cpp +++ b/src/configs/proxy/Bean2CoreObj_box.cpp @@ -323,6 +323,27 @@ namespace Configs { return result; } + CoreObjOutboundBuildResult TailscaleBean::BuildCoreObjSingBox() + { + CoreObjOutboundBuildResult result; + QJsonObject outbound{ + {"type", "tailscale"}, + {"state_directory", state_directory}, + {"auth_key", auth_key}, + {"control_url", control_url}, + {"ephemeral", ephemeral}, + {"hostname", hostname}, + {"accept_routes", accept_routes}, + {"exit_node", exit_node}, + {"exit_node_allow_lan_access", exit_node_allow_lan_access}, + {"advertise_routes", QListStr2QJsonArray(advertise_routes)}, + {"advertise_exit_node", advertise_exit_node}, + }; + + result.outbound = outbound; + return result; + } + CoreObjOutboundBuildResult SSHBean::BuildCoreObjSingBox(){ CoreObjOutboundBuildResult result; diff --git a/src/configs/proxy/Bean2Link.cpp b/src/configs/proxy/Bean2Link.cpp index 734ac1c..7fc16b8 100644 --- a/src/configs/proxy/Bean2Link.cpp +++ b/src/configs/proxy/Bean2Link.cpp @@ -323,6 +323,28 @@ namespace Configs { return url.toString(QUrl::FullyEncoded); } + QString TailscaleBean::ToShareLink() + { + QUrl url; + url.setScheme("ts"); + url.setHost("tailscale"); + if (!name.isEmpty()) url.setFragment(name); + QUrlQuery q; + q.addQueryItem("state_directory", QUrl::toPercentEncoding(state_directory)); + q.addQueryItem("auth_key", QUrl::toPercentEncoding(auth_key)); + q.addQueryItem("control_url", QUrl::toPercentEncoding(control_url)); + q.addQueryItem("ephemeral", ephemeral ? "true" : "false"); + q.addQueryItem("hostname", QUrl::toPercentEncoding(hostname)); + q.addQueryItem("accept_routes", accept_routes ? "true" : "false"); + q.addQueryItem("exit_node", exit_node); + q.addQueryItem("exit_node_allow_lan_access", exit_node_allow_lan_access ? "true" : "false"); + q.addQueryItem("advertise_routes", QUrl::toPercentEncoding(advertise_routes.join(","))); + q.addQueryItem("advertise_exit_node", advertise_exit_node ? "true" : "false"); + q.addQueryItem("global_dns", globalDNS ? "true" : "false"); + url.setQuery(q); + return url.toString(QUrl::FullyEncoded); + } + QString SSHBean::ToShareLink() { QUrl url; url.setScheme("ssh"); diff --git a/src/configs/proxy/Json2Bean.cpp b/src/configs/proxy/Json2Bean.cpp index ff2bf14..c3e9934 100644 --- a/src/configs/proxy/Json2Bean.cpp +++ b/src/configs/proxy/Json2Bean.cpp @@ -1,15 +1,4 @@ - - -#include -#include -#include -#include -#include -#include -#include - -#include "include/configs/proxy/ExtraCore.h" -#include "include/configs/proxy/SSHBean.h" +#include namespace Configs { @@ -261,6 +250,23 @@ namespace Configs return true; } + bool TailscaleBean::TryParseJson(const QJsonObject& obj) + { + name = obj["tag"].toString(); + state_directory = obj["state_directory"].toString(); + auth_key = obj["auth_key"].toString(); + control_url = obj["control_url"].toString(); + ephemeral = obj["ephemeral"].toBool(); + hostname = obj["hostname"].toString(); + accept_routes = obj["accept_routes"].toBool(); + exit_node = obj["exit_node"].toString(); + exit_node_allow_lan_access = obj["exit_node_allow_lan_access"].toBool(); + advertise_routes = QJsonArray2QListString(obj["advertise_routes"].toArray()); + advertise_exit_node = obj["advertise_exit_node"].toBool(); + + return true; + } + bool ExtraCoreBean::TryParseJson(const QJsonObject& obj) { return false; diff --git a/src/configs/proxy/Link2Bean.cpp b/src/configs/proxy/Link2Bean.cpp index 24c40b0..1008eca 100644 --- a/src/configs/proxy/Link2Bean.cpp +++ b/src/configs/proxy/Link2Bean.cpp @@ -419,6 +419,28 @@ namespace Configs { return true; } + bool TailscaleBean::TryParseLink(const QString &link) + { + auto url = QUrl(link); + if (!url.isValid()) return false; + auto query = GetQuery(url); + name = url.fragment(QUrl::FullyDecoded); + + state_directory = QUrl::fromPercentEncoding(query.queryItemValue("state_directory").toUtf8()); + auth_key = QUrl::fromPercentEncoding(query.queryItemValue("auth_key").toUtf8()); + control_url = QUrl::fromPercentEncoding(query.queryItemValue("control_url").toUtf8()); + ephemeral = query.queryItemValue("ephemeral") == "true"; + hostname = QUrl::fromPercentEncoding(query.queryItemValue("hostname").toUtf8()); + accept_routes = query.queryItemValue("accept_routes") == "true"; + exit_node = query.queryItemValue("exit_node"); + exit_node_allow_lan_access = query.queryItemValue("exit_node_allow_lan_access") == "true"; + advertise_routes = QUrl::fromPercentEncoding(query.queryItemValue("advertise_routes").toUtf8()).split(","); + advertise_exit_node = query.queryItemValue("advertise_exit_node") == "true"; + globalDNS = query.queryItemValue("globalDNS") == "true"; + + return true; + } + bool SSHBean::TryParseLink(const QString &link) { auto url = QUrl(link); if (!url.isValid()) return false; diff --git a/src/dataStore/Database.cpp b/src/dataStore/Database.cpp index d796117..df33d8f 100644 --- a/src/dataStore/Database.cpp +++ b/src/dataStore/Database.cpp @@ -180,6 +180,8 @@ namespace Configs { bean = new Configs::AnyTLSBean(); } else if (type == "wireguard") { bean = new Configs::WireguardBean(Configs::WireguardBean()); + } else if (type == "tailscale") { + bean = new Configs::TailscaleBean(Configs::TailscaleBean()); } else if (type == "ssh") { bean = new Configs::SSHBean(Configs::SSHBean()); } else if (type == "custom") { diff --git a/src/ui/profile/dialog_edit_profile.cpp b/src/ui/profile/dialog_edit_profile.cpp index 1b426b2..ec94972 100644 --- a/src/ui/profile/dialog_edit_profile.cpp +++ b/src/ui/profile/dialog_edit_profile.cpp @@ -8,6 +8,7 @@ #include "include/ui/profile/edit_quic.h" #include "include/ui/profile/edit_anytls.h" #include "include/ui/profile/edit_wireguard.h" +#include "include/ui/profile/edit_tailscale.h" #include "include/ui/profile/edit_ssh.h" #include "include/ui/profile/edit_custom.h" #include "include/ui/profile/edit_extra_core.h" @@ -19,7 +20,6 @@ #include "include/global/GuiUtils.hpp" #include -#include #define ADJUST_SIZE runOnThread([=,this] { adjustSize(); adjustPosition(mainwindow); }, this); #define LOAD_TYPE(a) ui->type->addItem(Configs::ProfileManager::NewProxyEntity(a)->bean->DisplayType(), a); @@ -158,6 +158,7 @@ DialogEditProfile::DialogEditProfile(const QString &_type, int profileOrGroupId, LOAD_TYPE("tuic") LOAD_TYPE("anytls") LOAD_TYPE("wireguard") + LOAD_TYPE("tailscale") LOAD_TYPE("ssh") ui->type->addItem(tr("Custom (%1 outbound)").arg(software_core_name), "internal"); ui->type->addItem(tr("Custom (%1 config)").arg(software_core_name), "internal-full"); @@ -232,6 +233,10 @@ void DialogEditProfile::typeSelected(const QString &newType) { auto _innerWidget = new EditWireguard(this); innerWidget = _innerWidget; innerEditor = _innerWidget; + } else if (type == "tailscale") { + auto _innerWidget = new EditTailScale(this); + innerWidget = _innerWidget; + innerEditor = _innerWidget; } else if (type == "ssh") { auto _innerWidget = new EditSSH(this); innerWidget = _innerWidget; @@ -265,7 +270,7 @@ void DialogEditProfile::typeSelected(const QString &newType) { } // hide some widget - auto showAddressPort = type != "chain" && customType != "internal" && customType != "internal-full" && type != "extracore"; + auto showAddressPort = type != "chain" && customType != "internal" && customType != "internal-full" && type != "extracore" && type != "tailscale"; ui->address->setVisible(showAddressPort); ui->address_l->setVisible(showAddressPort); ui->port->setVisible(showAddressPort); diff --git a/src/ui/profile/edit_tailscale.cpp b/src/ui/profile/edit_tailscale.cpp new file mode 100644 index 0000000..6e4c3f4 --- /dev/null +++ b/src/ui/profile/edit_tailscale.cpp @@ -0,0 +1,46 @@ +#include "include/ui/profile/edit_tailscale.h" + +#include "include/configs/proxy/Tailscale.hpp" + +EditTailScale::EditTailScale(QWidget *parent) : QWidget(parent), ui(new Ui::EditTailScale) { + ui->setupUi(this); +} + +EditTailScale::~EditTailScale() { + delete ui; +} + +void EditTailScale::onStart(std::shared_ptr _ent) { + this->ent = _ent; + auto bean = this->ent->TailscaleBean(); + + ui->state_dir->setText(bean->state_directory); + ui->auth_key->setText(bean->auth_key); + ui->control_plane->setText(bean->control_url); + ui->ephemeral->setChecked(bean->ephemeral); + ui->hostname->setText(bean->hostname); + ui->accept_route->setChecked(bean->accept_routes); + ui->exit_node->setText(bean->exit_node); + ui->exit_node_lan_access->setChecked(bean->exit_node_allow_lan_access); + ui->advertise_routes->setText(bean->advertise_routes.join(",")); + ui->advertise_exit_node->setChecked(bean->advertise_exit_node); + ui->global_dns->setChecked(bean->globalDNS); +} + +bool EditTailScale::onEnd() { + auto bean = this->ent->TailscaleBean(); + + bean->state_directory = ui->state_dir->text(); + bean->auth_key = ui->auth_key->text(); + bean->control_url = ui->control_plane->text(); + bean->ephemeral = ui->ephemeral->isChecked(); + bean->hostname = ui->hostname->text(); + bean->accept_routes = ui->accept_route->isChecked(); + bean->exit_node = ui->exit_node->text(); + bean->exit_node_allow_lan_access = ui->exit_node_lan_access->isChecked(); + bean->advertise_routes = ui->advertise_routes->text().replace(" ", "").split(","); + bean->advertise_exit_node = ui->advertise_exit_node->isChecked(); + bean->globalDNS = ui->global_dns->isChecked(); + + return true; +}