Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RELEASE_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.31.0
2.32.0
29 changes: 29 additions & 0 deletions docs/releases/2.32.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Исправления по мотивам других форков, совместимость с sing-box-extended 2.7.3

Семь ошибок и одно улучшение подписок. Пять ошибок нашлись при сравнении с другими форками (Screamshow, slayer326, Tachyon) и с новой версией ядра sing-box-extended, ещё две — при проверке на ucode и busybox из OpenWrt. Обновление ставится поверх, настройки не меняются, ничего делать вручную не нужно.

## Ядро sing-box-extended 2.7.3

Вышла версия sing-box-extended 2.7.3 на основе sing-box 1.14.3. В ней исправлена ошибка 2.7.2: UDP с доменным адресом (QUIC/HTTP/3 к доменам из списков) не проходил через группы selector и urltest. Обновить ядро можно кнопкой «Обновить» у sing-box в «Компонентах». Prokop 2.32.0 учитывает новые проверки конфигурации в 2.7.3 (см. ниже), поэтому ставить 2.32.0 лучше до обновления ядра.

## Что изменилось

- **Фильтр по имени сервера в группах URLTest и «Приоритет».** Чтобы фильтр не терял сервер после переименования, Prokop помнит прежние имена серверов. Если старое имя переименованного сервера провайдер отдавал новому серверу, фильтр выбирал оба. Теперь прежнее имя действует, только пока его не носит другой сервер.
- **Ping к адресам из списков прокси-секций.** ICMP и другие протоколы, кроме TCP и UDP, к подсетям прокси-секции получали метку прокси и уходили в локальный маршрут. На ping отвечал сам роутер, и казалось, что адрес доступен, а traceroute ничего не показывал. Теперь в прокси уходят только TCP и UDP, остальное идёт обычным маршрутом. Kill-switch по-прежнему блокирует для защищённых секций любой протокол.
- **Адрес DNS-сервера больше не пишется в журнал.** При переключении резервного DNS в системный журнал записывался адрес сервера. У личного DoH (NextDNS, AdGuard и т. п.) в адресе есть идентификатор или токен. Теперь в журнале только номер приоритета и причина.
- **Узел xHTTP больше не ломает всю конфигурацию на sing-box-extended 2.7.3.** Ядро 2.7.3 отвергает конфигурацию целиком, если у узла xHTTP задана таблица session ID без длины или с длиной, которая даёт меньше 2³¹ вариантов, а также если в xmux заданы одновременно `max_connections` и `max_concurrency`. Prokop теперь убирает такие настройки у этого узла и пишет предупреждение в журнал. На 2.7.2 таблица без длины и так не использовалась.
- **Ссылки xHTTP с таблицей session ID снова разбираются на роутере.** С версии 2.28.0 проверка значения `sessionIDTable` использовала регулярное выражение, которое библиотека musl в OpenWrt не принимает. Подписка или ссылка с таким узлом не разбиралась на роутере, хотя тесты проходили. Новый тест следит, чтобы таких выражений больше не было.
- **Smart Detect ждёт запуска пробы.** busybox в OpenWrt не понимает `sleep 0.1`, поэтому ожидание запуска временного sing-box проходило мгновенно, и проба часто заканчивалась ошибкой «проба не готова». Остановка пробы тоже не ждала её завершения. Теперь ожидание ограничено временем (3 и 2 секунды).
- **sing-box не пишет дамп памяти в /tmp.** Для ядра, которое ставит сам Prokop, был разрешён дамп памяти без ограничения. OpenWrt пишет такие дампы в `/tmp`, то есть в оперативную память. При падении sing-box в нативном коде дамп размером с процесс заполнял память, а procd перезапускал sing-box до пяти раз. Теперь дампы отключены. Init-скрипт ядра обновится при следующем запуске Prokop.

## Подписки: смена только имён серверов

Панели вроде Remnawave пишут в имя каждого сервера остаток трафика или дней. Из-за этого каждое плановое обновление подписки считалось изменением и перезапускало sing-box, а перезапуск рвёт все соединения через прокси. Теперь плановое обновление не применяет подписку, если у серверов поменялись только имена, а сами серверы прежние. Новые имена появятся при ручном обновлении подписки, при следующем настоящем изменении серверов или после перезапуска Prokop.

## Что проверено

- PASS: автоматические тесты бэкенда, полный прогон. Новые тесты падают без исправлений и проходят с ними: `urltest_reused_name_filter`, `xhttp_core_refused_settings`, `subscription_names_only_update`, `ucode_regex_repeat_limit`, `busybox_fractional_sleep`, дополнены `dns_failover`, `singbox_managed_service_text`, `nft_apply`, `nft_device_mac`.
- PASS: конфигурации из `xhttp_core_refused_settings` и `xhttp_remnawave_settings` проходят `sing-box check` настоящего sing-box-extended 2.7.3 (linux-amd64). Без исправления 2.7.3 отвергает узел с таблицей session ID без длины («session_id_length.from must be greater than 0») и с короткой длиной («session_id_table or session_id_length is too small»).
- PASS: ошибка регулярного выражения и `sleep 0.1` воспроизведены на ucode и busybox из OpenWrt 25.12.6 (x86_64, тот же musl, что на роутере).
- NOT TESTED: на роутере.
- NOT TESTED: ping к подсетям секции после исправления на настоящем трафике; правило проверено тестами nftables.
12 changes: 9 additions & 3 deletions prokop/files/usr/lib/experiments/smart_detect.uc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const CONFIG = getenv("SB_CONFIG") || "/etc/sing-box/config.json";
const BIN = getenv("SB_BIN") || "/usr/bin/sing-box";
const PROC = getenv("PROKOP_SMART_DETECT_PROC") || "/proc";
const PORTS = { "11EE": 4590, "11EF": 4591 };
// A tenth of a second where sleep takes fractions, a second on OpenWrt,
// whose busybox sleep refuses "0.1".
function nap() { system("sleep 0.1 2>/dev/null || sleep 1"); }

function domain(host) {
if (length(host) > 253 || match(host, /^[a-z0-9.-]+$/) == null || ip.valid_ip(host)) return false;
let labels = split(host, ".");
Expand Down Expand Up @@ -165,12 +169,14 @@ function run(host, target) {
if (child) child.close();
if (!identity.record(pidfile, pid)) return c.fail("probe_start_failed");
// Wait for both ports; probes are never sent to an unrelated listener.
// OpenWrt's busybox sleep takes whole seconds only: the wait is bounded
// by time, not by a count of naps.
let ready = false;
for (let attempt = 0; attempt < 20; attempt++) {
for (let deadline = time() + 3; time() <= deadline;) {
if (identity.matches(pidfile, BIN, args, true, true) == "") break;
ready = ports_owned(probe_listeners(), pid);
if (ready) break;
system("sleep 0.1");
nap();
}
if (!ready) return c.fail("probe_not_ready");
let direct = probe(host, 4590), proxy = probe(host, 4591), result = verdict(direct, proxy);
Expand All @@ -190,7 +196,7 @@ function run(host, target) {
try { result = work(); } catch (e) { result = c.fail("probe_failed"); }
{
identity.signal(pidfile, BIN, args, true, "TERM", true);
for (let attempt = 0; attempt < 10 && identity.matches(pidfile, BIN, args, true, true) != ""; attempt++) system("sleep 0.1");
for (let deadline = time() + 2; time() <= deadline && identity.matches(pidfile, BIN, args, true, true) != "";) nap();
identity.signal(pidfile, BIN, args, true, "KILL", true);
fs.unlink(pidfile); fs.unlink(DIR + "/probe.json");
c.locks.release(DIR + "/lock", c.self());
Expand Down
10 changes: 8 additions & 2 deletions prokop/files/usr/lib/nft/apply.uc
Original file line number Diff line number Diff line change
Expand Up @@ -945,8 +945,14 @@ function nft_add_section_priority_rules(table, section, interface_set, localv4_s
if (needs_udp_ip_port_rules && !nft_add_subnet_ports(table, sets.udp_subnet_ports, core_ip.DISCORD_VOICE_PORTS_NFT))
return false;
let has_port_only_matchers = section_has_nft_port_only_matchers(section);
let match_ip4 = [ "ip", "daddr", "@" + as_string(sets.subnets) ];
let match_ip6 = [ "ip6", "daddr", "@" + as_string(sets.subnets6) ];
// A captured packet gets the mark of the TPROXY local route, which takes
// only TCP and UDP: an ICMP echo to a listed subnet would be answered by
// the router itself and other protocols lost. They keep the main table.
// The kill-switch (its own verdict) still stops every protocol.
let capture_l4 = section_priority_action(section) == "capture" && nft_priority_verdict_override == null ?
[ "meta", "l4proto", "{ tcp, udp }" ] : [];
let match_ip4 = [ "ip", "daddr", "@" + as_string(sets.subnets), ...capture_l4 ];
let match_ip6 = [ "ip6", "daddr", "@" + as_string(sets.subnets6), ...capture_l4 ];
let match_ip_port4_tcp = [ "ip", "daddr", ".", "tcp", "dport", "@" + as_string(sets.ip_ports) ];
let match_ip_port4_udp = [ "ip", "daddr", ".", "udp", "dport", "@" + as_string(sets.ip_ports) ];
let match_ip_port6_tcp = [ "ip6", "daddr", ".", "tcp", "dport", "@" + as_string(sets.ip6_ports) ];
Expand Down
5 changes: 3 additions & 2 deletions prokop/files/usr/lib/singbox/dns_failover.uc
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,10 @@ function apply_selections(state, selections) {
state.main_index = candidate.main_index;
state.bootstrap_index = candidate.bootstrap_index;
for (let item in changed) {
let values = item.kind == "main" ? state.main_servers : state.bootstrap_servers;
let selected = item.selected;
log_message("switched " + item.kind + " DNS to priority " + as_string(selected.index + 1) + " (" + values[selected.index] + ", " + selected.reason + ")", "info");
// A DoH address can carry a private token in its path or query: the
// log names the priority and the reason, never the server itself.
log_message("switched " + item.kind + " DNS to priority " + as_string(selected.index + 1) + " (" + selected.reason + ")", "info");
}
return true;
}
Expand Down
89 changes: 86 additions & 3 deletions prokop/files/usr/lib/singbox/generator.uc
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,76 @@ function drop_unknown_xhttp_fields(options, dropped) {
}
}

// The named session ID alphabets of sing-box-extended (and Xray).
const XHTTP_SESSION_ID_TABLES = {
ALPHABET: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
Alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
BASE36: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",
Base62: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
HEX: "0123456789ABCDEF",
alphabet: "abcdefghijklmnopqrstuvwxyz",
base36: "0123456789abcdefghijklmnopqrstuvwxyz",
hex: "0123456789abcdef",
number: "0123456789"
};
// The fewest session IDs a table and length must allow (2 << 30).
const XHTTP_SESSION_ID_MIN_ROOM = 2147483648;

// [ from, to ] of an xHTTP range as sing-box reads it (a number, "from-to"
// or { from, to }); [ 0, 0 ] when absent or unreadable.
function xhttp_range_bounds(value) {
if (type(value) == "int" || type(value) == "double")
return [ int(value), int(value) ];
if (type(value) == "object")
return [ int(value.from || 0), int(value.to || 0) ];
let text = trim(as_string(value));
let parts = match(text, /^([0-9]+)(-([0-9]+))?$/);
if (parts == null)
return [ 0, 0 ];
return [ int(parts[1]), int(parts[3] != null ? parts[3] : parts[1]) ];
}

// Whether a session ID table and length pass the check sing-box-extended
// 2.7.3 makes on the whole configuration: a length from 1 and room for
// 2^31 IDs, ASCII only. An older core used such a table with no length as
// no table at all (a UUID session ID).
function xhttp_session_id_settings_valid(options) {
let table = as_string(options.session_id_table);
table = XHTTP_SESSION_ID_TABLES[table] || table;
if (match(table, /^[ -~]+$/) == null)
return false;
let bounds = xhttp_range_bounds(options.session_id_length);
if (bounds[0] <= 0 || bounds[1] < bounds[0])
return false;
let room = 0;
for (let n = bounds[0]; n <= bounds[1] && room < XHTTP_SESSION_ID_MIN_ROOM; n++)
room += length(table) ** n;
return room >= XHTTP_SESSION_ID_MIN_ROOM;
}

// Settings that every core of sing-box-extended 2.7.3 or newer refuses for
// the whole configuration, removed from one xHTTP options object (the
// outbound's transport or its download options).
function drop_refused_xhttp_settings(options, dropped) {
if (type(options) != "object")
return;
if (exists(options, "session_id_table") && !xhttp_session_id_settings_valid(options)) {
delete options.session_id_table;
delete options.session_id_length;
push(dropped, "session_id_table");
}
if (options.mode == "stream-one" && exists(options, "download")) {
delete options.download;
push(dropped, "download");
}
let xmux = options.xmux;
if (type(xmux) == "object" && xhttp_range_bounds(xmux.max_connections)[1] > 0 &&
xhttp_range_bounds(xmux.max_concurrency)[1] > 0) {
delete xmux.max_connections;
push(dropped, "xmux.max_connections");
}
}

// The settings this core does not read are left out of every xHTTP
// outbound, also its download options, with a warning: the node may then
// not reach its server, but the configuration stays valid. Its ranges are
Expand All @@ -780,6 +850,12 @@ function drop_unsupported_xhttp_settings(config) {
for (let outbound in array_or_empty(config.outbounds)) {
if (!outbound_uses_xhttp(outbound))
continue;
let refused = [];
drop_refused_xhttp_settings(outbound.transport, refused);
drop_refused_xhttp_settings(outbound.transport.download, refused);
if (length(refused) > 0)
warn("xHTTP settings of outbound '", as_string(outbound.tag), "' that sing-box refuses for the whole configuration were left out: ",
join(", ", uniq(refused)), "\n");
let dropped = [];
drop_unknown_xhttp_fields(outbound.transport, dropped);
drop_unknown_xhttp_fields(outbound.transport.download, dropped);
Expand Down Expand Up @@ -1447,12 +1523,16 @@ function regex_match_set(tags, names, regexes) {
return object_keys_set(runtime_urltest.regex_matching_tag_array(tags, names, regexes));
}

function tag_name_filter_matches(tag, names, name_filter, regex_set, metadata) {
// A previous name of a renamed server (filterNames) selects it only while no
// candidate carries that name now: a name given to another server selects
// that server alone.
function tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, current_names) {
let name = tag_display_name(tag, names);
if (array_contains(name_filter, name) || regex_set[tag])
return true;
current_names = object_or_empty(current_names);
for (let previous_name in array_or_empty(object_or_empty(object_or_empty(metadata).filterNames)[tag]))
if (array_contains(name_filter, previous_name))
if (!current_names[previous_name] && array_contains(name_filter, previous_name))
return true;
return false;
}
Expand Down Expand Up @@ -1526,10 +1606,13 @@ function urltest_matching_candidate_outbounds(urltest_candidate_tags, names, cou
let regex_set = regex_match_set(urltest_candidate_tags, names, regexes);
let base_filter_configured = name_or_country_filter_configured(name_filter, regexes, country_filter);
let additional_set = object_keys_set(additional_matches);
let current_names = {};
for (let tag in array_or_empty(urltest_candidate_tags))
current_names[tag_display_name(tag, names)] = true;
let result = [];

for (let tag in array_or_empty(urltest_candidate_tags)) {
let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set, metadata) ||
let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set, metadata, current_names) ||
tag_country_filter_matches(tag, countries, country_filter);
let matches = additional_set[tag] || base_matches;
if (proxy_parameters_enabled && proxy_parameters_operator == "or") {
Expand Down
2 changes: 1 addition & 1 deletion prokop/files/usr/lib/singbox/managed_service.uc
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function text() {
" procd_open_instance\n" +
" procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" +
" procd_set_param stderr \"$log_stderr\"\n" +
" procd_set_param limits core=\"unlimited\"\n" +
" procd_set_param limits core=\"0\"\n" +
" procd_set_param limits nofile=\"1000000 1000000\"\n" +
" procd_set_param respawn\n" +
" procd_set_param term_timeout " + TERM_TIMEOUT + "\n" +
Expand Down
Loading
Loading