feat(tailscale): нативный режим tailscaled и компонент Tailscale

- новый компонент Tailscale: установка пакета из фида, карточка в Обновлениях
- режим native для секций Tailscale: системный tailscaled вместо sing-box endpoint
- доступ к тейлнету с LAN без клиента: маршруты 100.64.0.0/10 и MagicDNS через dnsmasq
- masquerade и exit-node (opt-in) с обходом tproxy по зарезервированной маске
- validator: требование tailscaled и auth-key для native; bypass-правила в mangle
- тесты: генерация конфига, смоук рантайма с моками, обновлена сигнатура состояния
This commit is contained in:
Dushnilin
2026-08-26 11:30:52 +03:00
parent 3532fa4280
commit 7f0b085a07
27 changed files with 1138 additions and 22 deletions
+24
View File
@@ -3726,6 +3726,30 @@ msgid "Tachyon-managed nfqws2 runtime is ready"
msgstr "NFQWS2 Tachyon готов"
msgid "Tailscale auth key"
msgid "Tailscale mode"
msgstr "Режим Tailscale"
msgid "sing-box endpoint (userspace, default)"
msgstr "Endpoint в sing-box (userspace, по умолчанию)"
msgid "Native tailscaled (tailnet reachable from LAN without client)"
msgstr "Нативный tailscaled (тейлнет доступен из LAN без клиента)"
msgid "Masquerade LAN traffic into tailnet"
msgstr "Маскарадинг LAN-трафика в тейлнет"
msgid "SNAT LAN sources when leaving through tailscale0 so peers can answer. Required unless every peer accepts routes."
msgstr "Подменять LAN-адреса при выходе через tailscale0, чтобы пиры могли отвечать. Нужно, если не все пиры принимают маршруты."
msgid "Use tailnet exit node for LAN"
msgstr "Использовать exit-node тейлнета для LAN"
msgid "Route all non-local LAN traffic through the tailnet exit node instead of the proxy engines."
msgstr "Направлять весь не-локальный LAN-трафик через exit-node тейлнета вместо прокси-движков."
msgid "Tailscale"
msgstr "Tailscale"
msgstr "Ключ аутентификации Tailscale"
msgid "Tailscale control URL"
@@ -23,6 +23,9 @@ const componentActionKeyMap: Record<string, UpdatesActionKey> = {
'byedpi:check_update': 'byedpiCheck',
'byedpi:install': 'byedpiInstall',
'byedpi:remove': 'byedpiRemove',
'tailscale:check_update': 'tailscaleCheck',
'tailscale:install': 'tailscaleInstall',
'tailscale:remove': 'tailscaleRemove',
};
export function getComponentActionKey(
@@ -217,6 +217,8 @@ export interface StoreType {
zapret2_installed: number;
byedpi_version: string;
byedpi_installed: number;
tailscale_version: string;
tailscale_installed: number;
server_inbounds_enabled_count: number;
openwrt_version: string;
device_model: string;
@@ -241,6 +243,9 @@ export interface StoreType {
byedpiCheck: { loading: boolean };
byedpiInstall: { loading: boolean };
byedpiRemove: { loading: boolean };
tailscaleCheck: { loading: boolean };
tailscaleInstall: { loading: boolean };
tailscaleRemove: { loading: boolean };
};
updatesChecks: Record<
Tachyon.ComponentName,
@@ -24,6 +24,8 @@ const UNKNOWN_SYSTEM_INFO: StoreType['diagnosticsSystemInfo'] = {
zapret2_installed: 0,
byedpi_version: _('unknown'),
byedpi_installed: 0,
tailscale_version: _('unknown'),
tailscale_installed: 0,
server_inbounds_enabled_count: -1,
openwrt_version: _('unknown'),
device_model: _('unknown'),
@@ -32,6 +32,9 @@ function getEmptyUpdatesActions(): StoreType['updatesActions'] {
byedpiCheck: { loading: false },
byedpiInstall: { loading: false },
byedpiRemove: { loading: false },
tailscaleCheck: { loading: false },
tailscaleInstall: { loading: false },
tailscaleRemove: { loading: false },
};
}
@@ -1651,7 +1651,8 @@ async function renderSectionsWidget() {
outbounds: section.outbounds.map((outbound) => ({
...outbound,
latency:
SERVICE_TYPES.has(outbound.type) || !customProxyLatencies.has(outbound.code)
SERVICE_TYPES.has(outbound.type) ||
!customProxyLatencies.has(outbound.code)
? outbound.latency
: customProxyLatencies.get(outbound.code)!,
})),
@@ -94,6 +94,8 @@ export const initialDiagnosticStore: Pick<
zapret2_installed: 0,
byedpi_version: 'loading',
byedpi_installed: 0,
tailscale_version: 'loading',
tailscale_installed: 0,
server_inbounds_enabled_count: -1,
openwrt_version: 'loading',
device_model: 'loading',
@@ -158,6 +160,9 @@ export const initialDiagnosticStore: Pick<
byedpiCheck: { loading: false },
byedpiInstall: { loading: false },
byedpiRemove: { loading: false },
tailscaleCheck: { loading: false },
tailscaleInstall: { loading: false },
tailscaleRemove: { loading: false },
},
updatesChecks: {
tachyon: { status: null, latest_version: '', release_url: '' },
@@ -165,5 +170,6 @@ export const initialDiagnosticStore: Pick<
zapret: { status: null, latest_version: '', release_url: '' },
zapret2: { status: null, latest_version: '', release_url: '' },
byedpi: { status: null, latest_version: '', release_url: '' },
tailscale: { status: null, latest_version: '', release_url: '' },
},
};
@@ -62,6 +62,8 @@ function getComponentCardTitle(component: Tachyon.ComponentName): string {
return 'Zapret2';
case 'byedpi':
return 'ByeDPI';
case 'tailscale':
return 'Tailscale';
default:
return String(component);
}
@@ -82,6 +84,8 @@ function getComponentCurrentVersion(
return sys.zapret2_version;
case 'byedpi':
return sys.byedpi_version;
case 'tailscale':
return sys.tailscale_version;
default:
return undefined;
}
@@ -1003,6 +1007,8 @@ function getComponentInstallKey(
return 'zapret2Install';
case 'byedpi':
return 'byedpiInstall';
case 'tailscale':
return 'tailscaleInstall';
}
}
@@ -1038,7 +1044,7 @@ function getOptionalComponentActions({
installKey,
removeKey,
}: {
component: 'zapret' | 'zapret2' | 'byedpi';
component: 'zapret' | 'zapret2' | 'byedpi' | 'tailscale';
installed: boolean;
checkKey: UpdatesActionKey;
installKey: UpdatesActionKey;
@@ -1066,6 +1072,7 @@ const COMPONENT_REPO_URLS: Record<Tachyon.ComponentName, string> = {
zapret: 'https://github.com/remittor/zapret-openwrt',
zapret2: 'https://github.com/Dushnilin/zapret2-openwrt',
byedpi: 'https://github.com/DPITrickster/ByeDPI-OpenWrt',
tailscale: 'https://openwrt.org/packages/pkgdata/tailscale',
};
function getComponentCards(): ComponentCard[] {
@@ -1076,6 +1083,7 @@ function getComponentCards(): ComponentCard[] {
const zapretInstalled = Boolean(systemInfo.zapret_installed);
const zapret2Installed = Boolean(systemInfo.zapret2_installed);
const byedpiInstalled = Boolean(systemInfo.byedpi_installed);
const tailscaleInstalled = Boolean(systemInfo.tailscale_installed);
const singBoxInstalled = !isNotInstalled(systemInfo.sing_box_version);
const singBoxStable =
singBoxInstalled &&
@@ -1176,6 +1184,13 @@ function getComponentCards(): ComponentCard[] {
installKey: 'byedpiInstall',
removeKey: 'byedpiRemove',
});
const tailscaleActions = getOptionalComponentActions({
component: 'tailscale',
installed: tailscaleInstalled,
checkKey: 'tailscaleCheck',
installKey: 'tailscaleInstall',
removeKey: 'tailscaleRemove',
});
return [
{
@@ -1253,6 +1268,20 @@ function getComponentCards(): ComponentCard[] {
repoUrl: COMPONENT_REPO_URLS.byedpi,
actions: byedpiActions,
},
{
component: 'tailscale',
column: 1,
title: 'Tailscale',
version: systemInfoLoading
? _('Loading...')
: tailscaleInstalled
? systemInfo.tailscale_version
: _('Not installed'),
latestVersion: getLatestVersion('tailscale'),
releaseUrl: getGitHubReleaseUrl('tailscale'),
repoUrl: COMPONENT_REPO_URLS.tailscale,
actions: tailscaleActions,
},
];
}
+4 -1
View File
@@ -551,6 +551,8 @@ export namespace Tachyon {
zapret2_installed: 0 | 1;
byedpi_version: string;
byedpi_installed: 0 | 1;
tailscale_version: string;
tailscale_installed: 0 | 1;
openwrt_version: string;
device_model: string;
generated_at?: number;
@@ -627,7 +629,8 @@ export namespace Tachyon {
| 'sing_box'
| 'zapret'
| 'zapret2'
| 'byedpi';
| 'byedpi'
| 'tailscale';
export type ComponentAction =
| 'check_update'
@@ -5253,6 +5253,8 @@ var initialDiagnosticStore = {
zapret2_installed: 0,
byedpi_version: "loading",
byedpi_installed: 0,
tailscale_version: "loading",
tailscale_installed: 0,
server_inbounds_enabled_count: -1,
openwrt_version: "loading",
device_model: "loading"
@@ -5316,14 +5318,18 @@ var initialDiagnosticStore = {
zapret2Remove: { loading: false },
byedpiCheck: { loading: false },
byedpiInstall: { loading: false },
byedpiRemove: { loading: false }
byedpiRemove: { loading: false },
tailscaleCheck: { loading: false },
tailscaleInstall: { loading: false },
tailscaleRemove: { loading: false }
},
updatesChecks: {
tachyon: { status: null, latest_version: "", release_url: "" },
sing_box: { status: null, latest_version: "", release_url: "" },
zapret: { status: null, latest_version: "", release_url: "" },
zapret2: { status: null, latest_version: "", release_url: "" },
byedpi: { status: null, latest_version: "", release_url: "" }
byedpi: { status: null, latest_version: "", release_url: "" },
tailscale: { status: null, latest_version: "", release_url: "" }
}
};
@@ -5680,7 +5686,10 @@ var componentActionKeyMap = {
"zapret2:remove": "zapret2Remove",
"byedpi:check_update": "byedpiCheck",
"byedpi:install": "byedpiInstall",
"byedpi:remove": "byedpiRemove"
"byedpi:remove": "byedpiRemove",
"tailscale:check_update": "tailscaleCheck",
"tailscale:install": "tailscaleInstall",
"tailscale:remove": "tailscaleRemove"
};
function getComponentActionKey(component, action) {
return componentActionKeyMap[`${component}:${action}`];
@@ -5792,7 +5801,10 @@ function getEmptyUpdatesActions() {
zapret2Remove: { loading: false },
byedpiCheck: { loading: false },
byedpiInstall: { loading: false },
byedpiRemove: { loading: false }
byedpiRemove: { loading: false },
tailscaleCheck: { loading: false },
tailscaleInstall: { loading: false },
tailscaleRemove: { loading: false }
};
}
function getEmptyDiagnosticsActions() {
@@ -9715,6 +9727,8 @@ var UNKNOWN_SYSTEM_INFO = {
zapret2_installed: 0,
byedpi_version: _("unknown"),
byedpi_installed: 0,
tailscale_version: _("unknown"),
tailscale_installed: 0,
server_inbounds_enabled_count: -1,
openwrt_version: _("unknown"),
device_model: _("unknown")
@@ -16572,6 +16586,8 @@ function getComponentCardTitle(component) {
return "Zapret2";
case "byedpi":
return "ByeDPI";
case "tailscale":
return "Tailscale";
default:
return String(component);
}
@@ -16589,6 +16605,8 @@ function getComponentCurrentVersion(component) {
return sys.zapret2_version;
case "byedpi":
return sys.byedpi_version;
case "tailscale":
return sys.tailscale_version;
default:
return void 0;
}
@@ -17257,6 +17275,8 @@ function getComponentInstallKey(component) {
return "zapret2Install";
case "byedpi":
return "byedpiInstall";
case "tailscale":
return "tailscaleInstall";
}
}
function getComponentInstallAction(component) {
@@ -17300,7 +17320,8 @@ var COMPONENT_REPO_URLS = {
sing_box: "https://github.com/SagerNet/sing-box",
zapret: "https://github.com/remittor/zapret-openwrt",
zapret2: "https://github.com/Dushnilin/zapret2-openwrt",
byedpi: "https://github.com/DPITrickster/ByeDPI-OpenWrt"
byedpi: "https://github.com/DPITrickster/ByeDPI-OpenWrt",
tailscale: "https://openwrt.org/packages/pkgdata/tailscale"
};
function getComponentCards() {
const systemInfo = normalizeSingBoxVariantFields(
@@ -17310,6 +17331,7 @@ function getComponentCards() {
const zapretInstalled = Boolean(systemInfo.zapret_installed);
const zapret2Installed = Boolean(systemInfo.zapret2_installed);
const byedpiInstalled = Boolean(systemInfo.byedpi_installed);
const tailscaleInstalled = Boolean(systemInfo.tailscale_installed);
const singBoxInstalled = !isNotInstalled(systemInfo.sing_box_version);
const singBoxStable = singBoxInstalled && !systemInfo.sing_box_extended && !systemInfo.sing_box_tiny;
const singBoxExtended = Boolean(systemInfo.sing_box_extended) && !systemInfo.sing_box_compressed && !systemInfo.sing_box_lx;
@@ -17398,6 +17420,13 @@ function getComponentCards() {
installKey: "byedpiInstall",
removeKey: "byedpiRemove"
});
const tailscaleActions = getOptionalComponentActions({
component: "tailscale",
installed: tailscaleInstalled,
checkKey: "tailscaleCheck",
installKey: "tailscaleInstall",
removeKey: "tailscaleRemove"
});
return [
{
component: "tachyon",
@@ -17451,6 +17480,16 @@ function getComponentCards() {
releaseUrl: getGitHubReleaseUrl("byedpi"),
repoUrl: COMPONENT_REPO_URLS.byedpi,
actions: byedpiActions
},
{
component: "tailscale",
column: 1,
title: "Tailscale",
version: systemInfoLoading ? _("Loading...") : tailscaleInstalled ? systemInfo.tailscale_version : _("Not installed"),
latestVersion: getLatestVersion("tailscale"),
releaseUrl: getGitHubReleaseUrl("tailscale"),
repoUrl: COMPONENT_REPO_URLS.tailscale,
actions: tailscaleActions
}
];
}
@@ -538,11 +538,11 @@ function populateProtocolValues(option, capabilities) {
resetOptionValues(option);
if (normalized.singBoxTailscale) {
Object.entries(TAILSCALE_PROTOCOL_LABELS).forEach(([value, label]) => {
addOptionValue(option, value, label);
});
}
// Tailscale is offered unconditionally: the native tailscaled mode does not
// depend on the sing-box build, only on the installable Tailscale component.
Object.entries(TAILSCALE_PROTOCOL_LABELS).forEach(([value, label]) => {
addOptionValue(option, value, label);
});
Object.entries(BASE_PROTOCOL_LABELS).forEach(([value, label]) => {
addOptionValue(option, value, label);
@@ -3277,6 +3277,46 @@ function createServerContent(section, options = {}) {
o.rmempty = true;
addTailscaleDepends(o);
o = section.option(form.ListValue, "tailscale_mode", _("Tailscale mode"));
o.value(
"singbox",
_("sing-box endpoint (userspace, default)"),
);
o.value(
"native",
_("Native tailscaled (tailnet reachable from LAN without client)"),
);
o.default = "singbox";
o.modalonly = true;
o.rmempty = false;
addTailscaleDepends(o);
o = section.option(
form.Flag,
"tailscale_masquerade",
_("Masquerade LAN traffic into tailnet"),
_(
"SNAT LAN sources when leaving through tailscale0 so peers can answer. Required unless every peer accepts routes.",
),
);
o.default = "1";
o.modalonly = true;
o.rmempty = false;
o.depends({ protocol: "tailscale", tailscale_mode: "native" });
o = section.option(
form.Flag,
"tailscale_use_exit_node",
_("Use tailnet exit node for LAN"),
_(
"Route all non-local LAN traffic through the tailnet exit node instead of the proxy engines.",
),
);
o.default = "0";
o.modalonly = true;
o.rmempty = false;
o.depends({ protocol: "tailscale", tailscale_mode: "native" });
// AWG Server Options
o = section.option(
form.Value,
+24
View File
@@ -3726,6 +3726,30 @@ msgid "Tachyon-managed nfqws2 runtime is ready"
msgstr "NFQWS2 Tachyon готов"
msgid "Tailscale auth key"
msgid "Tailscale mode"
msgstr "Режим Tailscale"
msgid "sing-box endpoint (userspace, default)"
msgstr "Endpoint в sing-box (userspace, по умолчанию)"
msgid "Native tailscaled (tailnet reachable from LAN without client)"
msgstr "Нативный tailscaled (тейлнет доступен из LAN без клиента)"
msgid "Masquerade LAN traffic into tailnet"
msgstr "Маскарадинг LAN-трафика в тейлнет"
msgid "SNAT LAN sources when leaving through tailscale0 so peers can answer. Required unless every peer accepts routes."
msgstr "Подменять LAN-адреса при выходе через tailscale0, чтобы пиры могли отвечать. Нужно, если не все пиры принимают маршруты."
msgid "Use tailnet exit node for LAN"
msgstr "Использовать exit-node тейлнета для LAN"
msgid "Route all non-local LAN traffic through the tailnet exit node instead of the proxy engines."
msgstr "Направлять весь не-локальный LAN-трафик через exit-node тейлнета вместо прокси-движков."
msgid "Tailscale"
msgstr "Tailscale"
msgstr "Ключ аутентификации Tailscale"
msgid "Tailscale control URL"
+7
View File
@@ -200,11 +200,18 @@ config settings 'settings'
# option enabled '1'
# option protocol 'tailscale'
# option routing_mode 'rules'
# # tailscale_mode: singbox (userspace endpoint inside sing-box, default)
# # or native (system tailscaled: tailnet reachable from LAN without client,
# # requires Tailscale component installed on the Updates tab)
# option tailscale_mode 'singbox'
# option tailscale_control_url 'https://controlplane.tailscale.com'
# option tailscale_hostname 'tachyon-server-tailscale-example'
# option tailscale_auth_key 'tskey-auth-...'
# option tailscale_accept_routes '0'
# option tailscale_advertise_exit_node '1'
# # Native mode only:
# #option tailscale_masquerade '1'
# #option tailscale_use_exit_node '0'
# #list tailscale_advertise_routes '192.168.1.0/24'
config telegram 'telegram'
@@ -21,6 +21,7 @@ const COMPONENT_LOCK_DIR = getenv("UPDATES_LOCK_DIR") || RUNTIME_STATE_DIR + "/c
const TMP_STALE_TTL_MINUTES = getenv("UPDATES_TMP_STALE_TTL_MINUTES") || "30";
const TMP_FILE_STALE_TTL_MINUTES = getenv("UPDATES_TMP_FILE_STALE_TTL_MINUTES") || "10";
const SB_MANAGED_SERVICE_MARKER = getenv("SB_MANAGED_SERVICE_MARKER") || constants.SB_MANAGED_SERVICE_MARKER || "Tachyon managed sing-box service for binary variants";
const TAILSCALE_PACKAGE_URL = getenv("TAILSCALE_PACKAGE_URL") || "https://openwrt.org/packages/pkgdata/tailscale";
let as_string = common.as_string;
let shell_quote = common.shell_quote;
@@ -1302,6 +1303,35 @@ function install_byedpi(action) {
action_success("byedpi", action, "ByeDPI package has been installed", current_version, pkg.version, 1, "latest", release.release_url || "");
}
function install_tailscale(action) {
let component = "tailscale";
let label = "Tailscale";
let runtime_module = LIB_DIR + "/providers/tailscale/runtime.uc";
init_tmp_dir() || action_fail(component, action, "Failed to create temporary directory");
if (action == "check_update") {
if (!pkg_is_installed("tailscale")) {
run_logged("Refreshing package index", pkg_list_update_command());
let available_version = available_package_version("tailscale");
action_success(component, action, label + " is not installed", "", available_version, 0, "", TAILSCALE_PACKAGE_URL);
}
run_logged("Refreshing package index", pkg_list_update_command());
check_success(component, installed_package_version("tailscale"), available_package_version("tailscale"), TAILSCALE_PACKAGE_URL);
}
if (!run_logged("Refreshing package index", pkg_list_update_command()))
updates_log("Package index refresh failed; trying to install from the cached index", "warn");
if (!run_logged("Installing " + label + " package", pkg_install_name_command("tailscale")))
action_fail(component, "install", "Failed to install " + label + " package from the feed");
disable_standalone_service("tailscale");
restart_tachyon_after_successful_change();
clear_version_caches();
let current_version = provider_package_version(runtime_module);
if (current_version == "")
current_version = "unknown";
action_success(component, "install", label + " package has been installed", current_version, current_version, 1, "latest", TAILSCALE_PACKAGE_URL);
}
function remove_optional_component(component, package_name, label, runtime_module) {
if (!pkg_is_installed(package_name)) {
if (provider_installed(runtime_module))
@@ -2507,6 +2537,10 @@ function component_action(component, action) {
install_byedpi(action);
else if (component == "byedpi" && action == "remove")
remove_optional_component("byedpi", "byedpi", "ByeDPI", LIB_DIR + "/providers/byedpi/runtime.uc");
else if (component == "tailscale" && (action == "check_update" || action == "install"))
install_tailscale(action);
else if (component == "tailscale" && action == "remove")
remove_optional_component("tailscale", "tailscale", "Tailscale", LIB_DIR + "/providers/tailscale/runtime.uc");
else
action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Unknown component action");
}
+1 -1
View File
@@ -1370,7 +1370,7 @@ function normalize_component_name(component) {
function valid_component_name(component) {
component = normalize_component_name(component);
return component == "tachyon" || component == "sing_box" || component == "zapret" ||
component == "zapret2" || component == "byedpi";
component == "zapret2" || component == "byedpi" || component == "tailscale";
}
function component_update_check_cache_path(component) {
+24 -2
View File
@@ -1772,6 +1772,25 @@ function validate_runtime_config(context) {
let schedules = sections_by_type("schedule");
validate_runtime_mark_ranges_context(context);
// Native-mode Tailscale servers need the system tailscaled binary; the
// check lives here so every validation entry point (including fixtures)
// enforces it.
for (let ts_server in sections_by_type("server")) {
if (!server_enabled(ts_server))
continue;
if (option(ts_server, "protocol", "vless") != "tailscale")
continue;
if (option(ts_server, "tailscale_mode", "singbox") != "native")
continue;
// Without a key `tailscale up` falls back to interactive login and
// would block the runtime forever on a headless router.
if (trim(as_string(option(ts_server, "tailscale_auth_key", "") || "")) == "")
fail_requirement("Server '" + section_name(ts_server) + "' uses native Tailscale mode without an auth key. Generate one in the Tailscale admin console and fill in 'Tailscale auth key', or switch this server back to sing-box mode. Aborted.", "fatal");
if (!command_exists("tailscaled"))
fail_requirement("Server '" + section_name(ts_server) + "' uses native Tailscale mode, but tailscaled is not installed. Install the Tailscale component on the Updates tab, or switch this server back to sing-box mode. Aborted.", "fatal");
}
validate_dns_settings(settings, sections, context);
validate_list_update_settings(settings);
validate_http_url_option(option(settings, "latency_test_url", DEFAULT_LATENCY_TEST_URL) || DEFAULT_LATENCY_TEST_URL, "settings.latency_test_url");
@@ -2029,8 +2048,11 @@ function validate_extended_server_features(ctx, sing_box_version, sing_box_versi
if (protocol == "mtproto")
fail_requirement("Server '" + name + "' uses MTProto proxy, but sing-box-extended is not installed. Install sing-box-extended or disable this server. Aborted.", "fatal");
if (protocol == "tailscale" && !sing_box_supports_tailscale(ctx, sing_box_version, sing_box_version_output))
fail_requirement("Server '" + name + "' uses Tailscale, but the installed sing-box binary was built without Tailscale support. Install full sing-box or sing-box-extended, or disable this server. Aborted.", "fatal");
// Native-mode Tailscale runs tailscaled outside sing-box and does
// not need the with_tailscale build.
if (protocol == "tailscale" && option(section, "tailscale_mode", "singbox") != "native" &&
!sing_box_supports_tailscale(ctx, sing_box_version, sing_box_version_output))
fail_requirement("Server '" + name + "' uses Tailscale, but the installed sing-box binary was built without Tailscale support. Install full sing-box or sing-box-extended, switch this server to native Tailscale mode, or disable this server. Aborted.", "fatal");
if (transport == "xhttp" && !sing_box_supports_xhttp(ctx, sing_box_version, sing_box_version_output))
fail_requirement("Server '" + name + "' uses XHTTP transport, but the installed sing-box binary does not support it. Install sing-box-extended or a binary with XHTTP support, or change the transport. Aborted.", "fatal");
@@ -57,6 +57,7 @@ const SINGBOX_RUNTIME_UC = LIB_DIR + "/singbox/runtime.uc";
const ZAPRET_RUNTIME_UC = LIB_DIR + "/providers/zapret/runtime.uc";
const ZAPRET2_RUNTIME_UC = LIB_DIR + "/providers/zapret2/runtime.uc";
const BYEDPI_RUNTIME_UC = LIB_DIR + "/providers/byedpi/runtime.uc";
const TAILSCALE_RUNTIME_UC = LIB_DIR + "/providers/tailscale/runtime.uc";
const ZAPRET_VALIDATOR_UC = LIB_DIR + "/providers/zapret/validator.uc";
const ZAPRET2_VALIDATOR_UC = LIB_DIR + "/providers/zapret2/validator.uc";
@@ -1146,6 +1147,8 @@ function build_system_info() {
let zapret2_version = zapret2_installed ? provider_version(ZAPRET2_RUNTIME_UC) : "not installed";
let byedpi_installed = provider_installed(BYEDPI_RUNTIME_UC) ? 1 : 0;
let byedpi_version = byedpi_installed ? provider_version(BYEDPI_RUNTIME_UC) : "not installed";
let tailscale_installed = provider_installed(TAILSCALE_RUNTIME_UC) ? 1 : 0;
let tailscale_version = tailscale_installed ? provider_version(TAILSCALE_RUNTIME_UC) : "not installed";
let device_model = first_line_value("/tmp/sysinfo/model", "unknown");
return {
@@ -1166,6 +1169,8 @@ function build_system_info() {
zapret2_installed,
byedpi_version,
byedpi_installed,
tailscale_version,
tailscale_installed,
openwrt_version: openwrt_release(),
device_model,
generated_at: int(clock()[0])
+29 -2
View File
@@ -41,6 +41,20 @@ function uci_settings() {
return uci_section("settings");
}
// True when at least one enabled server section runs Tailscale in native
// (tailscaled) mode; those need tailnet bypass rules in the mangle chain.
function native_tailscale_enabled() {
for (let section in uci_sections("server")) {
if (as_string(section["enabled"]) == "0" || as_string(section["enabled"]) == "false")
continue;
if (as_string(section["protocol"] || "") != "tailscale")
continue;
if (as_string(section["tailscale_mode"] || "singbox") == "native")
return true;
}
return false;
}
function section_by_name(sections, section_name) {
section_name = as_string(section_name);
for (let section in sections)
@@ -1094,8 +1108,21 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
return false;
if (!nft_add_rule(table, "mangle", [ "ct", "status", "dnat", "return" ]) ||
!nft_add_rule(table, "mangle", [ "jump", "parental_control" ]) ||
!nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(localv4_set), "return" ]) ||
!nft_add_rule(table, "mangle", [ "jump", "parental_control" ]))
return false;
// Native Tailscale: tailnet-bound traffic and anything already marked by
// the Tailscale runtime (mask 0x00ff0000, see config validator) must not
// be captured into tproxy — it is routed to tailscale0 instead.
if (native_tailscale_enabled()) {
if (!nft_add_rule(table, "mangle", [ "meta", "mark", "&", "0x00ff0000", "!=", "0", "return" ]) ||
!nft_add_rule(table, "mangle", [ "ip", "daddr", "100.64.0.0/10", "return" ]) ||
!nft_add_rule(table, "mangle", [ "ip6", "daddr", "fd7a:115c:a1e0::/48", "return" ]))
return false;
log_debug("Native Tailscale bypass rules added to mangle chain");
}
if (!nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(localv4_set), "return" ]) ||
!nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "daddr", "@" + as_string(localv6_set), "return" ]))
return false;
@@ -0,0 +1,526 @@
#!/usr/bin/env ucode
let fs = require("fs");
let common = require("core.common");
let uci_core = require("core.uci");
// NOTE: ucode does not hoist function declarations - a callee must be
// textually defined above its first caller. Keep that order intact.
let as_string = common.as_string;
let bool_value = common.bool_value;
let write_json = common.write_json;
let shell_quote = common.shell_quote;
let write_file = common.write_file;
let command_from_args = common.command_from_args;
let command_output = common.command_output;
let command_output_from_args = common.command_output_from_args;
let command_status = common.command_status;
let command_success_from_args = common.command_success_from_args;
let object_or_empty = common.object_or_empty;
let option = common.option;
let parent_dir = common.parent_dir;
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
const TAILSCALE_BIN = getenv("TAILSCALE_BIN") || "/usr/sbin/tailscale";
const TAILSCALED_BIN = getenv("TAILSCALED_BIN") || "/usr/sbin/tailscaled";
const PERSISTENT_STATE_BASE = getenv("TAILSCALE_STATE_BASE") || "/etc/tachyon/tailscale";
const RUNTIME_DIR = getenv("TAILSCALE_RUNTIME_DIR") || "/var/run/tachyon/tailscale";
const TAILSCALED_PORT = getenv("TAILSCALED_PORT") || "41641";
const DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com";
const MAGICDNS_DNSMASQ_FILE = getenv("TAILSCALE_DNSMASQ_FILE") || "/tmp/dnsmasq.d/tachyon-tailscale.conf";
const MAGICDNS_ADDRESS = getenv("TAILSCALE_MAGICDNS_ADDRESS") || "100.100.100.100";
// Marks live inside the mask reserved by the config validator (0x00ff0000):
// no other Tachyon engine may allocate bits there.
const TS_MARK_MASK = getenv("TAILSCALE_MARK_MASK") || "0x00ff0000";
const TS_MARK_EXIT_NODE = getenv("TAILSCALE_MARK_EXIT") || "0x00200000";
const TS_ROUTE_TABLE = getenv("TAILSCALE_ROUTE_TABLE") || "4247";
const TS_EXIT_TABLE = getenv("TAILSCALE_EXIT_TABLE") || "4248";
const TS_RULE_PRIORITY = getenv("TAILSCALE_RULE_PRIORITY") || "100";
const TS_EXIT_RULE_PRIORITY = getenv("TAILSCALE_EXIT_RULE_PRIORITY") || "90";
// Tailnet CGNAT ranges every peer address lives in.
const TAILNET_V4 = "100.64.0.0/10";
const TAILNET_V6 = "fd7a:115c:a1e0::/48";
// Destinations that must never be pushed through an exit node tunnel.
const EXIT_NODE_EXCLUDES_V4 = [
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16",
"100.64.0.0/10", "127.0.0.0/8", "224.0.0.0/4", "240.0.0.0/4"
];
const EXIT_NODE_EXCLUDES_V6 = [ "fc00::/7", "fe80::/10", "ff00::/8" ];
const NFT_TS_TABLE = "tachyon_tailscale";
function log_message(message, level) {
level = as_string(level || "info");
command_success_from_args([ "logger", "-t", "tachyon", "[tailscale] [" + level + "] " + as_string(message) ]);
}
function bool_option(section, key, fallback) {
let value = object_or_empty(section)[key];
return value == null ? !!fallback : bool_value(value);
}
function list_option(section, key) {
let value = object_or_empty(section)[key];
if (value == null)
return [];
if (type(value) != "array")
value = [ value ];
let result = [];
for (let item in value) {
item = trim(as_string(item));
if (item != "")
push(result, item);
}
return result;
}
function section_name(section) {
return as_string(object_or_empty(section)[".name"]);
}
function uci_sections(type_name) {
return uci_core.section_objects(CONFIG_NAME, as_string(type_name));
}
function is_native_mode(section) {
return as_string(option(section, "tailscale_mode", "") || "") == "native";
}
function native_tailscale_sections() {
let result = [];
for (let section in uci_sections("server")) {
if (!bool_option(section, "enabled", true))
continue;
if (as_string(option(section, "protocol", "")) != "tailscale")
continue;
if (!is_native_mode(section))
continue;
push(result, section);
}
return result;
}
function safe_filename(name) {
name = as_string(name);
name = replace(name, /[^a-zA-Z0-9_-]/g, "_");
return name == "" ? "section" : name;
}
function section_state_dir(section) {
return PERSISTENT_STATE_BASE + "/" + safe_filename(section_name(section)) + "/native";
}
function section_runtime_dir(section) {
return RUNTIME_DIR + "/" + safe_filename(section_name(section));
}
function section_pid_file(section) {
return section_runtime_dir(section) + "/tailscaled.pid";
}
function command_exists(name) {
return command_success_from_args([ "command", "-v", name ]);
}
function binary_available() {
return command_exists("tailscaled") && command_exists("tailscale");
}
function read_pid(path) {
let raw = trim(as_string(fs.readfile(path)) || "");
if (raw == "" || int(raw) <= 0)
return 0;
return int(raw);
}
function pid_alive(pid) {
if (pid <= 0)
return false;
return command_success_from_args([ "kill", "-0", as_string(pid) ]);
}
function running_pid(section) {
return pid_alive(read_pid(section_pid_file(section)));
}
function package_installed() {
if (command_exists("apk") && command_success_from_args([ "apk", "info", "-e", "tailscale" ]))
return true;
if (!command_exists("opkg"))
return false;
for (let line in split(command_output_from_args([ "opkg", "list-installed" ]), "\n")) {
if (match(trim(as_string(line)), /^tailscale[ \t]+-/))
return true;
}
return false;
}
function daemon_version() {
if (!binary_available())
return "";
let out = trim(command_output_from_args([ "tailscale", "version" ]));
if (out == "")
return "";
return split(out, "\n")[0];
}
function ensure_dirs(section) {
let state_dir = section_state_dir(section);
let runtime_dir = section_runtime_dir(section);
if (!command_success_from_args([ "mkdir", "-p", state_dir, runtime_dir ]))
return false;
// The daemon stores node identity here; it must survive reboots and resets.
fs.chmod(state_dir, 448);
return true;
}
function run_step(description, args) {
let status = command_status(command_from_args(args));
if (status != 0) {
log_message(description + " failed (exit " + as_string(status) + ")", "warn");
return false;
}
log_message(description + ": ok", "debug");
return true;
}
function tailscale_client_args(section) {
let runtime_dir = section_runtime_dir(section);
return [ TAILSCALE_BIN, "--socket", runtime_dir + "/tailscaled.sock" ];
}
function remove_magicdns_dns() {
fs.unlink(MAGICDNS_DNSMASQ_FILE);
command_status(command_from_args([ "/etc/init.d/dnsmasq", "restart" ]) + " >/dev/null 2>&1");
}
function remove_kernel_routing(keep_dns) {
command_status(command_from_args([ "ip", "rule", "del", "to", TAILNET_V4,
"priority", TS_RULE_PRIORITY, "table", TS_ROUTE_TABLE ]) + " 2>/dev/null");
command_status(command_from_args([ "ip", "-6", "rule", "del", "to", TAILNET_V6,
"priority", TS_RULE_PRIORITY, "table", TS_ROUTE_TABLE ]) + " 2>/dev/null");
command_status(command_from_args([ "ip", "route", "flush", "table", TS_ROUTE_TABLE ]) + " 2>/dev/null");
command_status(command_from_args([ "ip", "-6", "route", "flush", "table", TS_ROUTE_TABLE ]) + " 2>/dev/null");
command_status(command_from_args([ "ip", "rule", "del", "fwmark", TS_MARK_EXIT_NODE + "/" + TS_MARK_MASK,
"priority", TS_EXIT_RULE_PRIORITY, "table", TS_EXIT_TABLE ]) + " 2>/dev/null");
command_status(command_from_args([ "ip", "route", "flush", "table", TS_EXIT_TABLE ]) + " 2>/dev/null");
command_status(command_from_args([ "nft", "delete", "table", "inet", NFT_TS_TABLE ]) + " 2>/dev/null");
if (!keep_dns)
remove_magicdns_dns();
}
function install_exit_node_routing() {
// Opt-in: forward all non-local LAN traffic through the tailnet exit node.
// Marked packets are skipped by Tachyon's mangle chain (mask-based return),
// so they never enter tproxy, then follow the dedicated routing table.
command_status(command_from_args([ "ip", "rule", "del", "fwmark", TS_MARK_EXIT_NODE + "/" + TS_MARK_MASK,
"priority", TS_EXIT_RULE_PRIORITY, "table", TS_EXIT_TABLE ]) + " 2>/dev/null");
command_status(command_from_args([ "ip", "route", "flush", "table", TS_EXIT_TABLE ]) + " 2>/dev/null");
let nft_lines = [
"table inet " + NFT_TS_TABLE + " {",
" set exit_exclude_v4 { type ipv4_addr; flags interval; elements = { " + join(", ", EXIT_NODE_EXCLUDES_V4) + " } }",
" set exit_exclude_v6 { type ipv6_addr; flags interval; elements = { " + join(", ", EXIT_NODE_EXCLUDES_V6) + " } }",
" chain prerouting_mark {",
" type filter hook prerouting priority -150; policy accept;",
" meta mark & " + TS_MARK_MASK + " != 0 return",
" ip daddr @exit_exclude_v4 return",
" ip6 daddr @exit_exclude_v6 return",
" meta mark set " + TS_MARK_EXIT_NODE + " comment \"tachyon-tailscale exit-node\"",
" }",
"}"
];
if (command_status("nft -f - <<'EOF'\n" + join("\n", nft_lines) + "\nEOF\n") != 0) {
log_message("Failed to install exit-node marking rules", "warn");
return false;
}
let ok =
run_step("Adding exit-node route rule", [ "ip", "rule", "add", "fwmark", TS_MARK_EXIT_NODE + "/" + TS_MARK_MASK,
"priority", TS_EXIT_RULE_PRIORITY, "table", TS_EXIT_TABLE ]) &&
run_step("Adding exit-node default route", [ "ip", "route", "add", "default", "dev", "tailscale0", "table", TS_EXIT_TABLE ]);
if (!ok)
log_message("Exit-node routing setup failed", "warn");
return ok;
}
function configure_firewall(section) {
// Masquerade LAN sources leaving through the tunnel unless the user opted
// out; without SNAT peers cannot answer addresses they don't know.
let masquerade = bool_option(section, "tailscale_masquerade", true);
let nft_lines = [
"table inet " + NFT_TS_TABLE + " {",
" chain postrouting_snat {",
" type nat hook postrouting priority 110; policy accept;",
" oifname \"tailscale0\" ip saddr " + TAILNET_V4 + " return",
" oifname \"tailscale0\" ip6 saddr " + TAILNET_V6 + " return"
];
if (masquerade)
push(nft_lines, " oifname \"tailscale0\" counter masquerade");
push(nft_lines, " }");
push(nft_lines, "}");
if (command_status("nft -f - <<'EOF'\n" + join("\n", nft_lines) + "\nEOF\n") != 0) {
log_message("Failed to install tailscale firewall rules", "warn");
return false;
}
return true;
}
function configure_magicdns_dns() {
// Hand *.ts.net to the tailnet resolver without letting tailscaled touch
// resolv.conf; dnsmasq picks the file up from its conf-dir.
if (!command_success_from_args([ "mkdir", "-p", parent_dir(MAGICDNS_DNSMASQ_FILE) ]))
return false;
let lines = [
"# Managed by Tachyon native Tailscale runtime",
"server=/ts.net/" + MAGICDNS_ADDRESS
];
if (!write_file(MAGICDNS_DNSMASQ_FILE, join("\n", lines) + "\n")) {
log_message("Failed to write MagicDNS dnsmasq config", "warn");
return false;
}
command_status(command_from_args([ "/etc/init.d/dnsmasq", "restart" ]) + " >/dev/null 2>&1");
return true;
}
function install_kernel_routing(section) {
// Steer tailnet-bound traffic into the tunnel before Tachyon's policy
// routing (fwmark rule at priority 105) can send it into tproxy.
// Start from a clean slate so restarts never stack duplicate rules.
remove_kernel_routing(true);
let installed =
run_step("Adding IPv4 tailnet route", [ "ip", "route", "add", TAILNET_V4, "dev", "tailscale0", "table", TS_ROUTE_TABLE ]) &&
run_step("Adding IPv6 tailnet route", [ "ip", "-6", "route", "add", TAILNET_V6, "dev", "tailscale0", "table", TS_ROUTE_TABLE ]) &&
run_step("Adding IPv4 route rule", [ "ip", "rule", "add", "to", TAILNET_V4, "priority", TS_RULE_PRIORITY, "table", TS_ROUTE_TABLE ]) &&
run_step("Adding IPv6 route rule", [ "ip", "-6", "rule", "add", "to", TAILNET_V6, "priority", TS_RULE_PRIORITY, "table", TS_ROUTE_TABLE ]);
if (!installed) {
log_message("Kernel routing setup failed for " + section_name(section), "warn");
return false;
}
if (bool_option(section, "tailscale_use_exit_node", false))
install_exit_node_routing();
configure_firewall(section);
configure_magicdns_dns();
return true;
}
function start_daemon(section) {
if (running_pid(section)) {
log_message("tailscaled for " + section_name(section) + " already running", "debug");
return true;
}
let runtime_dir = section_runtime_dir(section);
let socket_path = runtime_dir + "/tailscaled.sock";
let log_file = runtime_dir + "/tailscaled.log";
let state_dir = section_state_dir(section);
let args = [
TAILSCALED_BIN,
"--tun=tailscale0",
"--statedir=" + state_dir,
"--socket=" + socket_path,
"--port=" + TAILSCALED_PORT,
"--netfilter-mode=on"
];
let cmdline = command_from_args(args) +
" >" + shell_quote(log_file) + " 2>&1 </dev/null & echo $!";
let output = trim(command_output(cmdline));
let pid = int(output);
if (pid <= 0) {
log_message("Failed to spawn tailscaled for " + section_name(section), "warn");
return false;
}
write_file(section_pid_file(section), as_string(pid));
// Wait until the client socket answers before running `up`.
for (let attempt = 0; attempt < 30; attempt++) {
if (command_success_from_args([ TAILSCALE_BIN, "--socket", socket_path, "status", "--peers=false" ]))
return true;
if (!pid_alive(pid))
break;
command_success_from_args([ "sleep", "1" ]);
}
log_message("tailscaled did not become ready for " + section_name(section), "warn");
return false;
}
function bring_up(section) {
let args = tailscale_client_args(section);
push(args, "up");
push(args, "--authkey=" + as_string(option(section, "tailscale_auth_key", "") || ""));
let hostname = as_string(option(section, "tailscale_hostname", "") || "");
if (hostname != "")
push(args, "--hostname=" + hostname);
let control_url = as_string(option(section, "tailscale_control_url", DEFAULT_CONTROL_URL) || DEFAULT_CONTROL_URL);
if (control_url != "" && control_url != DEFAULT_CONTROL_URL)
push(args, "--login-server=" + control_url);
if (bool_option(section, "tailscale_accept_routes", false))
push(args, "--accept-routes");
let advertise_routes = list_option(section, "tailscale_advertise_routes");
if (length(advertise_routes) > 0)
push(args, "--advertise-routes=" + join(",", advertise_routes));
else
push(args, "--advertise-routes=");
if (bool_option(section, "tailscale_advertise_exit_node", false))
push(args, "--advertise-exit-node");
else
push(args, "--advertise-exit-node=false");
// DNS stays under dnsmasq/Tachyon control; MagicDNS names are resolved
// through the dedicated dnsmasq forward instead of resolv.conf takeover.
push(args, "--accept-dns=false");
// Never block the runtime on interactive prompts or dead control planes.
push(args, "--timeout=120s");
push(args, "--reset");
if (!run_step("tailscale up (" + section_name(section) + ")", args))
return false;
return install_kernel_routing(section);
}
function stop_section(section) {
let down_args = tailscale_client_args(section);
push(down_args, "down");
command_status(command_from_args(down_args) + " 2>/dev/null");
let pid_file = section_pid_file(section);
let pid = read_pid(pid_file);
if (pid_alive(pid)) {
command_success_from_args([ "kill", as_string(pid) ]);
for (let attempt = 0; attempt < 10 && pid_alive(pid); attempt++)
command_success_from_args([ "sleep", "1" ]);
if (pid_alive(pid))
command_success_from_args([ "kill", "-9", as_string(pid) ]);
}
fs.unlink(pid_file);
}
function backend_state() {
if (!binary_available())
return "";
let out = trim(command_output_from_args([ TAILSCALE_BIN, "status", "--json", "--peers=false" ]));
if (out == "")
return "";
let parsed = json(out);
return as_string(object_or_empty(parsed)["BackendState"] || "");
}
function status_json() {
let sections = native_tailscale_sections();
let configured = length(sections) > 0;
let available = binary_available();
let pkg = package_installed();
let version = available ? (daemon_version() || "unknown") : "not installed";
let running = 0;
let states = {};
for (let section in sections) {
if (running_pid(section)) {
running++;
let state = backend_state();
if (state != "")
states[section_name(section)] = state;
}
}
let message = "tailscale provider status is normal";
if (configured && !available)
message = "native Tailscale is configured, but the tailscale package is missing; install it on the Updates tab";
else if (configured && running < length(sections))
message = "native Tailscale is configured, but tailscaled is not running for every section";
else if (!configured && available)
message = "Tailscale package is installed, but no server section uses native mode";
else if (!configured && !available)
message = "tailscale package is not installed; native Tailscale is unavailable";
write_json({
installed: available,
package_installed: pkg,
provider_available: available,
provider_path: TAILSCALED_BIN,
version,
configured,
expected_process_count: length(sections),
running_process_count: running,
backend_states: states,
ready: configured && available && running == length(sections) && length(sections) > 0,
conflict: running > length(sections),
status_message: message
});
}
function check_json() {
write_json({
tailscale_installed: binary_available(),
tailscale_package_installed: package_installed(),
tailscale_provider_path: TAILSCALED_BIN
});
}
function stop_runtime() {
for (let section in native_tailscale_sections())
stop_section(section);
// Also clean leftovers for sections that disappeared from the config.
let entries = null;
try {
entries = fs.readdir(RUNTIME_DIR);
} catch (e) {
entries = null;
}
if (entries != null) {
for (let entry in entries) {
let pid_file = RUNTIME_DIR + "/" + as_string(entry) + "/tailscaled.pid";
let pid = read_pid(pid_file);
if (pid_alive(pid))
command_success_from_args([ "kill", as_string(pid) ]);
}
}
remove_kernel_routing(false);
return 0;
}
function start_runtime() {
let sections = native_tailscale_sections();
if (length(sections) == 0) {
// Nothing configured: make sure nothing is left over from a removed section.
stop_runtime();
return 0;
}
if (!binary_available()) {
log_message("tailscale binaries are missing; install the Tailscale component first", "warn");
return 1;
}
let failures = 0;
for (let section in sections) {
if (!ensure_dirs(section) || !start_daemon(section) || !bring_up(section))
failures++;
}
return failures > 0 ? 1 : 0;
}
let mode = ARGV[0] || "";
if (mode == "start-runtime")
exit(start_runtime());
else if (mode == "stop-runtime")
exit(stop_runtime());
else if (mode == "status")
status_json();
else if (mode == "check")
check_json();
else if (mode == "installed" || mode == "provider-available")
exit(binary_available() ? 0 : 1);
else if (mode == "package-installed")
exit(package_installed() ? 0 : 1);
else if (mode == "package-version")
print(daemon_version(), "\n");
else if (mode == "native-section-count")
print(length(native_tailscale_sections()), "\n");
else {
warn("Usage: providers/tailscale/runtime.uc <start-runtime|stop-runtime|status|check|installed|package-installed|package-version|native-section-count>\n");
exit(1);
}
+7 -1
View File
@@ -119,6 +119,7 @@ const DIAGNOSTICS_UC = LIB_DIR + "/diagnostics/runtime.uc";
const ZAPRET_UC = LIB_DIR + "/providers/zapret/runtime.uc";
const ZAPRET2_UC = LIB_DIR + "/providers/zapret2/runtime.uc";
const BYEDPI_UC = LIB_DIR + "/providers/byedpi/runtime.uc";
const TAILSCALE_UC = LIB_DIR + "/providers/tailscale/runtime.uc";
const PACKAGES_UC = LIB_DIR + "/core/packages.uc";
const WATCHDOG_UC = LIB_DIR + "/service/watchdog.uc";
const TELEGRAM_UC = LIB_DIR + "/service/telegram.uc";
@@ -891,6 +892,8 @@ function stop_main() {
log_message("Zapret2 stop failed (non-fatal)", "warn");
if (!module_success(BYEDPI_UC, [ "stop-runtime" ]))
log_message("ByeDPI stop failed (non-fatal)", "warn");
if (!module_success(TAILSCALE_UC, [ "stop-runtime" ]))
log_message("Tailscale stop failed (non-fatal)", "warn");
if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) {
if (!command_success_from_args([ "nft", "delete", "table", "inet", NFT_TABLE_NAME ]))
@@ -1370,7 +1373,10 @@ function reload(reason) {
if (plan.needs_zapret2_restart == 1)
module_success(ZAPRET2_UC, [ "start-runtime" ]);
if (plan.needs_byedpi_restart == 1)
module_success(BYEDPI_UC, [ "start-runtime" ]);
module_success(BYEDPI_UC, [ "start-runtime" ]);
// Native Tailscale must be up before sing-box so tailnet routes win over
// policy routing from the very first packet.
module_success(TAILSCALE_UC, [ "start-runtime" ]);
if (plan.needs_dnsmasq_configure == 1) {
status = dnsmasq_configure(true);
+3
View File
@@ -1492,6 +1492,9 @@ function append_sing_box_server_signature_body(body, server) {
[ "tailscale_auth_key", "" ],
[ "tailscale_control_url", "" ],
[ "tailscale_hostname", "" ],
[ "tailscale_mode", "singbox" ],
[ "tailscale_masquerade", "1" ],
[ "tailscale_use_exit_node", "0" ],
[ "tailscale_accept_routes", "" ],
[ "tailscale_advertise_routes", "" ],
[ "tailscale_advertise_exit_node", "" ]
@@ -1498,6 +1498,9 @@ function section_by_name(sections, name) {
function add_server_routes(config, servers, sections) {
for (let server in servers) {
// Native-mode Tailscale runs outside sing-box: no inbound, no rules.
if (runtime_servers.is_native_tailscale(server))
continue;
runtime_servers.add_sniff_rule(config, server);
let inbound = runtime_constants.server_inbound_tag(server[".name"]);
+15 -2
View File
@@ -416,9 +416,18 @@ function add_tailscale_endpoint(config, section, tag_name) {
push(config.endpoints, endpoint);
}
function is_native_tailscale(section) {
return option(section, "protocol", "vless") == "tailscale" &&
option(section, "tailscale_mode", "singbox") == "native";
}
function add_dns_bypass(config, section) {
if (option(section, "protocol", "vless") != "tailscale")
return;
// Native mode runs tailscaled outside sing-box; MagicDNS is served by
// dnsmasq instead of a sing-box DNS server.
if (is_native_tailscale(section))
return;
let section_name = section[".name"];
let inbound = runtime_constants.server_inbound_tag(section_name);
@@ -443,8 +452,11 @@ function add_server(config, section) {
let protocol = option(section, "protocol", "vless");
let tag_name = runtime_constants.server_inbound_tag(section_name);
if (protocol == "tailscale")
if (protocol == "tailscale") {
if (is_native_tailscale(section))
return;
add_tailscale_endpoint(config, section, tag_name);
}
else if (protocol == "json_inbound")
add_json_inbound(config, section, tag_name);
else
@@ -517,5 +529,6 @@ function clone_rules_for_inbound(config, source_inbound, target_inbound, skip_do
return {
add_server,
add_sniff_rule,
clone_rules_for_inbound
clone_rules_for_inbound,
is_native_tailscale
};
File diff suppressed because one or more lines are too long
+6
View File
@@ -993,6 +993,12 @@ ws
[server.srv1.tailscale_hostname]
[server.srv1.tailscale_mode]
singbox
[server.srv1.tailscale_masquerade]
1
[server.srv1.tailscale_use_exit_node]
0
[server.srv1.tailscale_accept_routes]
1
[server.srv1.tailscale_advertise_routes]
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
GENERATOR_UC="$TACHYON_LIB/singbox/generator.uc"
VALIDATOR_UC="$TACHYON_LIB/config/validator.uc"
RUNTIME_UC="$TACHYON_LIB/providers/tailscale/runtime.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
generate_config() {
local fixture="$1"
local output="$2"
mkdir -p "${output}.section-cache"
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$fixture" "$output" "127.0.0.1"
}
make_fixture() {
local mode="$1"
cat >"$WORK_DIR/ts_$mode.json" <<JSON
{
"settings": {
".name": "settings",
".type": "settings",
"dns_server": [ "77.88.8.8" ],
"bootstrap_dns_server": [ "77.88.8.8" ]
},
"server": [
{
".name": "ts_native",
".type": "server",
"label": "Tailscale",
"enabled": "1",
"protocol": "tailscale",
"routing_mode": "rules",
"tailscale_mode": "$mode",
"tailscale_hostname": "tachyon-ts",
"tailscale_auth_key": "tskey-auth-secret"
}
],
"section": [
{
".name": "proxy",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [
"{\"type\":\"vless\",\"tag\":\"Alpha\",\"server\":\"alpha.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000001\",\"tls\":{\"enabled\":true}}"
]
}
]
}
JSON
}
# Native mode: no sing-box endpoint and no MagicDNS DNS server must appear.
make_fixture native
generate_config "$WORK_DIR/ts_native.json" "$WORK_DIR/native.out"
grep -Fq '"type": "tailscale"' "$WORK_DIR/native.out" &&
fail "native mode must not emit a tailscale endpoint"
grep -Fq 'tailscale-dns' "$WORK_DIR/native.out" &&
fail "native mode must not emit a tailscale DNS server"
# Default (singbox) mode: endpoint present.
make_fixture singbox
generate_config "$WORK_DIR/ts_singbox.json" "$WORK_DIR/singbox.out"
grep -Fq '"type": "tailscale"' "$WORK_DIR/singbox.out" ||
fail "singbox mode must emit a tailscale endpoint"
# Validator: native mode without tailscaled binary fails with install hint,
# even when sing-box lacks Tailscale support. fail_requirement() reports
# through `logger`, so a recording stub is installed on PATH.
STUB_BIN="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/logger.log"
mkdir -p "$STUB_BIN"
cat >"$STUB_BIN/logger" <<SH
#!/usr/bin/env sh
printf '%s\n' "\$*" >> '$LOG_FILE'
exit 0
SH
chmod +x "$STUB_BIN/logger"
validate() {
: >"$LOG_FILE"
PATH="$STUB_BIN:$PATH" ucode -L "$TACHYON_LIB" "$VALIDATOR_UC" \
validate-runtime-fixture "$1" "{}"
}
if validate "$WORK_DIR/ts_native.json"; then
fail "validator must reject native mode without tailscaled"
fi
grep -Fq "tailscaled is not installed" "$LOG_FILE" ||
fail "validator must require the Tailscale component for native mode"
# With tailscaled available the validator passes for native mode.
cat >"$STUB_BIN/tailscaled" <<'SH'
#!/usr/bin/env sh
exit 0
SH
chmod +x "$STUB_BIN/tailscaled"
validate "$WORK_DIR/ts_native.json" ||
fail "validator must accept native mode with tailscaled available"
grep -Fq "tailscaled is not installed" "$LOG_FILE" &&
fail "validator rejected native mode even though tailscaled is available"
# Native mode without an auth key is rejected: `tailscale up` would fall back
# to an interactive login and hang the runtime on a headless router.
sed 's/"tailscale_auth_key": "tskey-auth-secret"//' \
"$WORK_DIR/ts_native.json" >"$WORK_DIR/ts_nokey.json"
if PATH="$STUB_BIN:$PATH" ucode -L "$TACHYON_LIB" "$VALIDATOR_UC" \
validate-runtime-fixture "$WORK_DIR/ts_nokey.json" "{}" >/dev/null 2>&1; then
fail "validator must reject native mode without an auth key"
fi
grep -Fq "without an auth key" "$LOG_FILE" ||
fail "validator message about missing auth key not reported"
# Runtime provider module: CLI surface responds without a config.
OUTPUT=$(ucode -L "$TACHYON_LIB" "$RUNTIME_UC" package-version)
[ "$OUTPUT" = "" ] || fail "package-version should be empty without binaries, got: $OUTPUT"
ucode -L "$TACHYON_LIB" "$RUNTIME_UC" status >/dev/null ||
fail "status mode must always produce JSON"
printf 'config tailscale native mode checks passed\n'
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Smoke test for providers/tailscale/runtime.uc: runs the real start/stop
# runtime paths against stubbed tailscale/tailscaled binaries and verifies
# every kernel/nftables/dnsmasq side effect appears and is cleaned up.
# Requires a privileged OpenWrt container (ip rule, nft tables).
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
RUNTIME_UC="$TACHYON_LIB/providers/tailscale/runtime.uc"
if [ "$(id -u)" != "0" ]; then
printf 'skipped: requires root\n'
exit 0
fi
command -v nft >/dev/null || { printf 'skipped: nft not available\n'; exit 0; }
ip link add tailscale0 type dummy 2>/dev/null ||
{ printf 'skipped: cannot create dummy interface\n'; exit 0; }
ip link set tailscale0 up
WORK_DIR="$(mktemp -d)"
TS_LOG="$WORK_DIR/ts.log"
cleanup() {
PATH="$STUB_BIN:$PATH" ucode -L "$TACHYON_LIB" "$RUNTIME_UC" stop-runtime >/dev/null 2>&1 || true
ip link del tailscale0 2>/dev/null || true
rm -rf "$WORK_DIR" "$STATE_FILE"
}
trap cleanup EXIT
STUB_BIN="$WORK_DIR/bin"
mkdir -p "$STUB_BIN"
ip link set tailscale0 up
cat >"$STUB_BIN/tailscaled" <<EOF
#!/bin/sh
printf 'scaled %s\n' "\$*" >> '$TS_LOG'
exec sleep 3600
EOF
cat >"$STUB_BIN/tailscale" <<EOF
#!/bin/sh
printf 'cli %s\n' "\$*" >> '$TS_LOG'
case "\$*" in
*version*) echo "1.80.3-smoke" ;;
*) exit 0 ;;
esac
EOF
chmod +x "$STUB_BIN/tailscaled" "$STUB_BIN/tailscale"
STATE_FILE="$WORK_DIR/uci.state"
cat >"$STATE_FILE" <<'EOF'
tachyon.settings=settings
tachyon.settings.dns_server=77.88.8.8
tachyon.ts_native=server
tachyon.ts_native.label=Tailscale native
tachyon.ts_native.enabled=1
tachyon.ts_native.protocol=tailscale
tachyon.ts_native.tailscale_mode=native
tachyon.ts_native.routing_mode=rules
tachyon.ts_native.tailscale_auth_key=tskey-auth-smoke
tachyon.ts_native.tailscale_hostname=tachyon-smoke
EOF
export UCI_STATE="$STATE_FILE"
export TACHYON_CONFIG_NAME="tachyon"
export PATH="$STUB_BIN:$PATH"
export TAILSCALED_BIN="$STUB_BIN/tailscaled"
export TAILSCALE_BIN="$STUB_BIN/tailscale"
export TAILSCALE_STATE_BASE="$WORK_DIR/state"
export TAILSCALE_RUNTIME_DIR="$WORK_DIR/run"
export TAILSCALE_DNSMASQ_FILE="$WORK_DIR/dnsmasq.d/tachyon-tailscale.conf"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
run() {
ucode -L "$TACHYON_LIB" "$RUNTIME_UC" "$@"
}
# --- version reporting ------------------------------------------------------
[ "$(run package-version)" = "1.80.3-smoke" ] || fail "package-version output"
run installed || fail "installed must succeed with stubs present"
# --- start ------------------------------------------------------------------
run start-runtime || fail "start-runtime exited non-zero"
PID_FILE="$WORK_DIR/run/ts_native/tailscaled.pid"
[ -f "$PID_FILE" ] || fail "pid file was not written"
PID="$(cat "$PID_FILE")"
kill -0 "$PID" 2>/dev/null || fail "tailscaled pid is not alive"
grep -Fq "cli --socket $WORK_DIR/run/ts_native/tailscaled.sock up" "$TS_LOG" ||
fail "tailscale up was not invoked through the section socket"
grep -Fq -e "--authkey=tskey-auth-smoke" "$TS_LOG" ||
fail "auth key was not passed to tailscale up"
grep -Fq -e "--accept-dns=false" "$TS_LOG" ||
fail "up must disable tailscaled DNS takeover"
grep -Fq -e "--timeout=120s" "$TS_LOG" ||
fail "up must carry a hard timeout"
ip rule list | grep -Fq "100.64.0.0/10" || fail "tailnet ip rule missing"
ip rule list | grep -Fq "lookup 4247" || fail "route table rule missing"
[ "$(ip route show table 4247 | grep -cF 'dev tailscale0')" -ge 1 ] ||
fail "tailnet route missing from table 4247"
nft list table inet tachyon_tailscale >/dev/null 2>&1 ||
fail "nft table tachyon_tailscale was not created"
nft list table inet tachyon_tailscale | grep -Fq "masquerade" ||
fail "masquerade rule missing (default on)"
[ -f "$WORK_DIR/dnsmasq.d/tachyon-tailscale.conf" ] ||
fail "MagicDNS dnsmasq config was not written"
grep -Fq "server=/ts.net/100.100.100.100" \
"$WORK_DIR/dnsmasq.d/tachyon-tailscale.conf" ||
fail "MagicDNS forward entry missing"
STATUS="$(run status)"
printf '%s' "$STATUS" | grep -Fq '"configured": true' ||
fail "status must report configured=true, got: $STATUS"
printf '%s' "$STATUS" | grep -Fq '"ready": true' ||
fail "status must report ready=true, got: $STATUS"
# --- idempotent restart -----------------------------------------------------
run start-runtime || fail "second start-runtime failed"
RULE_COUNT=$(ip rule list | grep -cF "lookup 4247")
[ "$RULE_COUNT" = "1" ] || fail "restart duplicated route rules ($RULE_COUNT)"
# --- stop -------------------------------------------------------------------
run stop-runtime || fail "stop-runtime exited non-zero"
kill -0 "$PID" 2>/dev/null && fail "tailscaled pid survived stop-runtime"
ip rule list | grep -Fq "100.64.0.0/10" && fail "tailnet ip rule survived stop"
ip route show table 4247 | grep -q . && fail "route table 4247 survived stop"
nft list table inet tachyon_tailscale >/dev/null 2>&1 &&
fail "nft table survived stop"
[ -f "$WORK_DIR/dnsmasq.d/tachyon-tailscale.conf" ] &&
fail "dnsmasq config survived stop"
# --- exit node mode ---------------------------------------------------------
printf '%s\n' 'tachyon.ts_native.tailscale_use_exit_node=1' >>"$STATE_FILE"
run start-runtime || fail "start-runtime with exit node failed"
ip rule list | grep -Fq "lookup 4248" || fail "exit-node ip rule missing"
[ "$(ip route show table 4248 | grep -cF 'default dev tailscale0')" = "1" ] ||
fail "exit-node default route missing"
nft list chain inet tachyon_tailscale prerouting_mark >/dev/null 2>&1 ||
fail "exit-node marking chain missing"
run stop-runtime || fail "stop-runtime after exit node failed"
ip rule list | grep -Fq "lookup 4248" && fail "exit-node rule survived stop"
printf 'tailscale native runtime smoke checks passed\n'