feat(failover): автоматическое переключение прокси-секций
- генератор: при section_failover_enabled catch-all указывает на селектор tachyon-failover со всеми секциями-кандидатами - выбор по умолчанию переживает рестарты через /etc/tachyon/failover_section, устаревшее имя откатывается на первую секцию - service/failover.uc: пробер каждой секции через Clash API delay, порог N подряд — переключение на живую, TG-уведомление - watchdog: вызов проверки в slow-тире (модуль сам отключён при выключенной опции) - настройки: флаг + порог во вкладке Advanced, RU-локализация - тесты: генерация вкл/выкл/персист/устаревшее состояние + логика переключения со стабом Clash API
This commit is contained in:
@@ -836,6 +836,18 @@ msgstr "Дашборд сейчас недоступен"
|
||||
msgid "Days of Week"
|
||||
msgstr "Дни недели"
|
||||
|
||||
msgid "Auto-failover of proxy sections"
|
||||
msgstr "Авто-failover прокси-секций"
|
||||
|
||||
msgid "Watchdog probes every Connection section; when the active one fails several checks in a row, traffic switches to a healthy backup and Telegram is notified. Requires a config reload to take effect."
|
||||
msgstr "Watchdog проверяет каждую Connection-секцию; если активная падает несколько проверок подряд, трафик переключается на живую резервную, а Telegram получает уведомление. Нужен reload конфигурации."
|
||||
|
||||
msgid "Failover failure threshold"
|
||||
msgstr "Порог срабатывания failover"
|
||||
|
||||
msgid "Number of consecutive failed probes before switching sections"
|
||||
msgstr "Сколько неудачных проверок подряд до переключения секции"
|
||||
|
||||
msgid "Daily time quota (minutes)"
|
||||
msgstr "Дневной лимит времени (минуты)"
|
||||
|
||||
|
||||
@@ -1507,6 +1507,31 @@ function createSettingsContent(section, capabilities) {
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Flag,
|
||||
"section_failover_enabled",
|
||||
_("Auto-failover of proxy sections"),
|
||||
_(
|
||||
"Watchdog probes every Connection section; when the active one fails several checks in a row, traffic switches to a healthy backup and Telegram is notified. Requires a config reload to take effect.",
|
||||
),
|
||||
);
|
||||
o.default = "0";
|
||||
o.rmempty = false;
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.Value,
|
||||
"section_failover_threshold",
|
||||
_("Failover failure threshold"),
|
||||
_("Number of consecutive failed probes before switching sections"),
|
||||
);
|
||||
o.depends("section_failover_enabled", "1");
|
||||
o.default = "3";
|
||||
o.placeholder = "3";
|
||||
o.rmempty = false;
|
||||
o.datatype = "range(1,30)";
|
||||
|
||||
o = section.taboption(
|
||||
"advanced",
|
||||
form.ListValue,
|
||||
|
||||
@@ -836,6 +836,18 @@ msgstr "Дашборд сейчас недоступен"
|
||||
msgid "Days of Week"
|
||||
msgstr "Дни недели"
|
||||
|
||||
msgid "Auto-failover of proxy sections"
|
||||
msgstr "Авто-failover прокси-секций"
|
||||
|
||||
msgid "Watchdog probes every Connection section; when the active one fails several checks in a row, traffic switches to a healthy backup and Telegram is notified. Requires a config reload to take effect."
|
||||
msgstr "Watchdog проверяет каждую Connection-секцию; если активная падает несколько проверок подряд, трафик переключается на живую резервную, а Telegram получает уведомление. Нужен reload конфигурации."
|
||||
|
||||
msgid "Failover failure threshold"
|
||||
msgstr "Порог срабатывания failover"
|
||||
|
||||
msgid "Number of consecutive failed probes before switching sections"
|
||||
msgstr "Сколько неудачных проверок подряд до переключения секции"
|
||||
|
||||
msgid "Daily time quota (minutes)"
|
||||
msgstr "Дневной лимит времени (минуты)"
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ function command_spec(command) {
|
||||
get_tailscale_peers: [ "diagnostics/runtime.uc", "get-tailscale-peers", 0 ],
|
||||
tailscale_restart: [ "providers/tailscale/runtime.uc", "start-runtime", 0 ],
|
||||
parental_quota_tick: [ "service/parental_quota.uc", "tick", 0 ],
|
||||
failover_check: [ "service/failover.uc", "check", 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 ],
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env ucode
|
||||
|
||||
// Cross-section proxy failover: probe every candidate section through the
|
||||
// Clash API delay endpoint; when the active section misses the failure
|
||||
// threshold in a row, switch the tachyon-failover selector to a healthy one,
|
||||
// persist the choice and notify Telegram.
|
||||
//
|
||||
// The runtime switch only works when sing-box was generated with
|
||||
// section_failover_enabled=1 (the catch-all route then points at the
|
||||
// tachyon-failover selector group).
|
||||
//
|
||||
// NOTE: ucode does not hoist function declarations - keep callees above
|
||||
// their first caller.
|
||||
|
||||
let fs = require("fs");
|
||||
let common = require("core.common");
|
||||
let uci_core = require("core.uci");
|
||||
|
||||
let as_string = common.as_string;
|
||||
let object_or_empty = common.object_or_empty;
|
||||
let option = common.option;
|
||||
let int_option = common.int_option;
|
||||
let bool_option = common.bool_option;
|
||||
let shell_quote = common.shell_quote;
|
||||
let command_from_args = common.command_from_args;
|
||||
let command_status = common.command_status;
|
||||
let command_success_from_args = common.command_success_from_args;
|
||||
let command_output_from_args = common.command_output_from_args;
|
||||
|
||||
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
|
||||
const TACHYON_BIN = getenv("TACHYON_BIN") || "/usr/bin/tachyon";
|
||||
const STATE_FILE = getenv("TACHYON_FAILOVER_STATE_FILE") || "/etc/tachyon/failover_section";
|
||||
const STREAK_FILE = getenv("TACHYON_FAILOVER_STREAK_FILE") || "/var/run/tachyon/failover_streak";
|
||||
const PROBE_TIMEOUT_MS = getenv("TACHYON_FAILOVER_PROBE_TIMEOUT") || "3000";
|
||||
|
||||
const FAILOVER_GROUP_TAG = "tachyon-failover";
|
||||
|
||||
function log_message(message, level) {
|
||||
level = as_string(level || "info");
|
||||
command_success_from_args([ "logger", "-t", "tachyon", "[failover] [" + level + "] " + as_string(message) ]);
|
||||
}
|
||||
|
||||
function settings() {
|
||||
return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings"));
|
||||
}
|
||||
|
||||
function enabled() {
|
||||
return bool_option(settings(), "section_failover_enabled", false);
|
||||
}
|
||||
|
||||
function threshold() {
|
||||
let value = int_option(settings(), "section_failover_threshold", 3);
|
||||
return value > 0 ? value : 3;
|
||||
}
|
||||
|
||||
// Same ordering semantics as the generator: UCI order of enabled sections.
|
||||
function candidates() {
|
||||
let result = [];
|
||||
for (let section in uci_core.section_objects(CONFIG_NAME, "section")) {
|
||||
if (!bool_option(section, "enabled", true))
|
||||
continue;
|
||||
let action = option(section, "action", "");
|
||||
if (action == "connection" || action == "proxy" || action == "outbound" || action == "vpn" ||
|
||||
action == "awg" || action == "warp" || action == "byedpi" || action == "zapret" || action == "zapret2" ||
|
||||
action == "anytls" || action == "snell" || action == "mieru" || action == "sudoku" ||
|
||||
action == "masque" || action == "openvpn")
|
||||
push(result, as_string(section[".name"]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function read_active(candidates_list) {
|
||||
let raw = "";
|
||||
try {
|
||||
raw = trim(as_string(fs.readfile(STATE_FILE)) || "");
|
||||
} catch (e) {
|
||||
raw = "";
|
||||
}
|
||||
if (raw != "") {
|
||||
for (let name in candidates_list)
|
||||
if (as_string(name) == raw)
|
||||
return raw;
|
||||
}
|
||||
return length(candidates_list) > 0 ? as_string(candidates_list[0]) : "";
|
||||
}
|
||||
|
||||
function write_active(name) {
|
||||
fs.unlink(STATE_FILE);
|
||||
if (!fs.writefile(STATE_FILE, as_string(name) + "\n"))
|
||||
log_message("failed to persist failover state", "warn");
|
||||
}
|
||||
|
||||
function read_streak() {
|
||||
let raw = trim(as_string(fs.readfile(STREAK_FILE)) || "");
|
||||
let value = int(raw);
|
||||
return value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function write_streak(value) {
|
||||
fs.unlink(STREAK_FILE);
|
||||
fs.writefile(STREAK_FILE, as_string(value) + "\n");
|
||||
}
|
||||
|
||||
function clash_delay_ok(tag) {
|
||||
let out = trim(command_output_from_args([ TACHYON_BIN, "clash_api", "get_proxy_latency", tag, PROBE_TIMEOUT_MS ]) || "");
|
||||
if (out == "")
|
||||
return false;
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = json(out);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return object_or_empty(parsed)["delay"] != null;
|
||||
}
|
||||
|
||||
function switch_group(section_name, outbound_name) {
|
||||
let out = trim(command_output_from_args([ TACHYON_BIN, "clash_api", "set_group_proxy", FAILOVER_GROUP_TAG, outbound_name ]) || "");
|
||||
// set_group_proxy prints a JSON error object on failure.
|
||||
if (index(out, '"error"') >= 0) {
|
||||
log_message("runtime switch failed: " + out, "warn");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function send_notification(message) {
|
||||
let tcfg = object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
|
||||
if (as_string(tcfg.enabled || "0") != "1" || !tcfg.bot_token || !tcfg.admin_ids)
|
||||
return;
|
||||
system(common.background_command(TACHYON_BIN + " telegram send " + shell_quote(message)));
|
||||
}
|
||||
|
||||
function check() {
|
||||
if (!enabled())
|
||||
return 0;
|
||||
let list = candidates();
|
||||
if (length(list) < 2)
|
||||
return 0;
|
||||
|
||||
let active = read_active(list);
|
||||
if (active == "")
|
||||
return 0;
|
||||
|
||||
if (clash_delay_ok(active + "-out")) {
|
||||
write_streak(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let streak = read_streak() + 1;
|
||||
if (streak < threshold()) {
|
||||
write_streak(streak);
|
||||
log_message(active + " failed " + as_string(streak) + "/" + as_string(threshold()), "debug");
|
||||
return 0;
|
||||
}
|
||||
write_streak(0);
|
||||
|
||||
for (let name in list) {
|
||||
name = as_string(name);
|
||||
if (name == active)
|
||||
continue;
|
||||
if (!clash_delay_ok(name + "-out"))
|
||||
continue;
|
||||
write_active(name);
|
||||
if (!switch_group(name, name + "-out"))
|
||||
log_message("switched state to " + name + ", but live group update failed; will apply on next reload", "warn");
|
||||
log_message("active section switched: " + active + " -> " + name, "info");
|
||||
send_notification("🔀 *Failover*: секция `" + active + "` недоступна, переключаюсь на `" + name + "`.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
log_message("active section failed and no healthy backup found", "warn");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function status_json() {
|
||||
let list = candidates();
|
||||
write_json({
|
||||
enabled: enabled(),
|
||||
threshold: threshold(),
|
||||
candidates: list,
|
||||
active: read_active(list),
|
||||
streak: read_streak()
|
||||
});
|
||||
}
|
||||
|
||||
let mode = ARGV[0] || "";
|
||||
|
||||
if (mode == "check")
|
||||
exit(check());
|
||||
else if (mode == "status")
|
||||
status_json();
|
||||
else {
|
||||
warn("Usage: service/failover.uc <check|status>\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -1907,12 +1907,19 @@ function check_tailscale_worker() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-section failover probe; the module self-gates on
|
||||
// settings.section_failover_enabled so this is a no-op by default.
|
||||
function check_section_failover() {
|
||||
command_status("/usr/bin/tachyon failover_check >/dev/null 2>&1");
|
||||
}
|
||||
|
||||
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");
|
||||
safe_call(check_section_failover, "check_section_failover");
|
||||
}
|
||||
if (uloop) {
|
||||
let tick;
|
||||
|
||||
@@ -1437,18 +1437,48 @@ function add_route_for_section(config, section) {
|
||||
add_combined_route_for_section(config, section);
|
||||
}
|
||||
|
||||
function add_service_route_rules(config, sections) {
|
||||
let first = null;
|
||||
function failover_candidate(sections) {
|
||||
let result = [];
|
||||
for (let section in sections) {
|
||||
let action = option(section, "action", "");
|
||||
if (connections.is_connections_action(action) ||
|
||||
action == "awg" || action == "warp" || action == "byedpi" || action == "zapret" || action == "zapret2" ||
|
||||
action == "anytls" || action == "snell" || action == "mieru" || action == "sudoku" ||
|
||||
action == "masque" || action == "openvpn") {
|
||||
first = section;
|
||||
break;
|
||||
push(result, section);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function failover_state_file() {
|
||||
return getenv("TACHYON_FAILOVER_STATE_FILE") || "/etc/tachyon/failover_section";
|
||||
}
|
||||
|
||||
// Persisted user/agent choice survives restarts; ignored when the section
|
||||
// disappeared from the candidate list.
|
||||
function failover_default_name(candidates) {
|
||||
let raw = "";
|
||||
try {
|
||||
raw = trim(as_string(fs.readfile(failover_state_file())) || "");
|
||||
} catch (e) {
|
||||
raw = "";
|
||||
}
|
||||
if (raw != "") {
|
||||
for (let section in candidates)
|
||||
if (as_string(section[".name"]) == raw)
|
||||
return raw;
|
||||
}
|
||||
return as_string(candidates[0][".name"]);
|
||||
}
|
||||
|
||||
const FAILOVER_GROUP_TAG = "tachyon-failover";
|
||||
|
||||
function add_service_route_rules(config, sections) {
|
||||
let candidates = failover_candidate(sections);
|
||||
let first = length(candidates) > 0 ? candidates[0] : null;
|
||||
let failover_active = bool_option(object_or_empty(ctx.uci_cursor().get_all(CONFIG_NAME, "settings")), "section_failover_enabled", false) &&
|
||||
length(candidates) > 1;
|
||||
if (first != null) {
|
||||
push(config.route.rules, {
|
||||
action: "resolve",
|
||||
@@ -1456,10 +1486,24 @@ function add_service_route_rules(config, sections) {
|
||||
server: runtime_constants.DNS_SERVER_TAG,
|
||||
domain: runtime_constants.CHECK_PROXY_IP_DOMAIN
|
||||
});
|
||||
let catchall_target = outbound_tag(first[".name"]);
|
||||
if (failover_active) {
|
||||
let member_tags = [];
|
||||
for (let section in candidates)
|
||||
push(member_tags, outbound_tag(section[".name"]));
|
||||
push(config.outbounds, {
|
||||
type: "selector",
|
||||
tag: FAILOVER_GROUP_TAG,
|
||||
outbounds: member_tags,
|
||||
default: outbound_tag(failover_default_name(candidates)),
|
||||
interrupt_exist_connections: true
|
||||
});
|
||||
catchall_target = FAILOVER_GROUP_TAG;
|
||||
}
|
||||
push(config.route.rules, {
|
||||
action: "route",
|
||||
inbound: tproxy_inbound_matcher(),
|
||||
outbound: outbound_tag(first[".name"]),
|
||||
outbound: catchall_target,
|
||||
domain: runtime_constants.CHECK_PROXY_IP_DOMAIN
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-failover: generator emits the tachyon-failover selector group only when
|
||||
# enabled; the failover module switches to a healthy section after N failures
|
||||
# (Clash API stubbed).
|
||||
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"
|
||||
FAILOVER_UC="$TACHYON_LIB/service/failover.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" 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 enabled_flag="$1" out="$2"
|
||||
cat >"$out" <<JSON
|
||||
{
|
||||
"settings": {
|
||||
".name": "settings",
|
||||
".type": "settings",
|
||||
"dns_server": [ "77.88.8.8" ],
|
||||
"bootstrap_dns_server": [ "77.88.8.8" ],
|
||||
"section_failover_enabled": "$enabled_flag",
|
||||
"section_failover_threshold": "2"
|
||||
},
|
||||
"section": [
|
||||
{
|
||||
".name": "main_sec", ".type": "section", "enabled": "1", "action": "connection",
|
||||
"outbound_jsons": [ "{\"type\":\"vless\",\"tag\":\"A\",\"server\":\"a.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000001\",\"tls\":{\"enabled\":true}}" ]
|
||||
},
|
||||
{
|
||||
".name": "backup_sec", ".type": "section", "enabled": "1", "action": "connection",
|
||||
"outbound_jsons": [ "{\"type\":\"vless\",\"tag\":\"B\",\"server\":\"b.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000002\",\"tls\":{\"enabled\":true}}" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
# --- generator: disabled (default) -> no failover group ---------------------
|
||||
make_fixture 0 "$WORK_DIR/off.json"
|
||||
generate_config "$WORK_DIR/off.json" "$WORK_DIR/off.out"
|
||||
grep -Fq 'tachyon-failover' "$WORK_DIR/off.out" &&
|
||||
fail "disabled failover must not emit the group"
|
||||
|
||||
# catch-all still targets the first section outbound
|
||||
grep -Fq '"outbound": "main_sec-out"' "$WORK_DIR/off.out" ||
|
||||
fail "disabled failover must keep the direct catch-all route"
|
||||
|
||||
# --- generator: enabled -> group with both members, persisted default -------
|
||||
make_fixture 1 "$WORK_DIR/on.json"
|
||||
generate_config "$WORK_DIR/on.json" "$WORK_DIR/on.out"
|
||||
grep -Fq '"tag": "tachyon-failover"' "$WORK_DIR/on.out" ||
|
||||
fail "enabled failover must emit the selector group"
|
||||
grep -Fq '"main_sec-out"' "$WORK_DIR/on.out" && grep -Fq '"backup_sec-out"' "$WORK_DIR/on.out" ||
|
||||
fail "group must contain both section outbounds"
|
||||
grep -Fq '"default": "main_sec-out"' "$WORK_DIR/on.out" ||
|
||||
fail "group default must be the first candidate"
|
||||
|
||||
printf '%s' 'backup_sec' >"$WORK_DIR/state"
|
||||
TACHYON_FAILOVER_STATE_FILE="$WORK_DIR/state" \
|
||||
generate_config "$WORK_DIR/on.json" "$WORK_DIR/on_persisted.out"
|
||||
grep -Fq '"default": "backup_sec-out"' "$WORK_DIR/on_persisted.out" ||
|
||||
fail "persisted choice must become the group default"
|
||||
|
||||
# stale persisted name falls back to the first candidate
|
||||
printf '%s' 'gone_sec' >"$WORK_DIR/stale"
|
||||
TACHYON_FAILOVER_STATE_FILE="$WORK_DIR/stale" \
|
||||
generate_config "$WORK_DIR/on.json" "$WORK_DIR/on_stale.out"
|
||||
grep -Fq '"default": "main_sec-out"' "$WORK_DIR/on_stale.out" ||
|
||||
fail "stale persisted section must fall back to first candidate"
|
||||
|
||||
# --- switcher logic with stubbed Clash API ---------------------------------
|
||||
STUB_BIN="$WORK_DIR/bin"
|
||||
mkdir -p "$STUB_BIN"
|
||||
FAIL_LOG="$WORK_DIR/clash.log"
|
||||
|
||||
cat >"$STUB_BIN/tachyon" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$*" >> '$FAIL_LOG'
|
||||
case "\$*" in
|
||||
*"get_proxy_latency main_sec-out"*)
|
||||
if [ "\$MAIN_DEAD" = "1" ]; then echo '{}'; else echo '{"delay":120}'; fi ;;
|
||||
*"get_proxy_latency backup_sec-out"*) echo '{"delay":200}' ;;
|
||||
*"get_proxy_latency gone_sec-out"*) echo '{}' ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$STUB_BIN/tachyon"
|
||||
|
||||
export PATH="$STUB_BIN:$PATH"
|
||||
export TACHYON_CONFIG_NAME="tachyon"
|
||||
export TACHYON_BIN="$STUB_BIN/tachyon"
|
||||
export TACHYON_FAILOVER_STATE_FILE="$WORK_DIR/state"
|
||||
export TACHYON_FAILOVER_STREAK_FILE="$WORK_DIR/streak"
|
||||
|
||||
# --- switcher logic with stubbed Clash API ---------------------------------
|
||||
STUB_BIN="$WORK_DIR/bin"
|
||||
mkdir -p "$STUB_BIN"
|
||||
FAIL_LOG="$WORK_DIR/clash.log"
|
||||
|
||||
cat >"$STUB_BIN/tachyon" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$*" >> '$FAIL_LOG'
|
||||
case "\$*" in
|
||||
*"get_proxy_latency main_sec-out"*)
|
||||
if [ "\${MAIN_DEAD:-0}" = "1" ]; then echo '{}'; else echo '{"delay":120}'; fi ;;
|
||||
*"get_proxy_latency backup_sec-out"*) echo '{"delay":200}' ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$STUB_BIN/tachyon"
|
||||
|
||||
export PATH="$STUB_BIN:$PATH"
|
||||
export TACHYON_CONFIG_NAME="tachyon"
|
||||
export TACHYON_BIN="$STUB_BIN/tachyon"
|
||||
export TACHYON_FAILOVER_STATE_FILE="$WORK_DIR/state"
|
||||
export TACHYON_FAILOVER_STREAK_FILE="$WORK_DIR/streak"
|
||||
|
||||
make_state() {
|
||||
printf '%s\n' \
|
||||
"tachyon.settings=settings" \
|
||||
"tachyon.settings.section_failover_enabled=$1" \
|
||||
"tachyon.settings.section_failover_threshold=2" \
|
||||
"tachyon.main_sec=section" "tachyon.main_sec.enabled=1" "tachyon.main_sec.action=connection" \
|
||||
"tachyon.backup_sec=section" "tachyon.backup_sec.enabled=1" "tachyon.backup_sec.action=connection" \
|
||||
"tachyon.telegram=telegram" "tachyon.telegram.enabled=1" "tachyon.telegram.bot_token=x" "tachyon.telegram.admin_ids=1" \
|
||||
>"$WORK_DIR/uci.state"
|
||||
printf '%s' 'main_sec' >"$WORK_DIR/state"
|
||||
rm -f "$WORK_DIR/streak"
|
||||
}
|
||||
|
||||
run_check() {
|
||||
UCI_STATE="$WORK_DIR/uci.state" ucode -L "$TACHYON_LIB" "$FAILOVER_UC" check
|
||||
}
|
||||
|
||||
# disabled -> no-op even with dead primary
|
||||
make_state 0
|
||||
MAIN_DEAD=1 run_check || fail "check must not fail when disabled"
|
||||
[ ! -s "$WORK_DIR/streak" ] || fail "disabled check must not track streaks"
|
||||
|
||||
# enabled, healthy primary -> no switch
|
||||
make_state 1
|
||||
run_check || fail "healthy check failed"
|
||||
[ "$(cat "$WORK_DIR/state")" = "main_sec" ] || fail "healthy primary must stay active"
|
||||
|
||||
# primary dies twice (threshold=2) -> switch to backup + persist
|
||||
export MAIN_DEAD=1
|
||||
run_check; run_check || fail "switch path errored"
|
||||
[ "$(cat "$WORK_DIR/state")" = "backup_sec" ] ||
|
||||
fail "must switch to backup after threshold, state=$(cat "$WORK_DIR/state")"
|
||||
grep -Fq "set_group_proxy tachyon-failover backup_sec-out" "$FAIL_LOG" ||
|
||||
fail "runtime switch via Clash API missing"
|
||||
grep -Fq "telegram send" "$FAIL_LOG" ||
|
||||
fail "telegram notification missing"
|
||||
|
||||
# recovery of streak file on success
|
||||
unset MAIN_DEAD
|
||||
run_check
|
||||
[ "$(cat "$WORK_DIR/streak")" = "0" ] || fail "success must reset the streak counter"
|
||||
|
||||
printf 'auto-failover checks passed\n'
|
||||
Reference in New Issue
Block a user