feat(tailscale): watchdog нативного режима и панель пиров тейлнета

- watchdog: проверка native-секции в slow-тире, авторестарт демона после 2 сбоев, TG-алерт раз в час
- AI Doctor: узел Tailscale (готов/не установлен/упал) только при настроенном native
- провайдер: режим peers — парсинг status --json через per-section socket
- RPC get_tailscale_status/get_tailscale_peers/tailscale_restart
- дашборд: карточка Tailscale (состояние, IP роутера, до 5 пиров с онлайн-статусом)
This commit is contained in:
Dushnilin
2026-08-26 13:43:23 +03:00
parent 7f0b085a07
commit 2c6a1a7343
11 changed files with 356 additions and 1 deletions
@@ -260,6 +260,13 @@ export const TachyonShellMethods = {
'/usr/bin/tachyon',
{ allowNonZeroWithStdout: true },
),
getTailscalePeers: async () =>
callBaseMethod<Tachyon.GetTailscalePeers>(
Tachyon.AvailableMethods.GET_TAILSCALE_PEERS,
[],
'/usr/bin/tachyon',
{ allowNonZeroWithStdout: true },
),
getZapretStatus: async () =>
callBaseMethod<Tachyon.GetZapretStatus>(
Tachyon.AvailableMethods.GET_ZAPRET_STATUS,
@@ -169,6 +169,11 @@ export interface StoreType {
watchdogRunning: number;
};
};
tailscaleWidget: {
loading: boolean;
failed: boolean;
data: Tachyon.GetTailscalePeers | null;
};
sectionsWidget: {
loading: boolean;
failed: boolean;
@@ -290,6 +295,11 @@ const initialStore: StoreType = {
watchdogRunning: 0,
},
},
tailscaleWidget: {
loading: true,
failed: false,
data: null,
},
sectionsWidget: {
loading: true,
failed: false,
@@ -337,6 +337,7 @@ async function completeSubscriptionUpdateJob(
}
void fetchDashboardSections({ force: true });
void fetchServicesInfo();
void fetchTailscalePeers();
}
async function followSubscriptionUpdateState(
@@ -1754,7 +1755,8 @@ function renderStoreWidget(
| 'bandwidthWidget'
| 'trafficTotalWidget'
| 'systemInfoWidget'
| 'servicesInfoWidget',
| 'servicesInfoWidget'
| 'tailscaleWidget',
title: string,
getItems: (data: any) => Array<{
key: string;
@@ -1907,6 +1909,89 @@ async function renderServicesInfoWidget() {
);
}
let latestTailscalePeersRequestId = 0;
async function fetchTailscalePeers() {
const requestId = ++latestTailscalePeersRequestId;
try {
const response = await TachyonShellMethods.getTailscalePeers();
if (requestId !== latestTailscalePeersRequestId) return;
store.set({
tailscaleWidget: {
loading: false,
failed: !response.success,
data: response.success ? response.data : null,
},
});
} catch {
if (requestId !== latestTailscalePeersRequestId) return;
store.set({
tailscaleWidget: {
loading: false,
failed: true,
data: store.get().tailscaleWidget.data,
},
});
}
}
function peerRow(name: string, online: boolean) {
return {
key: name,
value: online ? _('✔ Online') : _('✘ Offline'),
attributes: {
class: online
? 'tachyon_dashboard-page__widgets-section__item__row--success'
: 'tachyon_dashboard-page__widgets-section__item__row--error',
},
};
}
async function renderTailscaleWidget() {
renderStoreWidget(
'dashboard-widget-tailscale',
'tailscaleWidget',
_('Tailscale'),
(data) => {
type WidgetItem = {
key: string;
value: string;
attributes?: Record<string, string>;
};
if (!data || !data.configured) return [] as WidgetItem[];
const items: WidgetItem[] = [
{
key: _('State'),
value: data.backend_state || '—',
attributes: {
class:
data.backend_state === 'Running'
? 'tachyon_dashboard-page__widgets-section__item__row--success'
: 'tachyon_dashboard-page__widgets-section__item__row--error',
},
},
];
if (data.self && data.self.ips.length) {
items.push({ key: _('This router'), value: data.self.ips[0] });
}
for (const peer of data.peers.slice(0, 5)) {
if (!peer.online && !peer.name) continue;
items.push(
peerRow(peer.name || peer.dns_name || peer.ips[0], peer.online),
);
}
if (data.peers.length > 5) {
items.push({
key: _('Other peers'),
value: `+${data.peers.length - 5}`,
});
}
return items;
},
'renderTailscaleWidget',
);
}
async function onStoreUpdate(
next: StoreType,
prev: StoreType,
@@ -1940,6 +2025,10 @@ async function onStoreUpdate(
syncDashboardServiceAvailability();
renderServicesInfoWidget();
}
if (diff.tailscaleWidget) {
renderTailscaleWidget();
}
}
async function onPageMount() {
@@ -1959,6 +2048,7 @@ async function onPageMount() {
if (!uiState) {
void fetchServicesInfo();
void fetchTailscalePeers();
}
}
@@ -1970,7 +2060,9 @@ async function onPageMount() {
void renderTrafficTotalWidget();
void renderSystemInfoWidget();
void renderServicesInfoWidget();
void renderTailscaleWidget();
syncDashboardServiceAvailability();
void fetchTailscalePeers();
if (hasRuntimeSnapshot) {
void refreshRuntimeUiState({ force: true });
@@ -60,6 +60,16 @@ export function render() {
items: [],
}),
),
E(
'div',
{ id: 'dashboard-widget-tailscale' },
renderWidget({
loading: true,
failed: false,
title: '',
items: [],
}),
),
]),
E('div', { id: 'dashboard-connections-grid' }, []),
E(
+15
View File
@@ -66,6 +66,7 @@ export namespace Tachyon {
CHECK_INBOUNDS = 'check_inbounds',
GET_SING_BOX_STATUS = 'get_sing_box_status',
GET_ZAPRET_STATUS = 'get_zapret_status',
GET_TAILSCALE_PEERS = 'get_tailscale_peers',
GET_ZAPRET2_STATUS = 'get_zapret2_status',
GET_BYEDPI_STATUS = 'get_byedpi_status',
CLASH_API = 'clash_api',
@@ -694,6 +695,20 @@ export namespace Tachyon {
updated_at?: number | null;
}
export interface TailscalePeer {
name: string;
dns_name: string;
ips: string[];
online: boolean;
}
export interface GetTailscalePeers {
configured: boolean;
backend_state: string;
self: TailscalePeer | null;
peers: TailscalePeer[];
}
export interface GetZapretStatus {
installed: 0 | 1;
package_installed: 0 | 1;
@@ -2906,6 +2906,16 @@ function render() {
title: "",
items: []
})
),
E(
"div",
{ id: "dashboard-widget-tailscale" },
renderWidget({
loading: true,
failed: false,
title: "",
items: []
})
)
]),
E("div", { id: "dashboard-connections-grid" }, []),
@@ -3072,6 +3082,7 @@ var Tachyon;
AvailableMethods2["CHECK_INBOUNDS"] = "check_inbounds";
AvailableMethods2["GET_SING_BOX_STATUS"] = "get_sing_box_status";
AvailableMethods2["GET_ZAPRET_STATUS"] = "get_zapret_status";
AvailableMethods2["GET_TAILSCALE_PEERS"] = "get_tailscale_peers";
AvailableMethods2["GET_ZAPRET2_STATUS"] = "get_zapret2_status";
AvailableMethods2["GET_BYEDPI_STATUS"] = "get_byedpi_status";
AvailableMethods2["CLASH_API"] = "clash_api";
@@ -3324,6 +3335,12 @@ var TachyonShellMethods = {
"/usr/bin/tachyon",
{ allowNonZeroWithStdout: true }
),
getTailscalePeers: async () => callBaseMethod(
Tachyon.AvailableMethods.GET_TAILSCALE_PEERS,
[],
"/usr/bin/tachyon",
{ allowNonZeroWithStdout: true }
),
getZapretStatus: async () => callBaseMethod(
Tachyon.AvailableMethods.GET_ZAPRET_STATUS,
[],
@@ -5453,6 +5470,11 @@ var initialStore = {
watchdogRunning: 0
}
},
tailscaleWidget: {
loading: true,
failed: false,
data: null
},
sectionsWidget: {
loading: true,
failed: false,
@@ -6786,6 +6808,7 @@ async function completeSubscriptionUpdateJob(jobId, sectionName, response) {
}
void fetchDashboardSections({ force: true });
void fetchServicesInfo();
void fetchTailscalePeers();
}
async function followSubscriptionUpdateState(state) {
const jobId = state.job_id;
@@ -8008,6 +8031,75 @@ async function renderServicesInfoWidget() {
"renderServicesInfoWidget"
);
}
var latestTailscalePeersRequestId = 0;
async function fetchTailscalePeers() {
const requestId = ++latestTailscalePeersRequestId;
try {
const response = await TachyonShellMethods.getTailscalePeers();
if (requestId !== latestTailscalePeersRequestId) return;
store.set({
tailscaleWidget: {
loading: false,
failed: !response.success,
data: response.success ? response.data : null
}
});
} catch {
if (requestId !== latestTailscalePeersRequestId) return;
store.set({
tailscaleWidget: {
loading: false,
failed: true,
data: store.get().tailscaleWidget.data
}
});
}
}
function peerRow(name, online) {
return {
key: name,
value: online ? _("\u2714 Online") : _("\u2718 Offline"),
attributes: {
class: online ? "tachyon_dashboard-page__widgets-section__item__row--success" : "tachyon_dashboard-page__widgets-section__item__row--error"
}
};
}
async function renderTailscaleWidget() {
renderStoreWidget(
"dashboard-widget-tailscale",
"tailscaleWidget",
_("Tailscale"),
(data) => {
if (!data || !data.configured) return [];
const items = [
{
key: _("State"),
value: data.backend_state || "\u2014",
attributes: {
class: data.backend_state === "Running" ? "tachyon_dashboard-page__widgets-section__item__row--success" : "tachyon_dashboard-page__widgets-section__item__row--error"
}
}
];
if (data.self && data.self.ips.length) {
items.push({ key: _("This router"), value: data.self.ips[0] });
}
for (const peer of data.peers.slice(0, 5)) {
if (!peer.online && !peer.name) continue;
items.push(
peerRow(peer.name || peer.dns_name || peer.ips[0], peer.online)
);
}
if (data.peers.length > 5) {
items.push({
key: _("Other peers"),
value: `+${data.peers.length - 5}`
});
}
return items;
},
"renderTailscaleWidget"
);
}
async function onStoreUpdate(next, prev, diff) {
if (diff.sectionsWidget) {
const inlineUpdated = canUpdateLatencyProgressInline(
@@ -8031,6 +8123,9 @@ async function onStoreUpdate(next, prev, diff) {
syncDashboardServiceAvailability();
renderServicesInfoWidget();
}
if (diff.tailscaleWidget) {
renderTailscaleWidget();
}
}
async function onPageMount() {
onPageUnmount();
@@ -8045,6 +8140,7 @@ async function onPageMount() {
}
if (!uiState) {
void fetchServicesInfo();
void fetchTailscalePeers();
}
}
store.subscribe(onStoreUpdate);
@@ -8055,7 +8151,9 @@ async function onPageMount() {
void renderTrafficTotalWidget();
void renderSystemInfoWidget();
void renderServicesInfoWidget();
void renderTailscaleWidget();
syncDashboardServiceAvailability();
void fetchTailscalePeers();
if (hasRuntimeSnapshot) {
void refreshRuntimeUiState({ force: true });
}
+3
View File
@@ -208,6 +208,9 @@ function command_spec(command) {
get_zapret_status: [ "diagnostics/runtime.uc", "get-zapret-status", 0 ],
get_zapret2_status: [ "diagnostics/runtime.uc", "get-zapret2-status", 0 ],
get_byedpi_status: [ "diagnostics/runtime.uc", "get-byedpi-status", 0 ],
get_tailscale_status: [ "diagnostics/runtime.uc", "get-tailscale-status", 0 ],
get_tailscale_peers: [ "diagnostics/runtime.uc", "get-tailscale-peers", 0 ],
tailscale_restart: [ "providers/tailscale/runtime.uc", "start-runtime", 0 ],
get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ],
get_server_capabilities: [ "diagnostics/runtime.uc", "get-server-capabilities", 0 ],
get_ui_capabilities: [ "service/ui.uc", "get-ui-capabilities", 0 ],
@@ -2871,6 +2871,24 @@ function run_doctor_checks_impl(repair) {
}
}
// Tailscale native runtime check (skipped entirely without native sections
// so sing-box-mode users never see the node).
let ts_status_raw = trim(module_output(TAILSCALE_RUNTIME_UC, [ "status" ]));
if (ts_status_raw != "") {
let ts = object_or_empty(json(ts_status_raw));
if (ts.configured == true) {
if (ts.installed != true) {
issues++;
doc_check("⚠️", "Tailscale", "package missing", "→ install the Tailscale component on the Updates tab");
} else if (ts.ready == true) {
doc_check("✅", "Tailscale", "running (" + as_string(ts.version || "") + ")", "");
} else {
issues++;
doc_check("❌", "Tailscale", as_string(ts.status_message || "not ready"), "→ run: tachyon tailscale_restart");
}
}
}
// 11. MSS Clamping Check
if (routing_mode == "nftables") {
let out_clamping = command_capture("nft list table inet " + NFT_TABLE_NAME + " | grep maxseg").output;
@@ -4856,6 +4874,10 @@ else if (mode == "get-zapret2-status")
exit(module_passthrough(ZAPRET2_RUNTIME_UC, [ "status" ]));
else if (mode == "get-byedpi-status")
exit(module_passthrough(BYEDPI_RUNTIME_UC, [ "status" ]));
else if (mode == "get-tailscale-status")
exit(module_passthrough(TAILSCALE_RUNTIME_UC, [ "status" ]));
else if (mode == "get-tailscale-peers")
exit(module_passthrough(TAILSCALE_RUNTIME_UC, [ "peers" ]));
else if (mode == "get-system-info")
exit(get_system_info());
else if (mode == "get-server-capabilities")
@@ -18,6 +18,8 @@ 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;
@@ -461,6 +463,55 @@ function check_json() {
});
}
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);
@@ -512,6 +563,8 @@ 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")
+33
View File
@@ -24,6 +24,8 @@ let proxy_restart_window_start = time();
const PROXY_RESTART_LOCK = "/var/run/tachyon_proxy_restart.lock";
let telegram_msg_count = 0;
let telegram_msg_window = time();
let tailscale_fail_streak = 0;
let tailscale_last_alert = 0;
let command_from_args = common.command_from_args;
let command_status = common.command_status;
@@ -1875,11 +1877,42 @@ function check_telegram_worker() {
telegram_worker_restart();
}
// Native Tailscale sections run tailscaled outside sing-box; nobody else
// watches that daemon. Two consecutive unhealthy statuses trigger an
// idempotent provider restart (start-runtime), alerts throttled to 1/hour.
function check_tailscale_worker() {
let raw = trim(command_output_from_args([ "/usr/bin/tachyon", "get_tailscale_status" ]) || "");
if (raw == "")
return;
let st = common.object_or_empty(json(raw));
if (st.configured != true)
return;
if (st.ready == true) {
tailscale_fail_streak = 0;
return;
}
tailscale_fail_streak++;
if (tailscale_fail_streak < 2)
return;
tailscale_fail_streak = 0;
log_message("Native Tailscale runtime is unhealthy (" + as_string(st.status_message || "") + "); restarting", "warn");
command_status("/usr/bin/tachyon tailscale_restart >/dev/null 2>&1");
let now = time();
if (now - tailscale_last_alert >= 3600) {
tailscale_last_alert = now;
send_telegram_notification("🛰 *Watchdog:* Native Tailscale was down, restarting the service.");
}
}
function perform_slow_checks() {
controller.probe_slow();
safe_call(ai_heal_dns_loop, "ai_heal_dns_loop");
safe_call(check_mixed_proxy_port, "check_mixed_proxy_port");
safe_call(check_telegram_worker, "check_telegram_worker");
safe_call(check_tailscale_worker, "check_tailscale_worker");
}
if (uloop) {
let tick;
+12
View File
@@ -43,9 +43,13 @@ cat >"$STUB_BIN/tailscale" <<EOF
printf 'cli %s\n' "\$*" >> '$TS_LOG'
case "\$*" in
*version*) echo "1.80.3-smoke" ;;
*--json*) cat '$WORK_DIR/ts-status.json' ;;
*) exit 0 ;;
esac
EOF
cat >"$WORK_DIR/ts-status.json" <<'JSON'
{"BackendState":"Running","Self":{"HostName":"router","DNSName":"router.tailnet.ts.net.","TailscaleIPs":["100.64.0.1"]},"Peer":{"k1":{"HostName":"phone","DNSName":"phone.tailnet.ts.net.","TailscaleIPs":["100.64.0.2"],"Online":true},"k2":{"HostName":"laptop","DNSName":"laptop.tailnet.ts.net.","TailscaleIPs":["100.64.0.3"],"Online":false}}}
JSON
chmod +x "$STUB_BIN/tailscaled" "$STUB_BIN/tailscale"
STATE_FILE="$WORK_DIR/uci.state"
@@ -121,6 +125,14 @@ printf '%s' "$STATUS" | grep -Fq '"configured": true' ||
printf '%s' "$STATUS" | grep -Fq '"ready": true' ||
fail "status must report ready=true, got: $STATUS"
PEERS="$(run peers)"
printf '%s' "$PEERS" | grep -Fq '"backend_state": "Running"' ||
fail "peers must relay backend state, got: $PEERS"
printf '%s' "$PEERS" | grep -Fq '"dns_name": "phone.tailnet.ts.net."' ||
fail "peers must include tailnet peers, got: $PEERS"
printf '%s' "$PEERS" | grep -Fq '"online": false' ||
fail "peers must report offline flag, got: $PEERS"
# --- idempotent restart -----------------------------------------------------
run start-runtime || fail "second start-runtime failed"
RULE_COUNT=$(ip rule list | grep -cF "lookup 4247")