fix(fptn): boot recovery, reload planner, persistent selector state and telegram token flow

This commit is contained in:
Dushnilin
2026-09-16 14:54:54 +03:00
parent 3834b783cc
commit a23c2a2a55
11 changed files with 480 additions and 32 deletions
@@ -6,6 +6,7 @@ case "$INTERFACE" in
case "$ACTION" in
ifup)
/etc/init.d/tachyon retry_start_on_wan_up >/dev/null 2>&1 &
[ -x /usr/lib/tachyon/providers/fptn/runtime.uc ] && /usr/lib/tachyon/providers/fptn/runtime.uc ensure-routing >/dev/null 2>&1 &
;;
esac
;;
+22 -3
View File
@@ -756,13 +756,13 @@ function rule_action_supported(action) {
return contains([ "connection", "proxy", "outbound", "vpn", "awg", "warp",
"anytls", "snell", "mieru", "sudoku", "masque", "openvpn",
"bypass", "block", "dns", "zapret", "zapret2", "byedpi", "hosts",
"wdtt", "olcrtc" ], as_string(action));
"wdtt", "olcrtc", "fptn" ], as_string(action));
}
function server_routing_section_action_supported(action) {
return contains([ "connection", "proxy", "outbound", "vpn", "awg", "warp",
"anytls", "snell", "mieru", "sudoku", "masque", "openvpn",
"zapret", "zapret2", "byedpi", "wdtt", "olcrtc" ], as_string(action));
"zapret", "zapret2", "byedpi", "wdtt", "olcrtc", "fptn" ], as_string(action));
}
function duration_to_seconds_value(value) {
@@ -1517,6 +1517,13 @@ function validate_rule(section, sections, context) {
validate_olcrtc_section(section, context);
}
if (action == "fptn") {
let fptn_validator = require("providers.fptn.validator");
let vres = fptn_validator.validate_section(section);
if (!vres.valid)
fail_validation("FPTN rule '" + name + "': " + vres.error + ". Aborted.");
}
if (connections.is_connections_action(action)) {
validate_dashboard_filter(section);
@@ -2058,6 +2065,9 @@ function context_from_runtime() {
zapret2_installed: file_executable(constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN")),
wdtt_installed: file_executable(constant_value(constants, "WDTT_BIN")),
olcrtc_installed: file_executable(constant_value(constants, "OLCRTC_BIN")),
fptn_installed: file_executable("/usr/bin/fptn-client-cli") || file_executable("/usr/bin/fptn-client"),
fptn_bin: file_executable("/usr/bin/fptn-client-cli") ? "/usr/bin/fptn-client-cli" : "/usr/bin/fptn-client",
fptn_state_dir: "/var/run/tachyon/fptn",
zapret_provider_nfqws_bin: constant_value(constants, "ZAPRET_PROVIDER_NFQWS_BIN"),
zapret2_provider_nfqws2_bin: constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN"),
zapret_route_mark_base: constant_value(constants, "ZAPRET_ROUTE_MARK_BASE"),
@@ -2367,7 +2377,7 @@ function has_outbound_section(ctx) {
// without this check a lone AWG/WARP/VPN section triggered the
// misleading "No proxy outbound sections found" warning.
if (contains([ "awg", "warp", "vpn", "openvpn", "masque",
"anytls", "snell", "mieru", "sudoku" ], as_string(action)))
"anytls", "snell", "mieru", "sudoku", "fptn" ], as_string(action)))
return true;
if (length(connections.connection_urls(section)) > 0 ||
@@ -2451,6 +2461,15 @@ function check_provider_requirements(ctx) {
"ByeDPI provider is not available at " + ctx.byedpi_bin + ". Rules with action 'byedpi' will be skipped until the byedpi package is installed.",
"Failed to prepare the Tachyon ByeDPI state directory in " + ctx.byedpi_state_dir + ". Aborted."
);
check_provider_requirement(
"fptn",
"FPTN",
ctx.fptn_bin,
[ ctx.fptn_state_dir ],
"FPTN provider is not available at " + ctx.fptn_bin + ". Rules with action 'fptn' will be skipped until the fptn package is installed.",
"Failed to prepare the Tachyon FPTN state directory in " + ctx.fptn_state_dir + ". Aborted."
);
}
function check_runtime_requirements() {
+32 -1
View File
@@ -1825,6 +1825,19 @@ function latency_test_url() {
return value == "" ? DEFAULT_LATENCY_TEST_URL : value;
}
function save_persistent_selector_choice(group_tag, proxy_tag) {
group_tag = as_string(group_tag);
proxy_tag = as_string(proxy_tag);
if (group_tag == "" || proxy_tag == "")
return false;
let path = getenv("TACHYON_PERSISTENT_SELECTOR_STATE_FILE") || "/etc/tachyon/selector_state.json";
let state = common.read_json_file(path);
if (type(state) != "object")
state = {};
state[group_tag] = proxy_tag;
return common.write_json_file(path, state, 2);
}
function clash_api(action, arg1, arg2, arg3) {
let base_url = clash_api_url();
let test_url = latency_test_url();
@@ -1997,13 +2010,31 @@ function clash_api(action, arg1, arg2, arg3) {
push(args, "--data-raw");
push(args, payload);
let result = status_capture([ "clash-set-group-proxy-result", arg1, arg2 ], command_output(command_from_args(args)));
if (result.status == 0)
if (result.status == 0) {
command_status("conntrack -F >/dev/null 2>&1 || true");
save_persistent_selector_choice(arg1, arg2);
}
if (result.output != "")
print(result.output);
return result.status;
}
if (action == "restore_selector_state") {
let path = getenv("TACHYON_PERSISTENT_SELECTOR_STATE_FILE") || "/etc/tachyon/selector_state.json";
let state = common.read_json_file(path);
if (type(state) != "object" || length(keys(state)) == 0)
return 0;
let proxies_res = clash_json_data([ "curl", "-s", base_url + "/proxies" ], auth);
let proxies = object_or_empty(object_or_empty(proxies_res).proxies);
for (let group, selected in state) {
let grp = proxies[group];
if (type(grp) == "object" && lc(as_string(grp.type || "")) == "selector" && index(grp.all, selected) >= 0 && as_string(grp.now || "") != selected) {
clash_api("set_group_proxy", group, selected);
}
}
return 0;
}
if (action == "close_connection") {
if (as_string(arg1) == "")
return clash_json_error("connection_id required");
+138 -24
View File
@@ -145,26 +145,10 @@ function stop_runtime() {
return true;
}
function start_runtime() {
let sections = enabled_sections();
if (length(sections) == 0) {
stop_runtime();
return true;
}
if (!provider_available()) {
log_message("Cannot start FPTN: binary " + cfg.binary + " not found or not executable", "warn");
return false;
}
let section = sections[0];
function launch_fptn_process(section) {
let token = as_string(option(section, "access_token", ""));
if (token == "") {
log_message("Cannot start FPTN: access_token not configured for section " + section_name(section), "warn");
if (token == "")
return false;
}
stop_runtime();
command_status("mkdir -p " + shell_quote(cfg.state_dir) + " 2>/dev/null");
@@ -196,6 +180,95 @@ function start_runtime() {
let cmd_str = command_from_args(cmd_args) + " >> " + shell_quote(cfg.log_file) + " 2>&1 & echo $! > " + shell_quote(cfg.pid_file);
log_message("Starting FPTN client on interface " + cfg.tun_interface, "info");
system(cmd_str);
return true;
}
function supervise_runtime() {
let sup_pid_file = cfg.state_dir + "/supervisor.pid";
let cur_pid = trim(as_string(fs.readfile(sup_pid_file) || ""));
if (cur_pid != "" && !match(cur_pid, /[^0-9]/) && fs.stat("/proc/" + cur_pid) != null)
return true;
let delays = [ 2, 5, 10, 20, 30, 30, 30 ];
for (let i = 0; i < length(delays); i++) {
let sec_wait = delays[i];
system(sprintf("sleep %d 2>/dev/null || sleep %d", sec_wait, sec_wait));
let sections = enabled_sections();
if (length(sections) == 0)
break;
let tun_ok = command_status("ip link show " + shell_quote(cfg.tun_interface) + " >/dev/null 2>&1") == 0;
let p = running_pid();
if (!tun_ok || p == null) {
log_message("FPTN supervisor: retrying start (attempt " + (i + 1) + ")", "info");
stop_runtime();
launch_fptn_process(sections[0]);
for (let wait_i = 0; wait_i < 20; wait_i++) {
if (command_status("ip link show " + shell_quote(cfg.tun_interface) + " >/dev/null 2>&1") == 0) {
tun_ok = true;
break;
}
system("sleep 0.2 2>/dev/null || sleep 1");
}
}
if (tun_ok) {
let routed = install_kernel_routing();
if (routed) {
log_message("FPTN supervisor: interface " + cfg.tun_interface + " is UP and routing installed", "info");
remove_file(sup_pid_file);
return true;
}
}
}
remove_file(sup_pid_file);
return false;
}
function ensure_routing() {
let sections = enabled_sections();
if (length(sections) == 0)
return true;
let tun_up = command_status("ip link show " + shell_quote(cfg.tun_interface) + " >/dev/null 2>&1") == 0;
let p = running_pid();
if (p != null && tun_up) {
let route_installed = command_status("ip route show table " + cfg.route_table + " default dev " + shell_quote(cfg.tun_interface) + " 2>/dev/null | grep -q default") == 0;
let rule_installed = command_status("ip rule show 2>/dev/null | grep -q " + shell_quote(cfg.route_table)) == 0;
if (route_installed && rule_installed)
return true;
return install_kernel_routing();
}
return start_runtime();
}
function start_runtime() {
let sections = enabled_sections();
if (length(sections) == 0) {
stop_runtime();
return true;
}
if (!provider_available()) {
log_message("Cannot start FPTN: binary " + cfg.binary + " not found or not executable", "warn");
return false;
}
let section = sections[0];
let token = as_string(option(section, "access_token", ""));
if (token == "") {
log_message("Cannot start FPTN: access_token not configured for section " + section_name(section), "warn");
return false;
}
// Stop and disable conflicting standalone init.d service if present
command_status("/etc/init.d/fptn stop >/dev/null 2>&1 || true; /etc/init.d/fptn disable >/dev/null 2>&1 || true;");
stop_runtime();
if (!launch_fptn_process(section))
return false;
let started = false;
for (let i = 0; i < 30; i++) {
@@ -210,11 +283,24 @@ function start_runtime() {
}
if (!started) {
log_message("FPTN interface " + cfg.tun_interface + " did not come up", "warn");
return false;
log_message("FPTN interface " + cfg.tun_interface + " did not come up immediately (WAN may be initializing); launching background retry supervisor", "warn");
let sup_pid_file = cfg.state_dir + "/supervisor.pid";
let sup_exec = sprintf("ucode -L %s %s supervise-runtime",
shell_quote(LIB_DIR), shell_quote(LIB_DIR + "/providers/fptn/runtime.uc"));
system(common.background_command_with_pid(sup_exec, ">/dev/null", ">" + shell_quote(sup_pid_file)));
return true;
}
let routing_ok = install_kernel_routing();
if (!routing_ok) {
log_message("FPTN interface " + cfg.tun_interface + " came up but routing failed; launching background supervisor", "warn");
let sup_pid_file = cfg.state_dir + "/supervisor.pid";
let sup_exec = sprintf("ucode -L %s %s supervise-runtime",
shell_quote(LIB_DIR), shell_quote(LIB_DIR + "/providers/fptn/runtime.uc"));
system(common.background_command_with_pid(sup_exec, ">/dev/null", ">" + shell_quote(sup_pid_file)));
return true;
}
install_kernel_routing();
log_message("FPTN client successfully started on " + cfg.tun_interface + " (table " + cfg.route_table + ")", "info");
return true;
}
@@ -225,20 +311,42 @@ function status_json() {
let running = pid != null;
let rule_count = enabled_rule_count();
let ver = package_version();
let tun_up = command_status("ip link show " + shell_quote(cfg.tun_interface) + " >/dev/null 2>&1") == 0;
let route_installed = command_status("ip route show table " + cfg.route_table + " default dev " + shell_quote(cfg.tun_interface) + " 2>/dev/null | grep -q default") == 0;
let rule_installed = command_status("ip rule show 2>/dev/null | grep -q " + shell_quote(cfg.route_table)) == 0;
let ready = installed && running && tun_up && route_installed && rule_installed && rule_count > 0;
let status_msg = "FPTN is running";
if (!installed)
status_msg = "FPTN is not installed";
else if (rule_count == 0)
status_msg = "FPTN is not configured";
else if (!running)
status_msg = "FPTN is stopped";
else if (!tun_up)
status_msg = "FPTN is running (degraded: tun interface down)";
else if (!route_installed)
status_msg = "FPTN is running (degraded: table " + cfg.route_table + " route missing)";
else if (!rule_installed)
status_msg = "FPTN is running (degraded: table " + cfg.route_table + " ip rule missing)";
write_json({
installed: installed,
configured: rule_count > 0,
enabled_rule_count: rule_count,
service_running: running,
process_running: running,
tun_up: tun_up,
route_installed: route_installed,
rule_installed: rule_installed,
pid: pid,
version: ver,
binary: cfg.binary,
tun_interface: cfg.tun_interface,
route_table: cfg.route_table,
log_file: cfg.log_file,
ready: installed && running && rule_count > 0,
status_message: running ? "FPTN is running" : (installed ? "FPTN is installed but not running" : "FPTN is not installed")
ready: ready,
status_message: status_msg
});
return true;
}
@@ -258,6 +366,8 @@ function module_exports() {
package_version: package_version,
start_runtime: start_runtime,
stop_runtime: stop_runtime,
ensure_routing: ensure_routing,
supervise_runtime: supervise_runtime,
status_json: status_json,
check_json: check_json
};
@@ -275,6 +385,10 @@ else if (mode == "restart-runtime") {
stop_runtime();
exit(start_runtime() ? 0 : 1);
}
else if (mode == "ensure-routing")
exit(ensure_routing() ? 0 : 1);
else if (mode == "supervise-runtime")
exit(supervise_runtime() ? 0 : 1);
else if (mode == "status")
status_json();
else if (mode == "check")
@@ -286,6 +400,6 @@ else if (mode == "package-version" || mode == "version")
else if (mode == "enabled-rule-count")
printf("%d\n", enabled_rule_count());
else {
warn("Usage: providers/fptn/runtime.uc <start-runtime|stop-runtime|restart-runtime|status|check|installed|version>\n");
warn("Usage: providers/fptn/runtime.uc <start-runtime|stop-runtime|restart-runtime|ensure-routing|supervise-runtime|status|check|installed|version>\n");
exit(1);
}
+20
View File
@@ -556,6 +556,16 @@ let strings = {
critical_keyword_fallen: "Down",
critical_keyword_error: "Error",
btn_update_component: "Update",
fptn_token_title: "FPTN Access Token",
fptn_token_prompt: "Send the new FPTN access token (copy/forward from @fptn_bot):",
fptn_token_invalid: "Invalid or empty FPTN token. Please provide a valid token.",
fptn_token_no_section: "Token saved, but no section with action 'fptn' was found.",
fptn_restarting: "Updating token and connecting FPTN...",
fptn_token_updated_title: "FPTN Token Updated and Connected!",
fptn_token_saved_connecting: "Token saved. Connecting to FPTN...",
fptn_token_retry_hint: "Connecting in background; routing will be applied automatically.",
fptn_token_change_btn: "FPTN Token",
fptn_token_btn: "FPTN Token",
},
ru: {
@@ -1113,6 +1123,16 @@ let strings = {
critical_keyword_fallen: "Упал",
critical_keyword_error: "Ошибка",
btn_update_component: "Обновить",
fptn_token_title: "FPTN Access Token",
fptn_token_prompt: "Пришлите новый токен доступа FPTN (скопируйте или перешлите из @fptn_bot):",
fptn_token_invalid: "Некорректный или пустой токен FPTN. Попробуйте ещё раз.",
fptn_token_no_section: "Токен сохранён, но секция с действием 'fptn' не найдена в конфиге.",
fptn_restarting: "Обновляю токен и запускаю FPTN...",
fptn_token_updated_title: "FPTN токен успешно обновлён и подключён!",
fptn_token_saved_connecting: "Токен сохранён, но FPTN пока подключается.",
fptn_token_retry_hint: "Подключение продолжится в фоне; маршрутизация применится автоматически.",
fptn_token_change_btn: "Сменить токен FPTN",
fptn_token_btn: "Токен FPTN",
}
};
+19 -1
View File
@@ -435,10 +435,26 @@ function clash_api_json(action, arg1, arg2, arg3) {
}
function capture_selector_state() {
return selector_state_from_proxies_payload(clash_api_json("get_proxies"));
let state = selector_state_from_proxies_payload(clash_api_json("get_proxies"));
if (type(state) == "object" && length(keys(state)) > 0) {
let persistent_path = getenv("TACHYON_PERSISTENT_SELECTOR_STATE_FILE") || "/etc/tachyon/selector_state.json";
let disk_state = common.read_json_file(persistent_path);
if (type(disk_state) != "object")
disk_state = {};
for (let k, v in state)
disk_state[k] = v;
common.write_json_file(persistent_path, disk_state, 2);
}
return state;
}
function restore_selector_state(snapshot) {
if (snapshot == null || (type(snapshot) == "object" && length(keys(snapshot)) == 0)) {
let persistent_path = getenv("TACHYON_PERSISTENT_SELECTOR_STATE_FILE") || "/etc/tachyon/selector_state.json";
let disk_state = common.read_json_file(persistent_path);
if (type(disk_state) == "object" && length(keys(disk_state)) > 0)
snapshot = disk_state;
}
let pairs = selector_restore_pairs(snapshot, clash_api_json("get_proxies"));
for (let pair in pairs)
@@ -948,6 +964,8 @@ function start_main() {
module_success(PARENTAL_QUOTA_UC, [ "install-cron" ]);
module_success(PARENTAL_QUOTA_UC, [ "tick" ]);
restore_selector_state(null);
module_background(UI_UC, [ "latency-boot-sweep" ]);
module_background(UPDATES_UC, [ "list-update" ]);
module_background(LIB_DIR + "/service/lifecycle.uc", [ "discover-awg-mtu" ]);
+6
View File
@@ -159,6 +159,9 @@ function emit_reload_plan(previous, current, context) {
!needs.zapret_restart &&
!needs.zapret2_restart &&
!needs.byedpi_restart &&
!needs.wdtt_restart &&
!needs.olcrtc_restart &&
!needs.fptn_restart &&
!needs.dnsmasq_configure &&
!needs.dnsmasq_restore)
needs.sing_box_reload = true;
@@ -169,6 +172,9 @@ function emit_reload_plan(previous, current, context) {
needs.zapret_restart ||
needs.zapret2_restart ||
needs.byedpi_restart ||
needs.wdtt_restart ||
needs.olcrtc_restart ||
needs.fptn_restart ||
needs.dnsmasq_configure ||
needs.dnsmasq_restore ||
needs.cron_refresh ||
+184 -3
View File
@@ -882,6 +882,20 @@ function view_menu(token, chat_id, msg_id) {
{ text: t("menu_help"), callback_data: "/help" }
]
];
let has_fptn = false;
let sections = api.get_sections();
for (let i = 0; i < length(sections); i++) {
if (sections[i].action == "fptn") {
has_fptn = true;
break;
}
}
if (has_fptn) {
push(keyboard, [
{ text: "🔑 " + t("fptn_token_btn"), callback_data: "/fptn_token" }
]);
}
if (msg_id) edit_message(token, chat_id, msg_id, text, "HTML", keyboard);
else send_message(token, chat_id, text, "HTML", keyboard);
@@ -1046,6 +1060,130 @@ function view_outbounds(token, chat_id, msg_id, group_name) {
else send_message(token, chat_id, text, "HTML", keyboard);
}
function save_persistent_selector_choice(group_tag, proxy_tag) {
group_tag = as_string(group_tag);
proxy_tag = as_string(proxy_tag);
if (group_tag == "" || proxy_tag == "")
return false;
let path = getenv("TACHYON_PERSISTENT_SELECTOR_STATE_FILE") || "/etc/tachyon/selector_state.json";
let state = common.read_json_file(path);
if (type(state) != "object")
state = {};
state[group_tag] = proxy_tag;
return common.write_json_file(path, state, 2);
}
function extract_clean_fptn_token(raw) {
let text = trim(as_string(raw));
if (text == "") return "";
let m = match(text, /(?:access_token|token)[\s:=]+["']?([A-Za-z0-9_\-\.]{8,})["']?/i);
if (m && m[1])
return trim(m[1]);
m = match(text, /^["']([A-Za-z0-9_\-\.]{8,})["']$/);
if (m && m[1])
return trim(m[1]);
m = match(text, /^([A-Za-z0-9_\-\.]{8,})$/);
if (m && m[1])
return trim(m[1]);
return "";
}
function mask_fptn_token(tok) {
tok = as_string(tok);
if (length(tok) > 8)
return "••••••••" + substr(tok, length(tok) - 4);
if (length(tok) > 0)
return "••••";
return t("status_disabled");
}
function handle_fptn_token_update(token, chat_id, raw_input, target_sec) {
let clean_tok = extract_clean_fptn_token(raw_input);
if (clean_tok == "") {
send_message(token, chat_id, "❌ " + t("fptn_token_invalid"), "HTML", [
[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]
]);
return false;
}
let c = uci_core.cursor();
c.load(CONFIG_NAME);
let all = c.get_all(CONFIG_NAME);
let fptn_count = 0;
if (target_sec && all[target_sec]) {
c.set(CONFIG_NAME, target_sec, "access_token", clean_tok);
fptn_count++;
} else {
for (let sname in keys(all)) {
let s = all[sname];
if (type(s) == "object" && s.action == "fptn") {
c.set(CONFIG_NAME, sname, "access_token", clean_tok);
fptn_count++;
}
}
}
c.commit(CONFIG_NAME);
try {
let fc = uci_core.cursor();
if (fc.load("fptn")) {
if (fc.get_all("fptn", "config")) {
fc.set("fptn", "config", "access_token", clean_tok);
fc.commit("fptn");
}
}
} catch (e) {}
let masked = mask_fptn_token(clean_tok);
if (fptn_count == 0) {
let msg = "⚠️ <b>" + t("fptn_token_no_section") + "</b>\nToken: <code>" + escape_html(masked) + "</code>";
send_message(token, chat_id, msg, "HTML", [
[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]
]);
return true;
}
send_message(token, chat_id, "⏳ <b>" + t("fptn_token_saved_connecting") + "</b>\nToken: <code>" + escape_html(masked) + "</code>", "HTML");
// Restart FPTN runtime
command_status(command_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/providers/fptn/runtime.uc", "restart-runtime" ]));
// Poll up to 5 seconds for tun-fptn and table 4249 routing
let connected = false;
for (let i = 0; i < 5; i++) {
let out = command_output_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/providers/fptn/runtime.uc", "status" ]);
try {
let st = json(out);
if (st && st.running && st.tun_up && st.route_installed) {
connected = true;
break;
}
} catch (e) {}
command_status(command_from_args([ "sleep", "1" ]));
}
if (connected) {
let msg = "✅ <b>" + t("fptn_token_updated_title") + "</b>\n" +
"Token: <code>" + escape_html(masked) + "</code>\n" +
"Status: 🟢 Connected (tun-fptn & routing ready)";
send_message(token, chat_id, msg, "HTML", [
[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]
]);
} else {
let msg = "⚠️ <b>" + t("fptn_token_saved_connecting") + "</b>\n" +
"Token: <code>" + escape_html(masked) + "</code>\n\n" +
t("fptn_token_retry_hint");
send_message(token, chat_id, msg, "HTML", [
[{ text: "⬅️ " + t("nav_menu"), callback_data: "/menu" }]
]);
}
return true;
}
function handle_switch(token, chat_id, msg_id, group_name, server_name) {
api.clash_request("PUT", "proxies/" + group_name, { name: server_name });
// A successful switch answers 204 with an empty body, which clash_request()
@@ -1058,6 +1196,8 @@ function handle_switch(token, chat_id, msg_id, group_name, server_name) {
t("outbound_group") + ": <code>" + escape_html(group_name) + "</code>\n" +
t("outbound_server") + ": <code>" + escape_html(server_name) + "</code>\n\n" +
t("outbound_check_hint"), "HTML");
} else {
save_persistent_selector_choice(group_name, server_name);
}
view_outbounds(token, chat_id, msg_id, group_name);
}
@@ -1094,6 +1234,10 @@ function view_section_editor(token, chat_id, msg_id, sec_name) {
if (s.action == "proxy" || s.action == "route") {
text += t("status_target") + ": <code>" + escape_html(s.target || "main-out") + "</code>\n";
}
if (s.action == "fptn") {
let tok = s.access_token || "";
text += "🔑 Token: <code>" + escape_html(mask_fptn_token(tok)) + "</code>\n";
}
let d_count = length(common.list_option(s, "domain")) + length(common.list_option(s, "domain_suffix")) + length(common.list_option(s, "domain_keyword")) + length(common.list_option(s, "domain_regex"));
let ip_count = length(common.list_option(s, "ip")) + length(common.list_option(s, "ip_cidr"));
@@ -1116,6 +1260,9 @@ function view_section_editor(token, chat_id, msg_id, sec_name) {
if (s.action == "proxy" || s.action == "route") {
push(keyboard, [{ text: t("status_target") + ": " + (s.target || "main-out"), callback_data: "/sec_target " + sec_name }]);
}
if (s.action == "fptn") {
push(keyboard, [{ text: "🔑 " + t("fptn_token_change_btn"), callback_data: "/fptn_token " + sec_name }]);
}
push(keyboard, [
{ text: t("section_domains") + " (" + d_count + ")", callback_data: "/sec_list " + sec_name + " domain" },
@@ -2026,7 +2173,8 @@ function register_bot_commands(token) {
{ command: "close_connections", description: t("cmd_close_connections") },
{ command: "doctor", description: t("cmd_doctor") },
{ command: "restart", description: t("cmd_restart") },
{ command: "lang", description: t("cmd_lang") }
{ command: "lang", description: t("cmd_lang") },
{ command: "fptn", description: t("fptn_token_btn") }
];
tg_request(token, "setMyCommands", { commands: commands });
}
@@ -2537,6 +2685,32 @@ function dispatch_command(token, chat_id, text, msg_id) {
let grp = trim(join(" ", slice(parts, 1)));
return view_outbounds(token, chat_id, msg_id, grp);
}
if (cmd == "/fptn_token" || cmd == "/fptn") {
set_tg_state(chat_id, { action: "fptn_token" });
return send_message(token, chat_id,
"🔑 <b>" + t("fptn_token_title") + "</b>\n\n" +
t("fptn_token_prompt") + "\n\n<i>Отправьте /cancel для отмены</i>", "HTML");
}
if (match(cmd, /^\/(fptn_token|fptn)[ \t]+/)) {
let raw_token = trim(replace(cmd, /^\/(fptn_token|fptn)[ \t]+/, ""));
let all_sections = api.get_sections();
let target_sec = null;
for (let i = 0; i < length(all_sections); i++) {
let s = all_sections[i];
if (s[".name"] == raw_token || s.name == raw_token) {
target_sec = raw_token;
break;
}
}
if (target_sec) {
set_tg_state(chat_id, { action: "fptn_token", sec: target_sec });
return send_message(token, chat_id,
"🔑 <b>" + t("fptn_token_title") + " (" + escape_html(target_sec) + ")</b>\n\n" +
t("fptn_token_prompt") + "\n\n<i>Отправьте /cancel для отмены</i>", "HTML");
}
return handle_fptn_token_update(token, chat_id, raw_token, null);
}
if (cmd == "/sections" || cmd == "/rules") return view_sections(token, chat_id, msg_id);
if (cmd == "/devices") return view_devices(token, chat_id, msg_id);
@@ -3053,6 +3227,10 @@ function process_updates(token, admin_ids) {
view_quiet_hours(token, chat_id, null);
}
}
else if (state.action == "fptn_token") {
set_tg_state(chat_id, null);
handle_fptn_token_update(token, chat_id, trim(msg.text), state.sec || null);
}
continue;
}
@@ -3538,11 +3716,14 @@ else if (mode == "send") {
let msg_parts = [];
for (let i = 2; i < length(ARGV); i++)
push(msg_parts, ARGV[i]);
let message = join(" ", msg_parts);
if (message == "") { warn("No message text\n"); exit(1); }
exit(send_api(message));
}
else if (mode == "mask-token") {
print(mask_fptn_token(ARGV[1]), "\n");
exit(0);
}
else {
warn("Usage: service/telegram.uc <start-runtime|stop-runtime|worker|status|diagnose|send ...> ...\n");
warn("Usage: service/telegram.uc <start-runtime|stop-runtime|worker|status|diagnose|send|mask-token ...> ...\n");
exit(1);
}
@@ -23,6 +23,7 @@ let bool_option = common.bool_option;
let int_option = common.int_option;
let array_or_empty = common.array_or_empty;
let object_or_empty = common.object_or_empty;
let read_json_file = common.read_json_file;
let outbound_tag = runtime_constants.outbound_tag;
let tag = runtime_constants.tag;
@@ -883,6 +884,14 @@ function add_proxy_selector(config, section, selector_tags, urltest_candidate_ta
}
}
let persistent_selector_file = getenv("TACHYON_PERSISTENT_SELECTOR_STATE_FILE") || "/etc/tachyon/selector_state.json";
let saved_selector_state = read_json_file(persistent_selector_file);
if (type(saved_selector_state) == "object" && as_string(saved_selector_state[selector_tag]) != "") {
let saved_target = as_string(saved_selector_state[selector_tag]);
if (index(selector_outbounds, saved_target) >= 0)
selector_default = saved_target;
}
push(config.outbounds, {
type: "selector",
tag: selector_tag,
+34
View File
@@ -119,6 +119,40 @@ SIG_2="$(ucode_run "$STATE_UC" fptn-runtime-signature-fixture "$WORK_DIR/state_f
# 5. Test diagnostics runtime dispatch for get-fptn-status
STATUS_OUT="$(TACHYON_LIB="$TACHYON_LIB" ucode_run "$DIAGNOSTICS_UC" get-fptn-status)"
echo "$STATUS_OUT" | grep -q '"ready":' || fail "get-fptn-status should return status json"
echo "$STATUS_OUT" | grep -q '"process_running":' || fail "get-fptn-status should include process_running"
echo "$STATUS_OUT" | grep -q '"tun_up":' || fail "get-fptn-status should include tun_up"
echo "$STATUS_OUT" | grep -q '"route_installed":' || fail "get-fptn-status should include route_installed"
echo "$STATUS_OUT" | grep -q '"rule_installed":' || fail "get-fptn-status should include rule_installed"
echo "$STATUS_OUT" | grep -q '"status_message":' || fail "get-fptn-status should include status_message"
# 6. Test fptn ensure-routing command syntax
ENSURE_RES="$(TACHYON_LIB_DIR="$TACHYON_LIB" ucode_run "$FPTN_RUNTIME_UC" ensure-routing && echo "1" || echo "0")"
assert_eq "1" "$ENSURE_RES" "ensure-routing should execute successfully"
# 7. Test validator supports fptn rule action
cat >"$WORK_DIR/fptn_valid.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"main_action": "direct",
"dns_server": ["77.88.8.8"],
"bootstrap_dns_server": ["77.88.8.8"]
},
"sec_fptn": {
".name": "sec_fptn",
".type": "section",
"enabled": "1",
"action": "fptn",
"access_token": "secret_token_123"
}
}
JSON
TACHYON_LIB="$TACHYON_LIB" ucode -L "$TACHYON_LIB" "$ROOT_DIR/tachyon/files/usr/lib/config/validator.uc" validate-runtime-fixture "$WORK_DIR/fptn_valid.json" "{}" || fail "validator rejected valid fptn config"
# 8. Test telegram token masking subcommand
TG_MASK_OUT="$(ucode -L "$TACHYON_LIB" "$ROOT_DIR/tachyon/files/usr/lib/service/telegram.uc" mask-token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9Q7Fx")"
assert_eq "••••••••Q7Fx" "$TG_MASK_OUT" "telegram token masking"
echo "fptn component tests passed"
+15
View File
@@ -222,4 +222,19 @@ assert_state_plan_exit 2 "$previous_state" "$current_state" 0 0 0 0 0
rm -f "$previous_state"
assert_state_plan_exit 2 "$previous_state" "$current_state" 0 0 0 0 0
cat >"$previous_state" <<EOF
format=1
service_trigger_signature=svc
fptn_runtime_signature=tokenA
EOF
cat >"$current_state" <<EOF
format=1
service_trigger_signature=svc
fptn_runtime_signature=tokenB
EOF
plan="$(run_state_case state_fptn_changed "$previous_state" "$current_state" 0 0 0 0 0)"
assert_plan_value "$plan" changed_fptn_runtime 1
assert_plan_value "$plan" needs_fptn_restart 1
assert_plan_value "$plan" has_work 1
printf 'service reload plan checks passed\n'