/* * 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//` 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 stdout is "-" * --user-agent -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 */