add tailscale (#646)

This commit is contained in:
Mahdi 2025-08-13 01:25:56 -07:00 committed by GitHub
parent 4995570164
commit e288bfb476
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 373 additions and 23 deletions

View File

@ -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

View File

@ -50,6 +50,8 @@ namespace Configs {
virtual CoreObjOutboundBuildResult BuildCoreObjSingBox() { return {}; };
virtual QString ToShareLink() { return {}; };
virtual bool IsEndpoint() { return false; };
};
} // namespace Configs

View File

@ -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

View File

@ -70,5 +70,7 @@ namespace Configs {
bool TryParseJson(const QJsonObject &obj);
QString ToShareLink() override;
bool IsEndpoint() override {return true;}
};
} // namespace Configs

View File

@ -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"

View File

@ -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();
};

View File

@ -0,0 +1,28 @@
#pragma once
#include <QWidget>
#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<Configs::ProxyEntity> _ent) override;
bool onEnd() override;
private:
Ui::EditTailScale *ui;
std::shared_ptr<Configs::ProxyEntity> ent;
};

View File

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>EditTailScale</class>
<widget class="QWidget" name="EditTailScale">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>385</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>State directory</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="state_dir"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Auth key</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="auth_key"/>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Control URL</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLineEdit" name="control_plane"/>
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Hostname</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QLineEdit" name="hostname"/>
</item>
<item row="13" column="0">
<widget class="QCheckBox" name="accept_route">
<property name="text">
<string>Accept routes</string>
</property>
</widget>
</item>
<item row="12" column="0">
<widget class="QCheckBox" name="ephemeral">
<property name="text">
<string>Ephemeral</string>
</property>
</widget>
</item>
<item row="14" column="0">
<widget class="QCheckBox" name="exit_node_lan_access">
<property name="text">
<string>Exit node allow lan access</string>
</property>
</widget>
</item>
<item row="15" column="0">
<widget class="QCheckBox" name="advertise_exit_node">
<property name="text">
<string>Advertise exit node</string>
</property>
</widget>
</item>
<item row="9" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Exit node</string>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QLineEdit" name="exit_node"/>
</item>
<item row="10" column="0">
<widget class="QLabel" name="label_6">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;comma seperated list of subnets&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Advertise routes</string>
</property>
</widget>
</item>
<item row="10" column="1">
<widget class="QLineEdit" name="advertise_routes"/>
</item>
<item row="11" column="0">
<widget class="QCheckBox" name="global_dns">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;use tailscale dns as the remote dns&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Global DNS</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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");

View File

@ -1,15 +1,4 @@
#include <include/configs/proxy/QUICBean.hpp>
#include <include/configs/proxy/ShadowSocksBean.hpp>
#include <include/configs/proxy/SocksHttpBean.hpp>
#include <include/configs/proxy/TrojanVLESSBean.hpp>
#include <include/configs/proxy/VMessBean.hpp>
#include <include/configs/proxy/AnyTLSBean.hpp>
#include <include/configs/proxy/WireguardBean.h>
#include "include/configs/proxy/ExtraCore.h"
#include "include/configs/proxy/SSHBean.h"
#include <include/configs/proxy/includes.h>
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;

View File

@ -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;

View File

@ -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") {

View File

@ -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 <QInputDialog>
#include <QToolTip>
#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);

View File

@ -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<Configs::ProxyEntity> _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;
}