Files

1276 lines
49 KiB
Ucode

/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*/
import { access, lstat, mkdtemp, open, rmdir, unlink } from 'fs';
import { cursor } from 'uci';
import { urldecode_params } from 'luci.http';
/* Global variables start */
export const HP_DIR = '/etc/homeproxy-pro';
export const RUN_DIR = '/var/run/homeproxy-pro';
/* Where the UCI configuration lives. This is /etc/config - NOT HP_DIR/config.
* The package ships its config as the conffile /etc/config/homeproxy-pro, and
* ucode's `cursor(dir)` treats `dir` as the *confdir*, so
* cursor(HP_DIR + '/config') would read /etc/homeproxy-pro/config/homeproxy-pro, a
* path that exists nowhere. uci.load() then returns null, every uci.get()
* returns null, and the Loader silently yields pure defaults: no main-out,
* no route/direct final, none of the user's ports or nodes.
* Verified on a device: cursor('/etc/homeproxy-pro/config').get(...) is empty
* while cursor().get(...) returns the configured value.
* The test suite stages a rewritten copy of this constant instead of the
* old `__LOADER_DIR__` source sed. */
export const UCICONFIG_DIR = '/etc/config';
/* Largest subscription body we will fetch, in bytes. See wGETVerbose(). */
export const HP_FETCH_CAP = 5 * 1024 * 1024;
/* Global variables end */
/* Utilities start */
/* Kanged from luci-app-commands */
export function shellQuote(s) {
return `'${replace(s, "'", "'\\''")}'`;
};
export function isBinary(str) {
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
if (byte <= 8 || (byte >= 14 && byte <= 31))
return true;
return false;
};
/* Whitelist absolute paths the generators are allowed to put into
* sing-box config. sing-box runs as root and reads the file itself; an
* arbitrary UCI value of e.g. /etc/passwd would leak the file to anyone
* who could write to the UCI tree. The LuCI UI already gates the input
* via validateCertificatePath() in homeproxy-pro.js, but UCI can be set from
* any node on the LAN (subscription updates, scripted edits) and the
* UI check is only UX - we have to enforce on the backend.
*
* /etc/homeproxy-pro/... - project-managed certs / ruleset paths / etc.
* /tmp/homeproxy_* - upload staging files (cert_upload_* writes)
*
* Anything else (relative paths, /etc/passwd, /var/run/...) returns false
* and the caller is expected to die() / warn() / strip the value. */
export function validateHomeProxyPath(p) {
if (!p || type(p) !== 'string')
return false;
/* Reject traversal *before* the prefix checks below: a plain prefix
* comparison accepts '/etc/homeproxy-pro/../../etc/shadow', and sing-box reads
* these paths as root. */
if (match(p, /(^|\/)\.\.(\/|$)/))
return false;
/* Reject anything that does not start with '/' - relative paths in
* sing-box resolve against the process CWD, which is /tmp at boot
* but is not a position we want any UCI value to land in. */
if (substr(p, 0, 1) !== '/')
return false;
if (substr(p, 0, length('/etc/homeproxy-pro/')) === '/etc/homeproxy-pro/')
return true;
if (substr(p, 0, length('/tmp/homeproxy_')) === '/tmp/homeproxy_')
return true;
return false;
};
/* Certificates the sing-box instances are allowed to read.
*
* TLS certificate_path / key_path are not rule-set paths: a server's
* certificate normally lives under /etc/ssl/ or under the ACME state in
* /etc/acme/, and the two sing-box jails mount exactly those directories
* (see runtime/service.sh's procd_add_jail_mount calls). validateHomeProxyPath()
* only knows /etc/homeproxy-pro/ and /tmp/homeproxy_, so a certificate the user
* legitimately picked from /etc/ssl/ was accepted by the LuCI validator and
* then silently dropped here - certificate_path became null and the TLS
* listener failed with no error pointing at the path.
*
* The list is mirrored by HP_CERT_PATH_ROOTS in
* htdocs/luci-static/resources/homeproxy-pro.js; guard 29 in tests/arch-guard.sh
* keeps the two in step.
*/
export const CERT_PATH_ROOTS = ['/etc/homeproxy-pro/certs/', '/etc/acme/', '/etc/ssl/'];
/* validateCertificatePath(p) - the TLS certificate/key path gate.
*
* Same shape as validateHomeProxyPath(): reject traversal and relative paths,
* then require one of CERT_PATH_ROOTS. Kept separate rather than folded into
* validateHomeProxyPath() so a rule-set path cannot be pointed at /etc/ssl/
* and a certificate cannot be pointed at /tmp/homeproxy_*. */
export function validateCertificatePath(p) {
if (!p || type(p) !== 'string')
return false;
if (match(p, /(^|\/)\.\.(\/|$)/))
return false;
if (substr(p, 0, 1) !== '/')
return false;
for (let root in CERT_PATH_ROOTS)
if (length(p) > length(root) && substr(p, 0, length(root)) === root)
return true;
return false;
};
/* Rule-set source files: the `path` of a `type: local` rule_set and the
* `initial_path` of a `type: remote` one. sing-box opens both as root, so
* the threat model is the certificate one - an arbitrary UCI value would leak
* the file to anyone who can write the UCI tree from anywhere on the LAN.
*
* Unlike a certificate, a rule-set has exactly one home: the archive this
* package creates at install time (uci-defaults/luci-homeproxy-pro) and before
* every generation (runtime/service.sh's hp_prepare_ruleset_dir). So the
* policy is a single root rather than the three a certificate needs, and it
* is deliberately NARROWER than validateHomeProxyPath() - that gate also
* accepts /tmp/homeproxy_*, which exists for upload staging, and every path
* under /etc/homeproxy-pro/ including the resource lists the package itself
* rewrites. A rule-set belongs in the archive (issue #7, review 4.4/4.7).
*
* Kept as a third policy rather than folded into validateHomeProxyPath() for
* the reason validateCertificatePath() is: the policies have to be
* independently narrowable, and folding them back together is exactly how the
* certificate one regressed once already - a path the UI offered was accepted
* there and silently dropped here, so the TLS listener failed with nothing
* pointing at the path.
*
* The list is mirrored by HP_RULE_PATH_ROOTS in
* htdocs/luci-static/resources/homeproxy-pro.js; guard 50 in tests/arch-guard.sh
* keeps the two in step across the JS/ucode boundary, which nothing else can
* see. */
export const RULE_PATH_ROOTS = ['/etc/homeproxy-pro/ruleset/'];
/* validateRuleSetPath(p) - the rule-set path gate.
*
* Same shape as validateCertificatePath(): reject traversal and relative
* paths, then require one of RULE_PATH_ROOTS. A bare root ("/etc/homeproxy-pro/
* ruleset/" with nothing after it) is rejected - it names the directory, not
* a file. */
export function validateRuleSetPath(p) {
if (!p || type(p) !== 'string')
return false;
/* Reject traversal *before* the prefix checks: a plain prefix comparison
* accepts '/etc/homeproxy-pro/ruleset/../../etc/shadow', and sing-box reads
* these paths as root. */
if (match(p, /(^|\/)\.\.(\/|$)/))
return false;
/* Reject anything that does not start with '/' - a relative path in
* sing-box resolves against the process CWD. */
if (substr(p, 0, 1) !== '/')
return false;
for (let root in RULE_PATH_ROOTS)
if (length(p) > length(root) && substr(p, 0, length(root)) === root)
return true;
return false;
};
/* RULE_PATH_ROOTS rendered for a diagnostic.
*
* join(', ', RULE_PATH_ROOTS) would do this in one line - it is the form
* firewall_utils.uc uses for its own lists, and ucode's join() does flatten an
* array argument rather than stringifying it. The loop is kept anyway: it
* cannot be wrong about the separator, and this string ends up inside a die()
* a user reads in a log file. */
export function rulePathRootsText() {
let text = '';
for (let root in RULE_PATH_ROOTS)
text = text ? (text + ', ' + root) : root;
return text;
};
/* --- rule-set format probing ---------------------------------------------
*
* A rule-set comes in exactly two formats, and sing-box needs to be told
* which one it is looking at:
*
* source a JSON document, {"version":3,"rules":[...]}
* binary a compiled .srs, which starts with the three ASCII bytes "SRS"
*
* The field is optional, because sing-box infers it from the file extension
* (.json -> source, .srs -> binary). That inference is a guess about the
* NAME, and it is wrong in three ways that all end the same way - `sing-box
* check` rejects the whole configuration, so a reload is aborted and the user
* is left with "my change did not take":
*
* content disagrees with the name example.srs holding JSON -> the file
* is parsed as SRS and fails
* the field says the wrong thing format=binary on a .json -> same
* there is no name to infer from a file with no extension -> "missing
* format", a different error for the
* same underlying mistake
*
* So the correction is to look at the bytes. The functions below are split
* in two on purpose: ruleSetFormatFromBytes() is a pure function of what it
* is handed, so the decision is unit-testable and auditable, and
* probeRuleSetFile() is the only part that touches the disk. It is called
* from the CLI's resolve_env(), never from generator/ (guard 27), and its
* verdict reaches the generator as an ordinary context field.
*
* Nothing here ever *guesses*: an unreadable, empty or unclassifiable file
* yields no verdict at all, and the declared format is left exactly as the
* user wrote it. sing-box stays the authority, and auto-correction can
* never become a second source of wrongness. */
/* How many leading bytes ruleSetFormatFromBytes() needs. Three for the magic,
* plus room to skip leading whitespace before the first JSON brace - a file
* written by a text editor or a Windows tool routinely starts with "\r\n".
* 512 is far more than the decision can use and far less than any real
* rule-set, so reading it is one short read, not a load. */
export const RULESET_PROBE_BYTES = 512;
/* ruleSetFormatFromBytes(head, size) -> 'binary' | 'source' | null.
*
* `head` is the leading bytes as a string (ucode fs.read hands them over as
* one), `size` the whole file's size. Returns null when the bytes do not
* identify a format - an empty file, or content that is neither SRS nor JSON
* (a truncated download, an HTML error page served with a .srs name). */
export function ruleSetFormatFromBytes(head, size) {
/* Empty is not "source" and not "binary": sing-box rejects a 0-byte
* local rule-set with "invalid sing-box rule-set file" whatever format
* is declared, so no verdict is the honest answer here and declaring one
* would not help. */
if (!size || !head)
return null;
/* The SRS magic, compared byte by byte.
*
* isBinary() must NOT be reused here, and the reason is worth being exact
* about, because the obvious one is wrong: isBinary() does not say "not
* SRS", so it cannot be used as a negative test. It answers a different
* question - "does this look like text?" - and for rule-sets that answer
* is only *correlated* with the format, never equal to it:
*
* a full .srs happens to read as binary, because the version byte and
* the deflate stream carry control bytes. That is a property of this
* week's encoder, not of the format, and it only holds because the
* probe happens to read enough of the file;
* a negative is ambiguous - binary-but-not-SRS, or text that is not
* JSON - and those need different answers.
*
* The magic is the format's own identity, costs three bytes, and gives
* null for anything it does not recognise. That last part is the point:
* this probe declines to have an opinion rather than guessing. */
if (length(head) >= 3 &&
ord(head, 0) == 0x53 && ord(head, 1) == 0x52 && ord(head, 2) == 0x53)
return 'binary';
/* Source is JSON, so the first byte that is not whitespace decides.
* Both { (an object) and [ (an array) are accepted: sing-box's own
* source form is an object, but an array is still "this is JSON" and
* refusing to say so would only hand the decision back to a guess. */
const lead = trim(head);
if (length(lead) && (substr(lead, 0, 1) == '{' || substr(lead, 0, 1) == '['))
return 'source';
return null;
};
/* ruleSetFormatFromPath(path) -> 'binary' | 'source' | null.
*
* What sing-box's extension inference would decide for this name, expressed
* as the same two values. null means "no extension sing-box can infer from",
* which is the case that produces "missing format" rather than a wrong parse -
* and the reason a content probe is worth doing at all.
*
* Deliberately NOT a general extension parser: only the two suffixes sing-box
* acts on are named, so this cannot drift into claiming it knows sing-box's
* rules. A {tag} placeholder does not affect the answer, since the suffix
* comes after it. */
export function ruleSetFormatFromPath(path) {
if (!path || type(path) !== 'string')
return null;
if (match(path, /\.json$/))
return 'source';
if (match(path, /\.srs$/))
return 'binary';
return null;
};
/* probeRuleSetFile(path) -> 'binary' | 'source' | null.
*
* The disk half. Returns null - meaning "no opinion" - for a path outside the
* rule-set policy, a missing/empty/non-regular file, an unreadable one, and
* for content it cannot classify. The policy check is not redundant with
* ruleset.uc's: this runs BEFORE the generator, on paths that have not been
* vetted yet, and it is a reader.
*
* lstat() rather than the two-argument access() - see the note in
* generate_client.uc's china_ip6_ready about this ucode build answering only
* in its one-argument form. */
export function probeRuleSetFile(path) {
if (!validateRuleSetPath(path))
return null;
const st = lstat(path);
if (!st || st.type !== 'file' || st.size <= 0)
return null;
const f = open(path);
if (!f)
return null;
/* The `?? ''` is the same defensive read read_capped() uses: a short or
* failed read must not become a null the caller has to reason about. */
const head = f.read(RULESET_PROBE_BYTES) ?? '';
f.close();
return ruleSetFormatFromBytes(head, st.size);
};
/* Read at most `limit` bytes from a file, or '' when it does not exist.
* The cap is deliberate: a command's output is not trustworthy input. */
function read_capped(path, limit) {
const f = open(path);
if (!f)
return '';
const data = f.read(limit) ?? '';
f.close();
return data;
};
/* Remove the scratch files and the directory created for one run. Best
* effort: a command the shell could not even parse leaves no files behind,
* and a failed cleanup must never mask the command's own result. */
function cleanup_exec_dir(dir, outpath, errpath) {
try {
if (access(outpath))
unlink(outpath);
if (access(errpath))
unlink(errpath);
rmdir(dir);
} catch (e) {
/* nothing useful to do - the results are already captured */
}
};
export function executeCommand(...args) {
const command = join(' ', args);
const dir = mkdtemp();
const outpath = dir + '/stdout';
const errpath = dir + '/stderr';
let exitcode = null, stdout = '', stderr = '';
try {
/* Redirect to real paths, not to the descriptors of two mkstemp()
* files. The old form appended `>&N 2>&N`, which only works when
* the child shell can see those descriptors; /bin/sh reports
* "Bad file descriptor" (bash) / "Bad fd number" (dash) when it
* cannot, so the command was never executed and every caller got
* an empty result with exit status 2. That is what happened in
* CI, where /bin/sh is dash. Redirecting to paths is plain POSIX
* and behaves identically under busybox ash, dash and bash. */
exitcode = system(sprintf('%s >%s 2>%s', command, outpath, errpath));
/* The reader has to be able to return one byte more than
* HP_FETCH_CAP, or wGETVerbose()'s "response exceeds the limit"
* branch below is unreachable: the reader used to stop at 512 KiB,
* so every subscription between 512 KiB and 5 MiB was handed to the
* parser as a truncated body with error: null - the size check
* could never fire and the caller had no way to tell. stderr keeps
* the smaller cap: it is a diagnostic message, not a payload. The
* stderr_truncated flag on the result tells the caller the cap
* actually fired, so a "reload exited with status N" line is not
* the only signal that something went wrong upstream. */
stdout = read_capped(outpath, HP_FETCH_CAP + 1);
stderr = read_capped(errpath, 1024 * 512);
} catch (e) {
/* Never leave the scratch directory behind on a failing run.
* ucode has no `finally` clause, so the exception is re-raised
* by hand. */
cleanup_exec_dir(dir, outpath, errpath);
die(e);
}
const binary = isBinary(stdout);
/* Did stderr actually overflow the 512 KiB cap? The temp file is
* unlinked right after this, so size is read while it still exists.
* lstat() returns null on missing files, so the .size fallback is
* defensive against an exception-driven early cleanup. */
const stderr_size = (lstat(errpath) || {}).size || 0;
const stderr_truncated = stderr_size > 1024 * 512;
cleanup_exec_dir(dir, outpath, errpath);
return {
command,
stdout: binary ? null : stdout,
stderr,
stderr_truncated,
exitcode,
binary
};
};
export function getTime(epoch) {
const local_time = localtime(epoch);
return replace(replace(sprintf(
'%d-%2d-%2d@%2d:%2d:%2d',
local_time.year,
local_time.mon,
local_time.mday,
local_time.hour,
local_time.min,
local_time.sec
), ' ', '0'), '@', ' ');
};
/* Redact the credential-bearing parts of a URL before logging it. Any
* subscription URL we ship into /var/run/homeproxy-pro/homeproxy-pro.log is
* readable by anyone who can read /var/run/homeproxy-pro - including UCI
* defaults that ship on the device and anyone with shell on the LAN.
* (Confirmed on the device: the log is 0644.)
*
* Only the scheme, host and port are kept - enough to answer "which
* provider/endpoint failed?". The userinfo, the path and the query string
* all carry subscription tokens in practice: many providers hand out
* `https://host:port/<user>/<token>` with no query string at all, which is
* exactly what the device this was found on was configured with, and the
* path used to survive the redaction. The original URL is still passed to
* wGETVerbose.
*
* https://user:token@host.example.com/path/sub?q=abc&token=secret
* -> https://***@host.example.com/***?*** */
export function redactUrl(url) {
if (!url || type(url) !== 'string')
return '';
let u = url;
/* userinfo: scheme://user:pass@host -> scheme://***@host */
const at = index(u, '@');
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (scheme && at !== -1 && at > length(scheme[0]))
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
/* path and query: keep the authority, mark the rest. Splitting them
* rather than masking from the first '/' keeps the structural markers
* (`/***?***`) so a reader can still tell a path from a query, and it is
* why the authority end is the *earlier* of the two separators. */
const scheme_end = scheme ? length(scheme[0]) : 0;
const rest = substr(u, scheme_end);
const path_at = index(rest, '/');
const query_at = index(rest, '?');
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
let authority_end = length(rest);
if (has_path)
authority_end = path_at;
else if (query_at !== -1)
authority_end = query_at;
let tail = '';
if (has_path)
tail += '/***';
if (query_at !== -1)
tail += '?***';
u = substr(u, 0, scheme_end + authority_end) + tail;
return u;
};
/* Scan a free-form error string and redact every URL in it. GNU wget's
* stderr writes the requested URL back into the message:
*
* https://host/path?q=token=secret: Bad port '80080'.
*
* The trailing `:` separator is greedy-matched here, which is harmless -
* redactUrl leaves it as-is. Doing this here, in wGETVerbose, means the
* returned `error` is safe no matter where the caller ships it - the
* fetcher logs it, the orchestrator returns it, anything that prints it
* afterwards has already lost the token. Centralising the redaction at
* the source is the point: every call site used to have to
* remember to redact, and the one that forgot was the original bug.
*
* A non-string input is returned unchanged so this is safe to apply to
* the trimmed wget stderr even when it is empty.
*
* Both definitions sit above wGETVerbose on purpose: ucode does not hoist
* `export function`, so a call compiled before the declaration is bound
* fails at runtime with "access to undeclared variable". */
export function redactReason(reason) {
if (!reason || type(reason) !== 'string')
return reason;
return replace(reason, /https?:\/\/\S+/g, (url) => redactUrl(url));
};
/*
* Fetch a URL and report both the body and, on failure, the reason. The
* reason is wget's own stderr (whitespace collapsed, length-capped) so the
* caller can tell a DNS failure from a timeout or a TLS handshake error.
*/
/* fetchBinary() -> the path to invoke the fetch layer with.
*
* uclient-fetch is base OpenWrt, and it lives in /bin on every target measured
* so far - but "base package" is a packaging fact, not a path guarantee, and
* the whole point of the switch is not to hard-code an assumption that some
* buildroot can violate. So: the two known locations first, then PATH as the
* fallback, which is also what makes the test suite able to shadow it with a
* stub.
*
* Declared before wGETVerbose() on purpose - see the note above
* redactReason() about ucode not hoisting exported functions. */
export function fetchBinary() {
for (let p in [ '/bin/uclient-fetch', '/usr/bin/uclient-fetch' ]) {
/* One-argument access() is the only form this ucode build answers;
* see the china_ip6_ready note in generate_client.uc. */
if (access(p))
return p;
}
return 'uclient-fetch';
};
export function wGETVerbose(url, ua) {
if (!url || type(url) !== 'string')
return { content: null, error: 'invalid URL' };
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
/* Why uclient-fetch and not wget: `wget` is whatever the firmware's
* buildroot happened to compile, and the two implementations do not share
* a single option beyond -O. A command line that works on one of them
* exits 2 on the other with "unrecognized option" *before making a single
* request*, which is how every subscription fetch, every resource-list
* update and the connectivity check failed on a busybox-wget router while
* the suite stayed green - the guard below only ever ran against GNU
* wget, on CI and on the maintainer's own device.
*
* uclient-fetch is the fetcher OpenWrt itself ships and drives (opkg,
* sysupgrade, uci), with an option set fixed by the applet rather than by
* the buildroot, so one command line is correct on every target. Its
* interface was read off the applet on the device, not from memory:
*
* -O <file> stdout is "-"
* --user-agent <str> -U
* --timeout=N | -T N seconds, same unit as the --timeout= it replaces
* --spider | -s existence check only
* --header='K: V' note the '=': the space-separated form is not
* accepted, and the value is ONE argv element
*
* No --quiet, deliberately: the point of capturing stderr is to report
* *why* a fetch failed, and uclient-fetch's default stderr is better than
* wget's -nv was - on an HTTP error it prints
*
* Downloading 'https://…/x.srs?token=…'
* Connecting to 185.199.111.133:443
* HTTP error 404
*
* including the resolved address. The URL is in there, so this depends on
* redactReason() below; guard 14 is what keeps that honest. Quiet mode
* would have removed the URL from the message and, with it, the only
* thing distinguishing an HTTP 404 from a connect failure.
*
* The size cap is still enforced by piping through `head -c`, not by a
* fetcher option: there is no such option in either implementation, and
* `head` closing the pipe stops the download early. The cap is therefore
* CAP+1 bytes rather than exactly CAP, and one byte past the limit means
* "too large".
*
* 5 MiB covers a 10 000-node subscription with ~3 KB per node plus the
* base64 inflation. Anything larger is almost certainly an attack or a
* misconfiguration.
*
* The pipeline costs the exit status - `system()` returns head's, which is
* always 0 - so the fetcher's own status is carried out of it through a
* file (see the command below). Without that, a *truncated* transfer was
* indistinguishable from a complete one: uclient-fetch exits 4 with
* "Connection reset prematurely", and measured on the device a response
* that declared 244 bytes and delivered 80 was handed to the parser as a
* valid subscription body. */
/* The braces matter: executeCommand() appends `>out 2>err` to the command,
* and in `a | b >out 2>err` those redirections bind to b only - the
* fetcher's stderr would go to the caller's terminal and the failure
* message would be lost. Grouping the pipeline makes both stream to the
* capture files.
*
* fetchBinary() is shellQuote()d like everything else here rather than
* being waved through on the grounds that it only ever returns a literal:
* guard 25 exists to make "is this shell-safe" a mechanical check instead
* of a judgement call, and a quoted constant costs nothing. */
const dir = mkdtemp();
const rcpath = dir + '/rc';
const output = executeCommand(`{ { ${shellQuote(fetchBinary())} -O - --user-agent=${shellQuote(ua)} --timeout=10 ${shellQuote(url)}; echo $? >${shellQuote(rcpath)}; } | head -c ${HP_FETCH_CAP + 1}; }`) || {};
/* The fetcher's status, recovered from the file the pipeline wrote it to.
* `head -c` closing the pipe makes the fetcher exit on SIGPIPE (141), which
* is why the size check below runs first: a too-large body is reported as
* too large, not as a signal. */
const fetch_rc = int(trim(read_capped(rcpath, 32))) || 0;
cleanup_exec_dir(dir, rcpath, dir + '/unused');
let reason = trim(output.stderr || '');
reason = reason ? replace(reason, /\s+/g, ' ') : '';
/* An HTTP-level failure prints the requested URL back into the message
* with its query string and subscription token attached, as in
* `Downloading 'https://host/path?token=secret'` + `HTTP error 404`.
* (A pure connection failure prints only `Failed to send request: …` and
* leaks nothing, but the 404/403 case is enough.) Redact at the source
* so every caller of wGETVerbose gets a safe `error` whether or not it
* remembers to call redactUrl itself. */
if (reason)
reason = redactReason(reason);
if (length(output.stdout || '') > HP_FETCH_CAP)
return { content: null, error: `response exceeds the ${HP_FETCH_CAP} byte limit` };
const exitcode = (output.exitcode !== 0) ? output.exitcode : fetch_rc;
if (exitcode !== 0) {
if (length(reason) > 200)
reason = substr(reason, 0, 200) + '...';
return { content: null, error: `fetch exited with status ${exitcode}: ${reason || 'no error output'}` };
}
/* A binary body is not a failed fetch.
*
* executeCommand() nulls stdout for binary content - a subscription body
* that is not text cannot be parsed by anything downstream - and without
* this branch the "no content, but stderr said something" test below
* reported the result as
*
* fetch failed: Downloading '…' … Download completed (34185 bytes)
*
* which contradicts itself, and which would send a user looking for a
* network problem they do not have. Measured on a device, with a real
* .srs fetched from a URL that answered 200. */
if (output.binary)
return { content: null, error: 'the fetch succeeded but the response is binary, not a subscription payload' };
/* Reached only when the fetcher exited 0 (the status is recovered above),
* so an empty body here means a 200 with nothing in it - reported with the
* fetcher's own message rather than as a parse failure. */
if (!length(trim(output.stdout)) && reason) {
if (length(reason) > 200)
reason = substr(reason, 0, 200) + '...';
return { content: null, error: `fetch failed: ${reason}` };
}
return { content: trim(output.stdout), error: null };
};
/* Utilities end */
/* String helper start */
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
export function strToBool(str) {
return str === '1' ? true : (str === '0' ? false : null);
};
export function strToInt(str) {
/* Zero reads back as "not set" on purpose: several sing-box fields mean
* "omit me" at zero - a vmess user's alterId, for instance - and the
* generators depend on removeBlankAttrs() dropping the null this produces.
* A review flagged it as "a legitimate 0 becomes null"; the golden outbound
* snapshot and the vmess alterId assertion both say otherwise. */
return !isEmpty(str) ? (int(str) || null) : null;
};
export function strToTime(str) {
if (isEmpty(str))
return null;
/* Preserve values that already carry a time unit (e.g. "30s", "1m") */
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
};
/* Which node transports carry UDP as their own wire format.
*
* QUIC inside a tunnel whose own transport is TCP is the worst of both
* worlds: the QUIC layer retransmits on a schedule that assumes it owns the
* path, and the TCP layer underneath retransmits on a schedule that assumes
* nothing above it is doing that. Measured on a router where both candidate
* nodes were the SAME server and only the transport differed (anytls on
* TCP:443 vs hysteria2 on QUIC/UDP:50466), DNS-over-UDP through the tunnel
* came back at 174 ms +/- 1 ms through hysteria2 and produced a 568 ms spike
* with 37 ms jitter and outright loss through anytls. Same server, same
* path - the variable really was the transport, not the node.
*
* A set rather than a list so membership is an object lookup and an unknown
* protocol name is simply absent rather than accidentally matching.
*
* This is the TRANSPORT, not the sing-box build feature: the `with_quic`
* marker in htdocs/.../homeproxy-pro.js records whether the binary was compiled
* with QUIC support, which is a different question and the wrong thing to
* test here. */
export const UDP_NATIVE_PROTOCOLS = {
'hysteria': true,
'hysteria2': true,
'tuic': true,
'wireguard': true
};
/* Decide whether the UDP leaving this router has a UDP-native path, and say
* why. Used by firewall_post.ut to decide whether QUIC has to be rejected,
* and by the diagnostic report to explain it - one definition, two
* consumers, because a second copy of this judgement is a copy that drifts.
*
* Note what this is NOT: a capability check. A TCP-transport node still
* proxies UDP perfectly well, sing-box will tunnel it. The question is only
* whether putting QUIC on that path is a good idea, so `native: false` means
* "QUIC would perform badly", never "UDP cannot be proxied".
*
* Returns { native, configured, protocol, reason }:
* native - true when the effective UDP path is UDP end to end
* configured - false when there is no UDP node at all (nothing to judge)
* protocol - the resolved node type, for the log
* reason - one sentence, safe to print
*
* WHY THIS READS UCI RATHER THAN TAKING A MODEL, given guard 7 keeps the
* semantic layers off the cursor: the three consumers of this judgement -
* the fw4 template, the RPC diagnostic report and (through the exported file)
* the shell runtime - do not share a data model. firewall_post.ut already
* reads main_node/main_udp_node straight from UCI for the tproxy chain, so
* this is not a new dependency in that file. The alternative - resolving it
* in generator/context.uc from the Loader's model and exporting it - would
* make the firewall depend on a value the generator produced, which is the
* coupling firewall_post.ut's own header warns against when it reads
* node-addr-ips.txt instead of re-deriving node selection. One function,
* three callers, no second opinion: that is the property worth protecting
* here. The uci module is available everywhere this runs (the CI toolchain
* builds ucode with -DUCI_SUPPORT=ON).
*/
export function udp_transport_verdict(cfgname, cursor_arg) {
/* The cursor is a parameter so a caller that already has one - the fw4
* template and the RPC report both do - reuses it instead of opening a
* second view on the same config, and so the unit test can point this at
* a sandbox directory. Callers on the client path pass nothing and get
* the default confdir. */
const uci = cursor_arg ?? cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
/* Custom routing mode is the user driving the rule graph by hand. They
* have already said what carries what and this function cannot read it,
* so it must not second-guess them - that is the same reasoning the QUIC
* gate used when it keyed on `routing_mode === 'custom'` alone. */
if (routing_mode === 'custom')
return {
native: true,
configured: true,
protocol: 'custom',
reason: 'custom routing mode: the rule graph decides what carries UDP'
};
const udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
const main_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
const node_type = (sec) => (isEmpty(sec) || sec === 'nil')
? null
: (uci.get(cfgname, sec, 'type') || null);
/* `same` is the case this function was written for. It is also the
* silent one: the UI label reads "Same as main node", and nothing
* anywhere tells the user that a TCP-transport main node turns every UDP
* flow into a TCP tunnel. */
if (udp_node === 'same') {
const type = node_type(main_node);
if (type === null)
return {
native: false, configured: false, protocol: 'nil',
reason: 'UDP follows the main node and no main node is set'
};
return (UDP_NATIVE_PROTOCOLS[type] === true)
? { native: true, configured: true, protocol: type,
reason: sprintf('UDP follows the main node (%s), which is UDP-native', type) }
: { native: false, configured: true, protocol: type,
reason: sprintf('UDP follows the main node (%s), which is a TCP transport - QUIC would be tunnelled over TCP', type) };
}
/* The url-test group picks a member per connection, so "all of them" is
* the only answer that holds for every packet: a single TCP member is
* enough to put QUIC back on the stalling path whenever it happens to be
* the selected one. */
if (udp_node === 'urltest') {
const members = uci.get(cfgname, 'config', 'main_udp_urltest_nodes');
if (isEmpty(members))
return {
native: false, configured: false, protocol: 'urltest',
reason: 'the UDP url-test group has no members'
};
let offender = null;
for (let sec in members) {
const type = node_type(sec);
if (UDP_NATIVE_PROTOCOLS[type] !== true && offender === null)
offender = type || 'unknown';
}
return (offender === null)
? { native: true, configured: true, protocol: 'urltest',
reason: sprintf('every member of the UDP url-test group is UDP-native (%d members)', length(members)) }
: { native: false, configured: true, protocol: offender,
reason: sprintf('the UDP url-test group contains a TCP-transport member (%s) - QUIC would be tunnelled over TCP whenever it is selected', offender) };
}
if (udp_node === 'nil')
return {
native: false, configured: false, protocol: 'nil',
reason: 'UDP is disabled: no UDP node is configured'
};
const type = node_type(udp_node);
if (type === null)
return {
native: false, configured: false, protocol: 'nil',
reason: sprintf('the UDP node "%s" does not exist', udp_node)
};
return (UDP_NATIVE_PROTOCOLS[type] === true)
? { native: true, configured: true, protocol: type,
reason: sprintf('the UDP node is %s, which is UDP-native', type) }
: { native: false, configured: true, protocol: type,
reason: sprintf('the UDP node is %s, which is a TCP transport - QUIC would be tunnelled over TCP', type) };
};
/* Turn a UCI list of port strings into the int array sing-box wants
* (e.g. the WireGuard `reserved` list, a routing rule's `port` /
* `source_port`). Returns null for anything that is not a non-empty
* array, so a caller can let removeBlankAttrs() drop the field.
*
* Lives here rather than in generator/common.uc because the Adapter
* layer needs it too (EndpointFactory builds the WireGuard endpoint)
* and an adapter must not import from generator/. */
export function parse_port(strport) {
if (type(strport) !== 'array' || isEmpty(strport))
return null;
let ports = [];
for (let i in strport) {
/* Only a bare decimal is a port: int() truncates, so '80-90' used to
* become 80 and '443/tcp' become 443, silently widening the rule
* instead of dropping the malformed entry. */
if (match(i, /^[0-9]+$/) && int(i) >= 1 && int(i) <= 65535)
push(ports, int(i));
}
return ports;
};
export function removeBlankAttrs(res) {
let content;
if (type(res) === 'object') {
content = {};
map(keys(res), (k) => {
if (type(res[k]) in ['array', 'object'])
content[k] = removeBlankAttrs(res[k]);
else if (res[k] !== null && res[k] !== '')
content[k] = res[k];
});
} else if (type(res) === 'array') {
content = [];
map(res, (k, i) => {
if (type(k) in ['array', 'object'])
push(content, removeBlankAttrs(k));
else if (k !== null && k !== '')
push(content, k);
});
} else
return res;
return content;
};
export function validation(datatype, data) {
if (!datatype || !data)
return null;
const ret = system(`/sbin/validate_data ${shellQuote(datatype)} ${shellQuote(data)} 2>/dev/null`);
return (ret === 0);
};
/* Validate IP/CIDR format to prevent nftables template injection from resource files */
export function isValidCIDR(addr, family) {
if (isEmpty(addr))
return false;
/* Strip leading/trailing whitespace */
addr = trim(addr);
if (!addr)
return false;
/* Split address and optional prefix. Reject a second '/' outright:
* the only legal form is "ip" or "ip/prefix", and a resource-list
* entry like "1.2.3.4/24/32" or "1.2.3.4/;}" would otherwise split
* into a benign-looking ip and a prefix the next checks would then
* have to defend. Drop it here so all the following regexes only have
* to police a one-or-zero-slash input. */
const parts = split(addr, '/');
if (length(parts) > 2)
return false;
const ip = parts[0];
const prefix = parts[1];
/* Validate IP part */
if (family === 4) {
if (!match(ip, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/))
return false;
/* Validate octet ranges */
const octets = split(ip, '.');
for (let o in octets)
if (int(o) > 255)
return false;
/* Validate prefix if present. Anchor with a digit-only regex:
* without it, `int(prefix)` happily parses `24;}` as 24 (strtoll
* stops at the first non-digit), so a poisoned china_ip4.txt line
* `1.2.3.4/24;}` sailed past this check and ended up verbatim
* in the fw4 ruleset fw4 then loaded as root.
*
* ucode has no `undefined` (an absent index reads as null), and
* `if (prefix)` is also false for the empty string, so a
* trailing-slash entry like `1.2.3.4/` would slip past a plain
* `if (prefix)` gate and pass overall. Gate on null, which
* keeps `''` from sneaking in; the regex naturally rejects the
* empty string and any non-digit. */
if (prefix !== null) {
if (!match(prefix, /^\d{1,3}$/) || int(prefix) > 32)
return false;
}
} else if (family === 6) {
/* Only hex digits and colons */
if (!match(ip, /^[0-9a-fA-F:]+$/))
return false;
/* At most one "::" */
if (match(ip, /::.*::/))
return false;
/* Validate group structure */
const dcolon = index(ip, '::');
if (dcolon === -1) {
/* Uncompressed form: exactly 8 groups of 1-4 hex digits */
const groups = split(ip, ':');
if (length(groups) !== 8)
return false;
for (let g in groups)
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
return false;
} else {
/* Compressed form: "::" expands to at least one zero group */
let count = 0;
for (let part in [substr(ip, 0, dcolon), substr(ip, dcolon + 2)]) {
if (part === '')
continue;
for (let g in split(part, ':')) {
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
return false;
count++;
}
}
if (count > 7)
return false;
}
/* Same anchor as the IPv4 branch: `1.2.3.4/24;}` would let
* `int('24;}')` slip through unanchored and end up in fw4. Same
* null-presence guard for `::1/` (empty prefix). */
if (prefix !== null) {
if (!match(prefix, /^\d{1,3}$/) || int(prefix) > 128)
return false;
}
} else {
return false;
}
return true;
};
/* String helper end */
/* String parser start */
export function decodeBase64Str(str) {
if (isEmpty(str))
return null;
str = trim(str);
str = replace(str, /_/g, '/');
str = replace(str, /-/g, '+');
const padding = length(str) % 4;
if (padding)
str = str + substr('====', padding);
return b64dec(str);
};
export function parseURL(url) {
if (type(url) !== 'string')
return null;
const services = {
http: '80',
https: '443'
};
const objurl = {};
objurl.href = url;
url = replace(url, /#(.+)$/, (_, val) => {
objurl.hash = val;
return '';
});
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
objurl.protocol = val;
return '';
});
url = replace(url, /\?(.+)/, (_, val) => {
objurl.search = val;
objurl.searchParams = urldecode_params(val);
return '';
});
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
val = replace(val, /^([^@]+)@/, (_, val) => {
objurl.userinfo = val;
return '';
});
val = replace(val, /:(\d+)$/, (_, val) => {
objurl.port = val;
return '';
});
if (validation('ip4addr', val) ||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
validation('hostname', val))
objurl.hostname = val;
return '';
});
objurl.pathname = url || '/';
if (!objurl.protocol || !objurl.hostname)
return null;
if (objurl.userinfo) {
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
objurl.password = val;
return '';
});
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
objurl.username = objurl.userinfo;
delete objurl.userinfo;
} else {
delete objurl.userinfo;
delete objurl.password;
}
};
if (!objurl.port)
objurl.port = services[objurl.protocol];
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
objurl.origin = `${objurl.protocol}://${objurl.host}`;
return objurl;
};
/* String parser end */
/* Config generator helper start */
/*
* Shared sing-box TLS object builder for the client outbound and the server
* inbound. The property order below is significant: the generated JSON keeps
* the insertion order of this object after removeBlankAttrs() drops the blank
* attributes, and both generators used to emit the fields in exactly this
* order. Keep client-only fields (insecure/handshake_timeout/utls) and
* server-only fields (key_path/certificate_provider) at their current
* positions when editing.
*
* P1-B: the first arg is now a Node.tls-shape structured sub-object
* (the same shape the Loader produces) rather than a flat UCI section.
* The Adapter passes node.tls; the server generator builds a
* structured view of its UCI inbound before calling. Server-only
* fields (key material, ACME, server-side reality handshake) still
* live on the flat UCI section - they are not part of the Node model
* because no client outbound needs them - so the server passes them
* through `server_extras` instead of expecting the structured tls
* to carry them.
*/
export function buildTLSObject(tls, is_server, server_extras) {
if ((tls && tls.enabled) !== '1')
return null;
/* When is_server the caller hands a flat UCI section's server-only
* tail. We accept either an object or null; null means "no server
* extras" which only matters when the caller still wants a TLS
* object built for an inbound that does not actually serve TLS
* itself (the enabled check above returns null first in that case). */
const extras = is_server ? (server_extras || {}) : {};
return {
enabled: true,
server_name: tls.server_name,
insecure: is_server ? null : strToBool(tls.insecure),
alpn: tls.alpn,
min_version: tls.min_version,
max_version: tls.max_version,
handshake_timeout: is_server ? null : strToTime(tls.handshake_timeout),
cipher_suites: tls.cipher_suites,
certificate_path: tls.cert_path && validateCertificatePath(tls.cert_path) ? tls.cert_path : null,
key_path: (is_server && extras.tls_key_path && validateCertificatePath(extras.tls_key_path)) ? extras.tls_key_path : null,
certificate_provider: (is_server && extras.tls_acme === '1') ? {
type: 'acme',
domain: (type(extras.tls_acme_domain) === 'array') ? extras.tls_acme_domain
: (isEmpty(extras.tls_acme_domain) ? [] : [extras.tls_acme_domain]),
data_directory: HP_DIR + '/certs',
default_server_name: extras.tls_acme_dsn,
email: extras.tls_acme_email,
provider: extras.tls_acme_provider,
account_key: extras.tls_acme_account_key,
key_type: extras.tls_acme_key_type,
profile: extras.tls_acme_profile,
disable_http_challenge: strToBool(extras.tls_acme_dhc),
disable_tls_alpn_challenge: strToBool(extras.tls_acme_dtac),
alternative_http_port: strToInt(extras.tls_acme_ahp),
alternative_tls_port: strToInt(extras.tls_acme_atp),
external_account: (extras.tls_acme_external_account === '1') ? {
key_id: extras.tls_acme_ea_keyid,
mac_key: extras.tls_acme_ea_mackey
} : null,
dns01_challenge: (extras.tls_dns01_challenge === '1') ? {
provider: extras.tls_dns01_provider,
access_key_id: extras.tls_dns01_ali_akid,
access_key_secret: extras.tls_dns01_ali_aksec,
region_id: extras.tls_dns01_ali_rid,
api_token: extras.tls_dns01_cf_api_token
} : null
} : null,
ech: is_server ? (extras.tls_ech_key ? {
enabled: true,
key: split(extras.tls_ech_key, '\n')
/* config: split(extras.tls_ech_config, '\n') */
} : null) : ((tls.ech && tls.ech.enabled === '1') ? {
enabled: true,
config: tls.ech.config,
/* Same gate as certificate_path: this is a file sing-box opens as
* root, and it used to reach the config unvalidated. */
config_path: tls.ech.config_path && validateCertificatePath(tls.ech.config_path) ? tls.ech.config_path : null
} : null),
utls: (is_server || isEmpty(tls.utls && tls.utls.fingerprint)) ? null : {
enabled: true,
fingerprint: tls.utls.fingerprint
},
reality: ((tls.reality && tls.reality.enabled) !== '1') ? null : (is_server ? {
enabled: true,
private_key: extras.tls_reality_private_key,
short_id: tls.reality.short_id,
max_time_difference: strToTime(extras.tls_reality_max_time_difference),
handshake: {
server: extras.tls_reality_server_addr,
server_port: strToInt(extras.tls_reality_server_port)
}
} : {
enabled: true,
public_key: tls.reality.public_key,
short_id: tls.reality.short_id
})
};
};
/* Shared sing-box transport object builder; the client transport additionally
supports the gRPC keepalive hint, the server one does not.
P1-B: the first arg is now a Node.transport-shape structured sub-object. */
export function buildTransportObject(transport, is_server) {
if (isEmpty(transport && transport.type))
return null;
return {
type: transport.type,
host: transport.host,
path: transport.path,
headers: transport.headers,
method: transport.method,
max_early_data: strToInt(transport.max_early_data),
early_data_header_name: transport.early_data_header_name,
service_name: transport.service_name,
idle_timeout: strToTime(transport.idle_timeout),
ping_timeout: strToTime(transport.ping_timeout),
permit_without_stream: is_server ? null : strToBool(transport.permit_without_stream)
};
};
/* Config generator helper end */
/* PEM validation start */
/*
* Check that `content` is a PEM certificate (is_private_key = false) or an
* RSA/EC private key (is_private_key = true): matching BEGIN/END boundaries
* with a base64 body in between. Kanged from luci-proto-openconnect; used by
* the rpcd certificate upload and available for future certificate features.
*/
/* Shared by the certificate, private-key and ECH-config validators: only the
* markers differ, the body rule does not (base64 lines - or a single 64
* character line - between a BEGIN and an END marker). */
function validatePEM(content, beg, end) {
if (isEmpty(content))
return false;
const lines = split(trim(content), /[\r\n]/);
let start = false, i;
for (i = 0; i < length(lines); i++) {
if (match(lines[i], beg))
start = true;
else if (start && !b64dec(lines[i]) && length(lines[i]) !== 64)
break;
}
if (!start || i < length(lines) - 1 || !match(lines[i], end))
return false;
return true;
};
export function isValidPEM(content, is_private_key) {
const beg = is_private_key ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
end = is_private_key ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/;
return validatePEM(content, beg, end);
};
/* A client ECH config list is neither a certificate nor a private key - it is
* wrapped in its own markers ("-----BEGIN ECH CONFIGS-----"), so isValidPEM()
* rejects it. The node form's "Upload ECH config" button has always sent
* certificate_write('client_ech_conf'); the backend had no case for that name,
* and would have rejected the body here even with one. */
export function isValidECHConfig(content) {
return validatePEM(content,
/^-----BEGIN ECH CONFIGS-----$/,
/^-----END ECH CONFIGS-----$/);
};
/* PEM validation end */