diff --git a/luci-app-homeproxy-pro/Makefile b/luci-app-homeproxy-pro/Makefile index 2924bd76f..7094af7e2 100644 --- a/luci-app-homeproxy-pro/Makefile +++ b/luci-app-homeproxy-pro/Makefile @@ -18,7 +18,7 @@ LUCI_DEPENDS:= \ PKG_NAME:=luci-app-homeproxy-pro PKG_VERSION:=28.10.1.14 -PKG_RELEASE:=20 +PKG_RELEASE:=21 LUCI_BASENAME:=homeproxy-pro diff --git a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js index 5a6dd650c..1e4806881 100644 --- a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js +++ b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js @@ -316,32 +316,68 @@ return view.extend({ s = m.section(form.NamedSection, 'config', 'homeproxy-pro', _('Diagnostic report')); s.anonymous = true; + /* The button belongs on the title line, not parked under the + * description where it reads as part of the paragraph. + * + * It cannot simply be passed in as part of the title: form.js builds + * the heading with `E('h3', {}, this.title)` behind a + * `typeof(this.title) == 'string'` guard, so a node here is dropped + * and the heading disappears entirely. The section's own render() is + * the seam instead - it runs on every re-render and resolves with the + * finished section element, so the button goes into the h3 that + * render() has just built. Nothing of LuCI's own rendering is + * reproduced, which is what keeps this from silently drifting when + * form.js changes. */ + const reportButton = () => E('button', { + /* The same class triple as the "Clean log" button above, on + * purpose: `btn` is what carries the button's font-size, so + * matching it keeps the two the same size without this view + * hard-coding a size of its own that the theme can change + * under it. */ + 'class': 'btn cbi-button cbi-button-action', + 'style': 'margin-left: 8px;', + /* createHandlerFn(ctx, fn) only uses ctx to resolve a string + * method name and as the `this` for the call; fn is an arrow + * function, so ctx is inert here. The section is passed anyway + * so the binding reads like the one it replaced. */ + 'click': ui.createHandlerFn(s, () => { + return hp.rpcCall('debug_report', [], { expect: { '': {} } }) + .then((res) => { + if (!res.result) + throw new Error(res.error || _('Could not build the report.')); + return fs.read_direct(res.path, 'blob'); + }) + .then((blob) => { + const url = window.URL.createObjectURL(blob, { type: 'text/markdown' }); + const link = document.createElement('a'); + link.href = url; + link.download = 'homeproxy-pro-debug.log'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }); + }) + }, [ _('Generate and download') ]); + + const renderDiagnosticSection = s.render.bind(s); + s.render = function() { + return renderDiagnosticSection().then((el) => { + /* The gap before the button is the stylesheet's + * `margin-left`, not a text node - a spacer node would drag + * `document` into the test environment for nothing. */ + const heading = el.querySelector('h3'); + if (heading) + dom.append(heading, reportButton()); + return el; + }); + }; + o = s.option(form.DummyValue, '_debug_report'); o.rawhtml = true; o.render = function() { return E('div', { 'class': 'cbi-value' }, [ - E('p', {}, _('Collects system, dependency, routing, firewall and configuration state into one file. Credentials are masked by option name; public addresses and LAN topology are not. Read it before posting it anywhere.')), - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': ui.createHandlerFn(this, () => { - return hp.rpcCall('debug_report', [], { expect: { '': {} } }) - .then((res) => { - if (!res.result) - throw new Error(res.error || _('Could not build the report.')); - return fs.read_direct(res.path, 'blob'); - }) - .then((blob) => { - const url = window.URL.createObjectURL(blob, { type: 'text/markdown' }); - const link = document.createElement('a'); - link.href = url; - link.download = 'homeproxy-pro-debug.log'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - }); - }) - }, [ _('Generate and download') ]) + E('p', {}, _('Collects system, dependency, routing, firewall and configuration state into one file. Credentials are masked by option name; public addresses and LAN topology are not. Read it before posting it anywhere.')) ]); }; diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh index 9581e8902..45b280fbf 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh @@ -230,6 +230,38 @@ hp_prepare_ruleset_dir() { || log "Warning: failed to create ${hp_dir}/ruleset." } +# hp_rule_set_current +# 0 when is already the up-to-date rendering of , 1 when it +# has to be generated. +# +# Generating china-domain.json measured 3540 ms on the x86-64 target +# (111,353 suffixes, 2.2 MB), and the source lists are replaced at most daily +# by update_resources.sh. Regenerating unconditionally therefore spent a +# third of every reload - every Save & Apply, every node switch - rebuilding a +# byte-identical file, which is the single largest cost in a reload. +# +# The three conditions below are exactly the cases the unconditional version +# had to cover: +# +# [ -f "$out" ] a fresh install predates the file, or a previous run +# produced nothing +# [ -s "$out" ] the previous run was interrupted after truncating it; an +# empty rule-set would make the route-side set match nothing, +# which is the silent inversion this whole file exists to +# prevent +# [ "$out" -nt "$src" ] the list was replaced while the service was stopped +# +# A newer-but-corrupt output is deliberately NOT detected here. It does not +# need to be: the generated config names the file, a rule-set sing-box cannot +# parse fails the start, and the health gate rolls back to the known-good +# configuration. The check is cheap (<1 ms) and cannot mask a real failure - +# it can only skip work that would rebuild identical bytes. +hp_rule_set_current() { + local src="$1" out="$2" + + [ -f "$out" ] && [ -s "$out" ] && [ "$out" -nt "$src" ] +} + # hp_prepare_runtime_files # Create the mode-specific working files, truncate the instance logs and hand # every runtime file to the sing-box user. / are "1"/"0". @@ -249,7 +281,12 @@ hp_prepare_runtime_files() { # file and sing-box would refuse to start. A missing source list is not # an error - the generator then emits no route-side rule at all. if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_ip4.txt" ]; then - if ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \ + if hp_rule_set_current "$hp_dir/resources/china_ip4.txt" "$hp_dir/resources/china_ip4.json"; then + # Unchanged source: keep the file, but re-assert ownership anyway - + # chown costs nothing and survives an install that reset it. + chown sing-box:sing-box "$hp_dir/resources/china_ip4.json" 2>"/dev/null" \ + || log "Warning: failed to hand ${hp_dir}/resources/china_ip4.json to sing-box." + elif ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \ "$hp_dir/resources/china_ip4.txt" "$hp_dir/resources/china_ip4.json" >>"$LOG_PATH" 2>&1; then chown sing-box:sing-box "$hp_dir/resources/china_ip4.json" 2>"/dev/null" \ || log "Warning: failed to hand ${hp_dir}/resources/china_ip4.json to sing-box." @@ -275,7 +312,10 @@ hp_prepare_runtime_files() { cn_ipv6="$(grep -cE '^[0-9a-fA-F:]+:[0-9a-fA-F:]*(/[0-9]{1,3})?[[:space:]]*$' \ "$hp_dir/resources/china_ip6.txt" 2>"/dev/null")" [ -n "$cn_ipv6" ] || cn_ipv6=0 - if [ "$cn_ipv6" -gt 0 ] && ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \ + if [ "$cn_ipv6" -gt 0 ] && hp_rule_set_current "$hp_dir/resources/china_ip6.txt" "$hp_dir/resources/china_ip6.json"; then + chown sing-box:sing-box "$hp_dir/resources/china_ip6.json" 2>"/dev/null" \ + || log "Warning: failed to hand ${hp_dir}/resources/china_ip6.json to sing-box." + elif [ "$cn_ipv6" -gt 0 ] && ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \ "$hp_dir/resources/china_ip6.txt" "$hp_dir/resources/china_ip6.json" >>"$LOG_PATH" 2>&1; then chown sing-box:sing-box "$hp_dir/resources/china_ip6.json" 2>"/dev/null" \ || log "Warning: failed to hand ${hp_dir}/resources/china_ip6.json to sing-box." @@ -304,7 +344,10 @@ hp_prepare_runtime_files() { # DNS-side rule for it, and the split degrades to the resolver default # rather than failing the start. if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_list.txt" ]; then - if ucode -S "$hp_dir/scripts/runtime/domain_ruleset.uc" \ + if hp_rule_set_current "$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json"; then + chown sing-box:sing-box "$hp_dir/resources/china-domain.json" 2>"/dev/null" \ + || log "Warning: failed to hand ${hp_dir}/resources/china-domain.json to sing-box." + elif ucode -S "$hp_dir/scripts/runtime/domain_ruleset.uc" \ "$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json" >>"$LOG_PATH" 2>&1; then chown sing-box:sing-box "$hp_dir/resources/china-domain.json" 2>"/dev/null" \ || log "Warning: failed to hand ${hp_dir}/resources/china-domain.json to sing-box." diff --git a/luci-app-homeproxy-pro/tests/frontend-view-wiring.js b/luci-app-homeproxy-pro/tests/frontend-view-wiring.js index 40372e2b2..23f7ed428 100644 --- a/luci-app-homeproxy-pro/tests/frontend-view-wiring.js +++ b/luci-app-homeproxy-pro/tests/frontend-view-wiring.js @@ -83,11 +83,23 @@ class MockOption { } class MockSection { - constructor(kind, name) { + constructor(kind, name, title) { this.__kind = kind; this.__name = name; + this.title = title; this.options = []; } + /* form.js NamedSection.render() resolves with the finished section + * element, and status.js appends the report button into the h3 that + * render() built - the title itself cannot carry it, because form.js + * guards the heading with `typeof(this.title) == 'string'`. The mock + * reproduces exactly that much: a section element holding an h3. */ + render() { + return Promise.resolve(E('div', {}, [ + this.title != null && this.title !== '' ? E('h3', {}, this.title) : '', + ...this.options.map((o) => E('div', { 'class': 'cbi-section-node' }, [])) + ])); + } tab() {} taboption(_tab, ...args) { return this.option(...args); } option(kind, name, title, description) { @@ -120,8 +132,8 @@ function makeForm() { form.Map = class { constructor(title) { this.title = title; this.sections = []; } - section(kind, name) { - const section = new MockSection(kind && kind.__name__ ? kind.__name__ : String(kind), name); + section(kind, name, title) { + const section = new MockSection(kind && kind.__name__ ? kind.__name__ : String(kind), name, title); this.sections.push(section); return section; } @@ -268,18 +280,29 @@ function testAccessDomainLists() { * 3. status.js: each log view keeps its own basename * ====================================================================== */ -function E(tag, attrs, children) { - if (Array.isArray(tag) && attrs === undefined) - return { tag: null, attrs: {}, children: tag, appendChild() {} }; - +/* A node carries the two DOM operations a view can perform on an element it + * did not create itself: appendChild, and a tag lookup. The log views never + * need them - they build their own tree - but status.js reaches into the + * element the form layer returns in order to put the report button into the + * heading, so the mock has to offer the same two. findTag is a hoisted + * function declaration, so it is in scope here. */ +function mockNode(tag, attrs, children) { return { tag, attrs: attrs || {}, children: children === undefined ? [] : (Array.isArray(children) ? children : [ children ]), - appendChild() {} + appendChild(child) { this.children.push(child); return child; }, + querySelector(t) { return findTag(this, t); } }; } +function E(tag, attrs, children) { + if (Array.isArray(tag) && attrs === undefined) + return mockNode(null, {}, tag); + + return mockNode(tag, attrs, children); +} + function findClickable(node) { if (!node || typeof node !== 'object') return null; @@ -314,7 +337,13 @@ function testLogViewWiring() { path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/status.js'), { baseclass: {}, - dom: { content: () => {} }, + dom: { + content: () => {}, + append: (parent, ...children) => { + for (const child of children) + parent.appendChild(child); + } + }, form, fs: { read_direct: (p) => { reads.push(p); return Promise.resolve('log line'); } }, poll: { add: () => {} }, @@ -380,18 +409,47 @@ function testLogViewWiring() { check('status.js: all three log views rendered', rendered === 3, String(rendered)); check('status.js: the log poll was registered', pollHandlers !== null && typeof pollHandlers.read === 'function'); - if (!pollHandlers) - return Promise.resolve(); + /* --- the report button lives on the title line ----------------------- + * + * form.js builds the heading as `E('h3', {}, this.title)` behind a + * `typeof(this.title) == 'string'` guard, so the button cannot be part of + * the title. status.js overrides the section's render() and appends the + * button into the h3 that render() already built. Assert on the h3's own + * subtree: a check that walked the whole section would still pass if the + * button fell back below the description, which is the exact regression + * this replaced. */ + const report_section = map.sections.find((s) => + s.options.some((o) => o.__name === '_debug_report')); + check('status.js: the report section exists', report_section !== null, + String(map.sections.map((s) => s.__name).join(','))); - /* One tick reads one file per registered target, keyed by basename. */ - return Promise.resolve(pollHandlers.read()).then(() => { - check('status.js: the poll reads each log by basename', - JSON.stringify(reads.slice().sort()) === JSON.stringify([ - '/var/run/homeproxy-pro/homeproxy-pro.log', - '/var/run/homeproxy-pro/sing-box-c.log', - '/var/run/homeproxy-pro/sing-box-s.log' - ].sort()), - JSON.stringify(reads)); + return Promise.resolve(report_section ? report_section.render() : null).then((el) => { + const heading = el ? el.querySelector('h3') : null; + check('status.js: the report section renders a heading', heading !== null, String(el)); + + const in_heading = heading ? findClickable(heading) : null; + check('status.js: the download button sits inside the heading', + in_heading !== null, 'no clickable node under the h3'); + + /* And it must be gone from the body, or the page shows two of them. */ + const body = findOption(report_section, '_debug_report'); + const body_tree = body.render(0, 'cfg01', false); + check('status.js: the report body no longer holds the button', + findClickable(body_tree) === null, 'a button is still in the description block'); + + if (!pollHandlers) + return; + + /* One tick reads one file per registered target, keyed by basename. */ + return Promise.resolve(pollHandlers.read()).then(() => { + check('status.js: the poll reads each log by basename', + JSON.stringify(reads.slice().sort()) === JSON.stringify([ + '/var/run/homeproxy-pro/homeproxy-pro.log', + '/var/run/homeproxy-pro/sing-box-c.log', + '/var/run/homeproxy-pro/sing-box-s.log' + ].sort()), + JSON.stringify(reads)); + }); }); } diff --git a/luci-app-homeproxy-pro/tests/runtime/test_runtime_extraction.sh b/luci-app-homeproxy-pro/tests/runtime/test_runtime_extraction.sh index bcc8c3d1f..a8000a38b 100644 --- a/luci-app-homeproxy-pro/tests/runtime/test_runtime_extraction.sh +++ b/luci-app-homeproxy-pro/tests/runtime/test_runtime_extraction.sh @@ -862,6 +862,63 @@ else echo "PASS: the dnsmasq snippets are gone after the client is switched off" fi +# --- the rule-set mtime gate ------------------------------------------- +# +# Regenerating china-domain.json measured 3540 ms on the target and the +# source lists are replaced at most daily, so hp_prepare_runtime_files skips +# the rebuild when the output is already up to date. Asserted against the +# function rather than through a scenario: every scenario in this file runs +# against a sandbox that has no pre-existing rule-set, so the skip branch is +# unreachable from a scenario and the regression it guards would be silent. +# +# The three conditions that must still rebuild are exactly the three the +# unconditional version existed for - a fresh install, a truncated previous +# run, and a list replaced while the service was stopped. All four cases are +# checked, because a gate that skips too eagerly is worse than the 3.5s it +# saves: it would keep a rule-set the source has moved past. +# `.` is a POSIX special builtin: sourcing a missing file terminates a +# non-interactive shell outright, and `||` does not get a chance to run. So +# this names the real path and never guards it. +. "$ROOT/root/etc/homeproxy-pro/scripts/runtime/service.sh" + +hp_rs_dir="$(mktemp -d "${TMPDIR:-/tmp}/hp-rule-set.XXXXXX")" +hp_rs_src="$hp_rs_dir/src.txt" +hp_rs_out="$hp_rs_dir/out.json" +printf 'a.cn\n' > "$hp_rs_src" + +if hp_rule_set_current "$hp_rs_src" "$hp_rs_out"; then + echo "FAIL: a missing rule-set counted as current, so a fresh install would never get one" + exit 1 +else + echo "PASS: a missing rule-set is regenerated" +fi + +: > "$hp_rs_out" +if hp_rule_set_current "$hp_rs_src" "$hp_rs_out"; then + echo "FAIL: an empty rule-set counted as current; it would match nothing and invert the split" + exit 1 +else + echo "PASS: an empty rule-set is regenerated" +fi + +printf '{"rules":[]}\n' > "$hp_rs_out"; sleep 1; touch "$hp_rs_src" +if hp_rule_set_current "$hp_rs_src" "$hp_rs_out"; then + echo "FAIL: a rule-set older than its source counted as current" + exit 1 +else + echo "PASS: a rule-set older than its source is regenerated" +fi + +sleep 1; touch "$hp_rs_out" +if hp_rule_set_current "$hp_rs_src" "$hp_rs_out"; then + echo "PASS: an up-to-date rule-set is kept (the reload skip still works)" +else + echo "FAIL: an up-to-date rule-set would be rebuilt; the reload-time skip regressed" + exit 1 +fi + +rm -rf "$hp_rs_dir" + if cmp -s "$GOLDEN" "$TRACE.norm"; then echo "PASS: runtime orchestration matches $(basename "$GOLDEN")" # Counted from the golden rather than hardcoded: a scenario added without diff --git a/uugamebooster/Makefile b/uugamebooster/Makefile index 9d7984469..f2b485f7a 100644 --- a/uugamebooster/Makefile +++ b/uugamebooster/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=uugamebooster PKG_VERSION:=14.6.24 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION)-$(ARCH).tar.gz PKG_SOURCE_URL:=https://uurouter.gdl.netease.com/uuplugin/openwrt-$(ARCH)/v$(PKG_VERSION)/uu.tar.gz? diff --git a/uugamebooster/files/uugamebooster.init b/uugamebooster/files/uugamebooster.init index ed5179551..d01734630 100755 --- a/uugamebooster/files/uugamebooster.init +++ b/uugamebooster/files/uugamebooster.init @@ -9,6 +9,18 @@ PROG="/usr/bin/uugamebooster" UU_CONF="/usr/share/uugamebooster/uu.conf" +firewall_rule() { + json_add_object "" + json_add_string type rule + json_add_string src '*' + [ "$1" = forward ] && json_add_string dest '*' + json_add_string device tun163 + json_add_string direction "$2" + json_add_string proto all + json_add_string target ACCEPT + json_close_object +} + start_service() { config_load "$CONF" local enabled @@ -24,9 +36,29 @@ start_service() { procd_set_param stdout 1 procd_set_param stderr 1 + # UU's own nftables ACCEPT rules cannot bypass fw4's later base chains. + # Publish tunnel rules through procd so fw4 owns their full lifecycle. + if [ -x /sbin/fw4 ]; then + procd_open_data + json_add_array firewall + firewall_rule input in + firewall_rule forward in + firewall_rule forward out + json_close_array + procd_close_data + fi + procd_close_instance } +service_started() { + [ ! -x /sbin/fw4 ] || procd_set_config_changed firewall +} + +service_stopped() { + [ ! -x /sbin/fw4 ] || procd_set_config_changed firewall +} + reload_service() { stop start