Files
NeoTachyon/tachyon/files/usr/lib/providers/tailscale/runtime.uc
T
Dushnilin 6a85384111 fix(tailscale): готовность демона, флаги tailscaled и нейтрализация standalone-сервиса
- удалён неподдерживаемый флаг --netfilter-mode=on, вызывавший ошибку старта на OpenWrt
- проба готовности парсит status --json (BackendState), а не exit-code: незалогиненный узел отвечает NeedsLogin с non-zero и раньше считался неготовым — корень «did not become ready» при первом входе
- перед стартом своего tailscaled останавливается и отключается init-скрипт официального пакета: его демон держал UDP 41641, из-за чего наш умирал мгновенно
- при сбое старта в лог пишется хвост tailscaled.log для диагностики
2026-08-26 19:03:16 +03:00

623 lines
23 KiB
Ucode

#!/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 object_key_count = common.object_key_count;
let array_or_empty = common.array_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;
}
// The official package ships its own init script; if it autostarted at boot,
// its daemon holds UDP 41641 and ours dies instantly. Neutralize it the same
// way component install does - stop + disable, idempotent.
function file_executable(path) {
let st = fs.stat(path);
return st != null && st.mode != null && (int(st.mode) & 73) != 0;
}
function neutralize_standalone_service() {
let init = "/etc/init.d/tailscale";
if (!file_executable(init))
return;
command_status(command_from_args([ init, "stop" ]) + " >/dev/null 2>&1");
command_status(command_from_args([ init, "disable" ]) + " >/dev/null 2>&1");
log_message("standalone tailscale service stopped and disabled", "info");
}
function log_file_tail(path) {
let data = "";
try {
data = as_string(fs.readfile(path)) || "";
} catch (e) {
return "";
}
let lines = split(trim(data), "\n");
let start = length(lines) > 5 ? length(lines) - 5 : 0;
let tail = [];
for (let i = start; i < length(lines); i++)
push(tail, lines[i]);
return join(" | ", tail);
}
function start_daemon(section) {
if (running_pid(section)) {
log_message("tailscaled for " + section_name(section) + " already running", "debug");
return true;
}
neutralize_standalone_service();
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
];
fs.unlink(log_file);
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`. Probe parses
// JSON instead of trusting the exit code: an unlogged-in node answers
// with BackendState=NeedsLogin and a non-zero exit code, which must still
// count as "daemon ready".
for (let attempt = 0; attempt < 30; attempt++) {
let probe = command_output_from_args([ TAILSCALE_BIN, "--socket", socket_path, "status", "--json", "--peers=false" ]);
if (index(as_string(probe), "\"BackendState\"") >= 0)
return true;
if (!pid_alive(pid))
break;
command_success_from_args([ "sleep", "1" ]);
}
let tail = log_file_tail(log_file);
if (!pid_alive(pid))
log_message("tailscaled for " + section_name(section) + " exited during startup; log tail: " + (tail == "" ? "<empty>" : tail), "warn");
else
log_message("tailscaled did not become ready for " + section_name(section) + "; log tail: " + (tail == "" ? "<empty>" : tail), "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 peer_entry(peer) {
peer = object_or_empty(peer);
return {
name: as_string(peer["HostName"] || ""),
dns_name: as_string(peer["DNSName"] || ""),
ips: array_or_empty(peer["TailscaleIPs"]),
online: peer["Online"] == true
};
}
// Tailnet peers of the first native section, via its per-section socket.
// All Tachyon-managed sections are separate nodes; the dashboard shows the
// first one (users run a single native section in practice).
function peers_json() {
let sections = native_tailscale_sections();
if (!binary_available() || length(sections) == 0) {
write_json({ configured: false, backend_state: "", self: null, peers: [] });
return;
}
let args = tailscale_client_args(sections[0]);
push(args, "status");
push(args, "--json");
let out = trim(command_output_from_args(args));
let backend_state = "";
let self_entry = null;
let peers = [];
if (out != "") {
let parsed = object_or_empty(json(out));
backend_state = as_string(parsed["BackendState"] || "");
let self = object_or_empty(parsed["Self"]);
if (object_key_count(self) > 0) {
self_entry = peer_entry(self);
self_entry.online = true;
}
let peer_map = object_or_empty(parsed["Peer"]);
for (let key, peer in peer_map)
push(peers, peer_entry(peer));
}
write_json({
configured: true,
backend_state,
self: self_entry,
peers
});
}
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 == "peers")
peers_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);
}