🐶 Sync 2026-10-03 11:32:07

This commit is contained in:
github-actions[bot] committed 2026-10-03 11:32:07 +08:00
1 parent a5bb91e6ed
commit 66e3d835c4
72 files changed
+5107 -1799

No files matched your search

@@ -34,6 +34,30 @@ const HTTP_PING_KEEPALIVE_HINT = _('The timeout (in seconds) that after performi
TLS listener failed with nothing pointing at the path. */
const HP_CERT_PATH_ROOTS = [ '/etc/homeproxy-pro/certs/', '/etc/acme/', '/etc/ssl/' ];
/* Rule-set source files: a `type: local` rule-set's `path` and a `type: remote`
one's `initial_path`. This list mirrors RULE_PATH_ROOTS in
/etc/homeproxy-pro/scripts/homeproxy-pro.uc, and tests/arch-guard.sh guard 50 fails
when the two drift apart - the same lock guard 29 puts on the certificate
list, for the same reason: the UI's check is only UX, UCI can be set from
anywhere on the LAN, and a path the UI accepted but the backend dropped made
the field vanish from the generated configuration with nothing pointing at
the path.
It is deliberately a single root, and deliberately narrower than the
general HP_DIR-shaped gate the backend also has: a rule-set belongs in the
archive, which the package creates at install time
(/etc/uci-defaults/luci-homeproxy-pro) and before every generation
(runtime/service.sh's hp_prepare_ruleset_dir). */
const HP_RULE_PATH_ROOTS = [ '/etc/homeproxy-pro/ruleset/' ];
/* The archive path offered as the datalist entry on the rule-set path fields,
so the default the backend expects is one click away instead of something
the user has to know. Kept as its own constant so the placeholder, the
datalist and the validator's message all quote the same directory - a
placeholder that named a path the validator then refused would be its own
small version of the bug this list exists to prevent. */
const HP_RULE_PATH_DEFAULT = '/etc/homeproxy-pro/ruleset/example.srs';
/* Methods whose failure has already been reported, so that a polled call
cannot repeat the same notification every few seconds. */
const rpc_warned = new Set();
@@ -58,6 +82,80 @@ for (let i = 0; i < 64; i++)
MD5_K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296);
return baseclass.extend({
/* The archive path the rule-set form offers as its datalist entry and
placeholder. Exported rather than left module-local so the view quotes
this one string instead of repeating the path: a placeholder naming a
directory the validator then refuses is its own small version of the
drift guard 50 exists to catch, and the placeholder is exactly what a
user copies. */
rule_path_default: HP_RULE_PATH_DEFAULT,
/* Parse sing-box's rule-set download failures out of a log.
*
* With `ruleset_safe_start` on, a remote rule-set that cannot be
* downloaded is no longer a failed start: sing-box reads the `initial_path`
* fallback, comes up, and retries in the background, logging
*
* 2026-10-03 01:15:42 ERROR router: fetch rule-set geoip-cn: Get "https://…": dial tcp …
*
* That is a good trade, and it is also invisible: the instance is healthy,
* the health gate passes, the LAN is proxied - and the rule-set that is
* supposed to keep mainland traffic direct matches NOTHING, so all of it
* goes to `final`. The status page is the only place a user looks without
* ssh, so it has to show this.
*
* Two deliberate choices about the format:
*
* - the tag and the reason are matched on the stable part
* (`fetch rule-set <tag>:`) and nothing assumes sing-box's timestamp
* or level layout. A rule-set whose download failed long ago and whose
* log line moves out of the file must not read as "no failures", so a
* missing timestamp is omitted rather than faked.
* - the last occurrence of a tag wins. The file is truncated at 50 KB by
* clean_log.sh, so a tag that failed every 24 hours is in there
* repeatedly, and the most recent reason is the one that is true now.
*
* The timestamp shape is the one thing that IS pinned, and it was pinned
* from a real 1.14.2 line rather than guessed - the device writes
*
* +0800 2026-10-03 09:29:34 ERROR router: fetch rule-set geoip-cn: Get ...
*
* i.e. a UTC offset BEFORE the date. An earlier version of this anchored
* the date at the start of the line and therefore returned no time for
* every real line, which is the one shape the panel is supposed to show.
* The offset is optional in the pattern so a build that omits it still
* parses.
*
* Returns [{ tag, reason, at }], newest last. Never throws: this reads a
* file that may be empty, half-written, or rotated mid-read. */
parseRuleSetFetchFailures(text) {
const out = [];
if (!text)
return out;
const seen = {};
for (const line of String(text).split('\n')) {
const m = /fetch rule-set\s+([^\s:]+):\s*(.*)$/.exec(line);
if (!m)
continue;
const tag = m[1];
const at = /^(?:[+-]\d{4}\s+)?(\d{4}-\d{2}-\d{2}[ T][0-9:.]+)/.exec(line);
if (seen[tag] !== undefined) {
out[seen[tag]].reason = m[2].trim();
out[seen[tag]].at = at ? at[1] : null;
continue;
}
seen[tag] = out.length;
out.push({ tag: tag, reason: m[2].trim(), at: at ? at[1] : null });
}
return out;
},
dns_strategy: {
'': _('Default'),
'prefer_ipv4': _('Prefer IPv4'),
@@ -1108,6 +1206,23 @@ return baseclass.extend({
return true;
},
validateRuleSetPath(section_id, value) {
if (section_id && value)
/* HP_RULE_PATH_ROOTS is the same list the backend enforces
(RULE_PATH_ROOTS, guard 50). The `..` rejection is explicit for
the same reason as in validateCertificatePath: a prefix match
alone accepts /etc/homeproxy-pro/ruleset/../../etc/shadow, and
sing-box opens these files as root. The backend is the authority
- this only saves a save that the generator would refuse a
moment later with a message the user reads in a log file rather
than on the page they are editing. */
if (value.match(/(^|\/)\.\.(\/|$)/) ||
!HP_RULE_PATH_ROOTS.some((root) => value.indexOf(root) === 0 && value.length > root.length))
return _('Expecting: %s').format(_('/etc/homeproxy-pro/ruleset/...'));
return true;
},
validatePortRange(section_id, value) {
if (section_id && value) {
value = value.match(/^(\d+)?\:(\d+)?$/);
@@ -52,15 +52,38 @@ function render(ctx) {
so.default = 'remote';
so.rmempty = false;
so = ss.option(form.ListValue, 'format', _('Format'));
so = ss.option(form.ListValue, 'format', _('Format'),
_('Rule-set file format. Leave it empty and HomeProxy reads it from the file itself; a value that does not match the file is corrected and reported in the log.'));
so.value('binary', _('Binary file'));
so.value('source', _('Source file'));
so.default = 'binary';
so.rmempty = false;
/* rmempty, and no default.
*
* A default of 'binary' plus rmempty=false meant EVERY local rule-set
* carried an explicit format - and an explicit value beats sing-box's
* extension inference, so "add a local rule-set, pick my example.json,
* forget to change Format" produced a configuration that parsed JSON as
* a compiled .srs and was rejected at apply time.
*
* Leaving the field empty is now the recommended state rather than an
* accident: the generator reads the first bytes of the file and declares
* the format itself (generator/ruleset.uc's resolveFormat), correcting a
* wrong value and saying so in the log. Writing a default here would
* put a value back on every rule-set for that code to undo. */
so.rmempty = true;
so = ss.option(form.Value, 'path', _('Path'));
so = ss.option(form.Value, 'path', _('Path'),
_('Rule-set file in %s. Copy it there first: the generator refuses a rule-set whose file is missing.').format('/etc/homeproxy-pro/ruleset/'));
so.datatype = 'file';
so.placeholder = '/etc/homeproxy-pro/ruleset/example.json';
/* The archive is the only directory the backend admits (RULE_PATH_ROOTS),
* and it is created for the user - at install time and again before every
* generation. Offering it as the datalist entry is what turns "point at a
* file under a directory that may not exist" into a one-click default.
* The same field used to carry a placeholder naming example.json while
* the UI defaulted the format to binary, so the file it suggested was one
* the generated configuration would then refuse to parse. */
so.value(hp.rule_path_default);
so.placeholder = hp.rule_path_default;
so.validate = hp.validateRuleSetPath;
so.rmempty = false;
so.depends('type', 'local');
so.modalonly = true;
@@ -108,8 +131,11 @@ function render(ctx) {
so.depends('type', 'remote');
so = ss.option(form.Value, 'initial_path', _('Initial path'),
_('Local file with initial rule-set content; avoids blocking startup on first download (1.14).'));
_('Local file with initial rule-set content; avoids blocking startup on first download (1.14). Must be under %s.').format('/etc/homeproxy-pro/ruleset/'));
so.datatype = 'file';
so.value(hp.rule_path_default);
so.placeholder = hp.rule_path_default;
so.validate = hp.validateRuleSetPath;
so.depends('type', 'remote');
so.modalonly = true;
@@ -130,8 +156,37 @@ function render(ctx) {
so.modalonly = true;
so = ss.option(form.Value, 'update_interval', _('Update interval'),
_('Update interval of rule set.'));
so.placeholder = '1d';
_('Update interval of rule set, e.g. 24h or 3600 (seconds).'));
/* 24h, not 1d.
*
* sing-box parses update_interval as a Go duration, and Go's units are
* ns/us/ms/s/m/h - there is no day. The placeholder used to read "1d",
* which is the one value a user is most likely to copy verbatim into a
* field that then rejects the whole configuration. 24h is the same
* interval, is a unit Go reads, and is exactly what this generator emits
* for its own built-in rule-sets (generator/route.uc).
*
* A bare number is accepted because the generator normalises it: the
* value is passed through strToTime(), which appends "s" to anything
* that does not already end in a unit - that is the fix for the measured
* `time: missing unit in duration "3600"` failure. */
so.placeholder = '24h';
so.validate = function(section_id, value) {
/* Empty means "no update_interval", which sing-box reads as its own
* default - the field is optional, so it must not be made required. */
if (!section_id || !value)
return true;
/* What Go's time.ParseDuration accepts: a sequence of
* number+unit groups (so 1h30m is fine), or a bare number, which
* strToTime() will turn into seconds. Everything else - "1d", "1w",
* "daily", "24 h" - is refused here, on the page, instead of becoming
* a rejected configuration the user only learns about from a log. */
if (/^\d+$/.test(value) || /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(value))
return true;
return _('Expecting: %s').format(_('update interval like 24h, 1h30m or 3600'));
};
so.depends('type', 'remote');
/* Rule set settings end */
}
@@ -75,6 +75,77 @@ const ensureLogPoll = hp.statusPoller({
}
});
/* --- rule-set download failures ------------------------------------------
*
* With ruleset_safe_start on, a remote rule-set that cannot be downloaded no
* longer stops the service: sing-box reads the empty initial_path fallback,
* binds, and retries in the background. The instance is healthy and the LAN is
* proxied - and the rule-set that keeps mainland traffic direct matches
* nothing, so all of it goes to `final`.
*
* That is the trade, and it is the reason this block exists: the degradation
* has no other symptom. The health gate does not look at rule-sets at all
* (there is nothing in health.sh that could), so the service reports itself
* healthy while a rule-set is silently empty.
*
* sing-box-c.log is read rather than a new RPC: the ACL already grants the
* browser read on that file for the log viewer below, so this costs no
* permission and no new method to keep in step with the method table.
*
* Whether a fallback is actually in play is read from UCI rather than guessed:
* with the opt-in OFF, a failing rule-set means the instance would not have
* started at all, which is a different and much more visible problem - so the
* wording has to differ, not just the colour. */
const ruleSetNode = E('div', { 'id': 'ruleset_status' },
E('img', {
'src': L.resource('icons/loading.svg'),
'alt': _('Loading'),
'style': 'vertical-align:middle'
}, _('Collecting data...'))
);
function renderRuleSetStatus() {
/* Same poller as the log views, so there is still one handler per view
* no matter how often render() runs. */
ensureLogPoll();
fs.read_direct(`${hp_dir}/sing-box-c.log`, 'text')
.then((text) => {
const failures = hp.parseRuleSetFetchFailures(text);
const safeStart = (uci.get('homeproxy-pro', 'config', 'ruleset_safe_start') || '0') === '1';
if (!failures.length) {
dom.content(ruleSetNode, E('span', { 'style': 'color:green' },
[_('No rule-set download failures in the current log.')]));
return;
}
dom.content(ruleSetNode, E('div', [
E('p', { 'style': 'color:red' }, [
_('%d rule-set(s) cannot be downloaded.').format(failures.length)
]),
E('p', { 'style': 'color:gray' }, [
safeStart
? _('Startup fallback in use: these rule-sets are EMPTY, so the traffic they would have split falls through to the default route. The service is up, but the split is not in effect until the download succeeds.')
: _('Startup fallback is OFF, so a rule-set that cannot be downloaded prevents the service from starting at all. The service is running, so the log below is from an earlier attempt or the download has since recovered.')
]),
E('ul', {}, failures.map((f) => E('li', [
E('code', [ f.tag ]),
' — ',
/* Text nodes, never markup: the reason is a string
* sing-box wrote, and it contains a URL. */
f.reason || _('(no reason recorded)'),
f.at ? E('small', { 'style': 'color:gray' }, [ ' — ' + f.at ]) : ''
])))
]));
})
.catch((err) => {
dom.content(ruleSetNode, E('span', { 'style': 'color:gray' }, [
_('Cannot read the sing-box client log (%s).').format(String(err))
]));
});
}
function getConnStat(o, site) {
o.default = E('div', { 'style': 'cbi-value-field' }, [
E('button', {
@@ -301,6 +372,20 @@ return view.extend({
return node;
}
s = m.section(form.NamedSection, 'config', 'homeproxy-pro', _('Rule sets'));
s.anonymous = true;
/* A dedicated section rather than a fourth log view: the question is
* "is any rule-set currently broken", and the answer has to be a
* verdict plus a per-tag reason, not a wall of log the user has to read
* a timestamp out of. */
o = s.option(form.DummyValue, '_ruleset_status');
o.rawhtml = true;
o.render = function() {
renderRuleSetStatus();
return ruleSetNode;
};
s = m.section(form.NamedSection, 'config', 'homeproxy-pro');
s.anonymous = true;