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:
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user