mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 21:05:13 +08:00
⛅ Sync 2026-09-29 21:45:00
This commit is contained in:
1 parent
a4d6e3e4b9
commit
da77daf88e
191 files changed
+8077
-6247
No files matched your search
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"admin/services/homeproxy-pro": {
|
||||
"title": "HomeProxy Pro",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "firstchild"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-homeproxy-pro" ],
|
||||
"uci": { "homeproxy-pro": true }
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy-pro/client": {
|
||||
"title": "Client Settings",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy-pro/client"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy-pro/node": {
|
||||
"title": "Node Settings",
|
||||
"order": 15,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy-pro/node"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy-pro/server": {
|
||||
"title": "Server Settings",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy-pro/server"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy-pro/status": {
|
||||
"title": "Service Status",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy-pro/status"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"luci-app-homeproxy-pro": {
|
||||
"description": "Grant access to homeproxy-pro configuration",
|
||||
"read": {
|
||||
"file": {
|
||||
"/var/run/homeproxy-pro/homeproxy-pro.log": [ "read" ],
|
||||
"/var/run/homeproxy-pro/sing-box-c.log": [ "read" ],
|
||||
"/var/run/homeproxy-pro/sing-box-s.log": [ "read" ]
|
||||
},
|
||||
"ubus": {
|
||||
"service": [ "list" ],
|
||||
"luci.homeproxy-pro": [
|
||||
"acllist_read",
|
||||
"connection_check",
|
||||
"node_parse",
|
||||
"resources_get_version",
|
||||
"singbox_get_features",
|
||||
"update_subscriptions_status"
|
||||
]
|
||||
},
|
||||
"uci": [ "homeproxy-pro" ]
|
||||
},
|
||||
"write": {
|
||||
"file": {
|
||||
"/tmp/homeproxy_cert_server_publickey.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_server_privatekey.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_client_ca.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_client_ech_conf.tmp": [ "write" ]
|
||||
},
|
||||
"ubus": {
|
||||
"luci.homeproxy-pro": [
|
||||
"acllist_write",
|
||||
"certificate_write",
|
||||
"log_clean",
|
||||
"resources_update",
|
||||
"singbox_generator",
|
||||
"update_subscriptions"
|
||||
]
|
||||
},
|
||||
"uci": [ "homeproxy-pro" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2023-2024 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { access, error, lstat, popen, readfile, writefile } from 'fs';
|
||||
|
||||
import { isBinary, isValidECHConfig, isValidPEM, shellQuote, executeCommand } from '/etc/homeproxy-pro/scripts/homeproxy-pro.uc';
|
||||
import { parse_uri } from '/etc/homeproxy-pro/scripts/parser/uri.uc';
|
||||
|
||||
/* Kanged from ucode/luci - replaced by the imported shellQuote() so every shell
|
||||
* argument in this file goes through one helper. */
|
||||
function hasKernelModule(kmod) {
|
||||
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellQuote(kmod))) === 0);
|
||||
}
|
||||
|
||||
const HP_DIR = '/etc/homeproxy-pro';
|
||||
const RUN_DIR = '/var/run/homeproxy-pro';
|
||||
const LOG_PATH = `${RUN_DIR}/homeproxy-pro.log`;
|
||||
|
||||
/* Queried by singbox_get_features and by node_parse: the share-link parser
|
||||
* skips QUIC protocols and uTLS fingerprints the running sing-box cannot
|
||||
* serve, so it needs the same feature set the UI displays. */
|
||||
function collectFeatures() {
|
||||
let features = {};
|
||||
|
||||
const fd = popen('/usr/bin/sing-box version');
|
||||
if (fd) {
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
if (match(trim(line), /^sing-box version (.*)/))
|
||||
features.version = match(trim(line), /^sing-box version (.*)/)[1];
|
||||
|
||||
let tags = match(trim(line), /^Tags: (.*)/);
|
||||
if (tags)
|
||||
for (let i in split(tags[1], ','))
|
||||
features[i] = true;
|
||||
}
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
features.hp_has_ip_full = access('/usr/libexec/ip-full');
|
||||
features.hp_has_tcp_brutal = hasKernelModule('brutal.ko');
|
||||
features.hp_has_tproxy = hasKernelModule('nft_tproxy.ko') || access('/etc/modules.d/nft-tproxy');
|
||||
features.hp_has_tun = hasKernelModule('tun.ko') || access('/etc/modules.d/30-tun');
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
acllist_read: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
|
||||
return { content: null, error: 'illegal type' };
|
||||
|
||||
const filecontent = readfile(`${HP_DIR}/resources/${req.args?.type}.txt`);
|
||||
return { content: filecontent };
|
||||
}
|
||||
},
|
||||
acllist_write: {
|
||||
args: { type: 'type', content: 'content' },
|
||||
call: function(req) {
|
||||
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const file = `${HP_DIR}/resources/${req.args?.type}.txt`;
|
||||
let content = req.args?.content;
|
||||
|
||||
/* Sanitize content */
|
||||
if (content) {
|
||||
content = trim(content);
|
||||
content = replace(content, /\r\n?/g, '\n');
|
||||
if (!match(content, /\n$/))
|
||||
content += '\n';
|
||||
|
||||
/* Domains only: reject lines that could break the downstream
|
||||
dnsmasq/nftset injection (whitespace / shell or directive chars).
|
||||
Review L1: the rejection used to say "invalid character in
|
||||
domain list" with no line number, so a 200-line list with one
|
||||
bad entry forced the user to bisect by hand. Report line 1
|
||||
for the first bad row so the user can jump straight to it. */
|
||||
const lines = split(content, /\n/);
|
||||
for (let i = 0; i < length(lines); i++) {
|
||||
const line = lines[i];
|
||||
if (!trim(line))
|
||||
continue;
|
||||
if (match(line, /[ \t\/\\;<>="'&#|]/))
|
||||
return { result: false, error: `line ${i + 1}: invalid character in domain list (only domains, no spaces/comments)` };
|
||||
}
|
||||
}
|
||||
|
||||
/* writefile() with a null body writes nothing useful; an empty
|
||||
* body is what clearing the field means. */
|
||||
if (content == null)
|
||||
content = '';
|
||||
|
||||
system(`mkdir -p ${shellQuote(HP_DIR)}/resources`);
|
||||
writefile(file, content);
|
||||
|
||||
return { result: true };
|
||||
}
|
||||
},
|
||||
|
||||
certificate_write: {
|
||||
args: { filename: 'filename' },
|
||||
call: function(req) {
|
||||
/* `kind` is 'cert', 'key' or 'ech'. Each certificate button
|
||||
* has its own PEM shape; an ECH config list in particular is
|
||||
* neither a certificate nor a key, so reusing isValidPEM()
|
||||
* for it would reject every valid upload with a misleading
|
||||
* "does not look like a correct PEM file". */
|
||||
const writeCertificate = (filename, kind) => {
|
||||
/* Per-button staging path. The previous code used a
|
||||
* single /tmp/homeproxy_certificate.tmp shared by every
|
||||
* certificate button, which was a race: the second upload
|
||||
* landed on top of the first while the backend was still
|
||||
* reading it, and a slow upload could overwrite a faster
|
||||
* one. The frontend (homeproxy-pro.js uploadCertificate)
|
||||
* derives the same path from the UCI option name, and
|
||||
* the ACL write list enumerates all four. */
|
||||
const tmpcert = `/tmp/homeproxy_cert_${filename}.tmp`;
|
||||
const filestat = lstat(tmpcert);
|
||||
|
||||
if (!filestat || filestat.type !== 'file' || filestat.size <= 0) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'empty certificate file' };
|
||||
}
|
||||
|
||||
let filecontent = readfile(tmpcert);
|
||||
if (isBinary(filecontent)) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'illegal file type: binary' };
|
||||
}
|
||||
|
||||
/* Validate the PEM structure before storing it */
|
||||
const wellformed = kind === 'ech'
|
||||
? isValidECHConfig(filecontent)
|
||||
: isValidPEM(filecontent, kind === 'key');
|
||||
|
||||
if (!wellformed) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'this does not look like a correct PEM file' };
|
||||
}
|
||||
|
||||
/* Sanitize certificate */
|
||||
filecontent = trim(filecontent);
|
||||
filecontent = replace(filecontent, /\r\n?/g, '\n');
|
||||
if (!match(filecontent, /\n$/))
|
||||
filecontent += '\n';
|
||||
|
||||
system(`mkdir -p ${shellQuote(HP_DIR)}/certs`);
|
||||
writefile(`${HP_DIR}/certs/${filename}.pem`, filecontent);
|
||||
/* writefile() is fopen(path, "w"): 0666 & ~umask, i.e. 0644 at
|
||||
* the usual 022 - world-readable, on a file that can be a TLS
|
||||
* private key. Start it at 0600; the start path
|
||||
* (runtime/service.sh, hp_prepare_runtime_files) hands the
|
||||
* directory and its files to the sing-box user so the jailed
|
||||
* server can read them, with the mode kept at 0600. */
|
||||
system(`chmod 600 ${shellQuote(HP_DIR)}/certs/${shellQuote(filename + '.pem')}`);
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
|
||||
return { result: true };
|
||||
};
|
||||
|
||||
const filename = req.args?.filename;
|
||||
switch (filename) {
|
||||
case 'client_ca':
|
||||
case 'server_publickey':
|
||||
return writeCertificate(filename, 'cert');
|
||||
break;
|
||||
case 'server_privatekey':
|
||||
return writeCertificate(filename, 'key');
|
||||
break;
|
||||
/* The node form's fourth upload button. It was missing here
|
||||
* from the initial commit, so the button always failed with
|
||||
* "illegal cerificate filename" while the ACL granted the
|
||||
* two write paths - the frontend assumed the backend case
|
||||
* existed and nothing tested that it did. */
|
||||
case 'client_ech_conf':
|
||||
return writeCertificate(filename, 'ech');
|
||||
break;
|
||||
default:
|
||||
return { result: false, error: 'illegal cerificate filename' };
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
connection_check: {
|
||||
args: { site: 'site' },
|
||||
call: function(req) {
|
||||
let url;
|
||||
switch(req.args?.site) {
|
||||
case 'baidu':
|
||||
url = 'https://www.baidu.com';
|
||||
break;
|
||||
case 'google':
|
||||
url = 'https://www.google.com';
|
||||
break;
|
||||
default:
|
||||
return { result: false, error: 'illegal site' };
|
||||
break;
|
||||
}
|
||||
|
||||
return { result: (system(`/usr/bin/wget --spider -qT3 ${shellQuote(url)} 2>"/dev/null"`, 3100) === 0) };
|
||||
}
|
||||
},
|
||||
|
||||
log_clean: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['homeproxy-pro', 'sing-box-c', 'sing-box-s'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const filestat = lstat(`${RUN_DIR}/${req.args?.type}.log`);
|
||||
if (filestat)
|
||||
writefile(`${RUN_DIR}/${req.args?.type}.log`, '');
|
||||
return { result: true };
|
||||
}
|
||||
},
|
||||
|
||||
singbox_generator: {
|
||||
args: { type: 'type', params: 'params' },
|
||||
call: function(req) {
|
||||
if (index(['ech-keypair', 'uuid', 'reality-keypair', 'vapid-keypair', 'wg-keypair'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const type = req.args?.type;
|
||||
let result = {};
|
||||
|
||||
const fd = popen(`/usr/bin/sing-box generate ${shellQuote(type)} ${shellQuote(req.args?.params || '')}`);
|
||||
if (fd) {
|
||||
let ech_cfg_set = false;
|
||||
let ech_key_set = false;
|
||||
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
if (type === 'uuid')
|
||||
result.uuid = trim(line);
|
||||
else if (index(['reality-keypair', 'vapid-keypair', 'wg-keypair'], type) !== -1) {
|
||||
let priv = match(trim(line), /PrivateKey: (.*)/);
|
||||
if (priv)
|
||||
result.private_key = priv[1];
|
||||
let pub = match(trim(line), /PublicKey: (.*)/);
|
||||
if (pub)
|
||||
result.public_key = pub[1];
|
||||
} else if (type === 'ech-keypair') {
|
||||
if (trim(line) === '-----BEGIN ECH CONFIGS-----')
|
||||
ech_cfg_set = true;
|
||||
else if (trim(line) === '-----BEGIN ECH KEYS-----')
|
||||
ech_key_set = true;
|
||||
|
||||
if (ech_cfg_set)
|
||||
result.ech_cfg = result.ech_cfg ? result.ech_cfg + '\n' + trim(line) : trim(line) ;
|
||||
if (ech_key_set)
|
||||
result.ech_key = result.ech_key ? result.ech_key + '\n' + trim(line) : trim(line) ;
|
||||
|
||||
if (trim(line) === '-----END ECH CONFIGS-----')
|
||||
ech_cfg_set = false;
|
||||
else if (trim(line) === '-----END ECH KEYS-----')
|
||||
ech_key_set = false;
|
||||
}
|
||||
}
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
return { result };
|
||||
}
|
||||
},
|
||||
|
||||
singbox_get_features: {
|
||||
call: function() {
|
||||
return collectFeatures();
|
||||
}
|
||||
},
|
||||
|
||||
/* Parse one share link with the same parser the subscription pipeline
|
||||
* uses (parse_uri.uc). This exists so the browser does not need a second
|
||||
* implementation: the LuCI form used to carry a 388-line JavaScript copy
|
||||
* of the parser that had already drifted from the backend (the vmess
|
||||
* branch lost vmess_global_padding, anytls defaulted a missing port to
|
||||
* 80) and that wrote nodes into UCI without the /sbin/validate_data
|
||||
* host/port checks parse_uri() applies. */
|
||||
node_parse: {
|
||||
args: { uri: 'string' },
|
||||
call: function(req) {
|
||||
const uri = req.args?.uri;
|
||||
|
||||
if (type(uri) !== 'string' || !length(uri) || length(uri) > 4096)
|
||||
return { config: null, error: 'illegal share link' };
|
||||
|
||||
let config;
|
||||
try {
|
||||
/* parse_uri() reports skipped nodes through the logger;
|
||||
* the caller only needs the verdict. */
|
||||
config = parse_uri(trim(uri), collectFeatures(), function() {});
|
||||
} catch (e) {
|
||||
return { config: null, error: 'parse failed: ' + e.message };
|
||||
}
|
||||
|
||||
if (!config)
|
||||
return { config: null, error: 'unsupported or invalid share link' };
|
||||
|
||||
return { config: config };
|
||||
}
|
||||
},
|
||||
|
||||
resources_get_version: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
/* whitelist resource types to prevent path traversal */
|
||||
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
|
||||
return { error: 'illegal type', version: null, updated_at: null };
|
||||
let version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
|
||||
/* Review M7: the version file holds the upstream commit date
|
||||
* and SHA; .updated_at is when *this router* last succeeded.
|
||||
* The two are not the same - a successful run on a stale
|
||||
* router still records the same upstream date, so the UI
|
||||
* shows both. */
|
||||
/* The shipped .ver files hold a single timestamp (the format the
|
||||
* resource updater used before it started recording the upstream
|
||||
* commit); a runtime update writes "<date> <sha>". Show the date
|
||||
* part when there is one, and the value as it stands otherwise.
|
||||
*
|
||||
* No non-capturing groups: ucode's regex engine rejects `(?:...)`
|
||||
* with "Repetition not preceded by valid expression" when the
|
||||
* literal is evaluated, so it compiles fine and then takes this
|
||||
* method down at call time. */
|
||||
const fields = version ? match(version, /^(\S+) \S+$/) : null;
|
||||
version = fields ? fields[1] : version;
|
||||
/* Only a missing .ver means "not found"; that is the case the
|
||||
* UI paints red. */
|
||||
if (!version)
|
||||
return { version: null, updated_at: null, error: 'no version file' };
|
||||
/* .updated_at is written by update_resources.sh after a
|
||||
* successful run, so a device that has never updated anything
|
||||
* legitimately has none. Returning error() here reported the
|
||||
* errno left behind by *that* missing file, which made every
|
||||
* fresh install show "not found" in red next to a perfectly
|
||||
* good version string. */
|
||||
const updated_at = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.updated_at`));
|
||||
return { version: version, updated_at: updated_at || null };
|
||||
}
|
||||
},
|
||||
resources_update: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
|
||||
return { status: 255, error: 'illegal type' };
|
||||
if (req.args?.type) {
|
||||
const exit_code = system(`${shellQuote(HP_DIR)}/scripts/update_resources.sh ${shellQuote(req.args?.type)}`);
|
||||
return { status: exit_code };
|
||||
} else
|
||||
return { status: 255, error: 'illegal type' };
|
||||
}
|
||||
},
|
||||
update_subscriptions: {
|
||||
/* Run the subscription updater once and report how it went.
|
||||
*
|
||||
* The LuCI button used fs.exec_direct() on
|
||||
* /etc/homeproxy-pro/scripts/update_subscriptions.uc, which is the only
|
||||
* reason the package ACL had to grant `file exec` on a root-owned
|
||||
* entry point: the browser session was authorised to execute that
|
||||
* path. Through one named ubus method the session is granted the
|
||||
* action instead, and the ACL lists no executable path at all
|
||||
* (arch-guard 30).
|
||||
*
|
||||
* The previous synchronous shape returned the orchestrator's exit
|
||||
* status and captured stderr, but the full pipeline (wget fetch
|
||||
* through a CN -> overseas link, UCI commit, /etc/init.d/homeproxy-pro
|
||||
* reload, the 30 s sing-box health gate) regularly took 10-15 s,
|
||||
* which exceeded the browser XHR timeout on every browser we tried.
|
||||
* The UI button then surfaced "XHR request timed out" while the
|
||||
* backend happily completed the work; the user was left with a
|
||||
* stale page and no obvious way to know whether anything happened.
|
||||
*
|
||||
* The fix: spawn the script detached via "sh -c '... &'", return
|
||||
* immediately, and let the frontend poll update_subscriptions_status
|
||||
* until the orchestrator's lock file is gone (created in
|
||||
* acquire_lock(), removed on every exit path). The lock is also
|
||||
* what serialises two simultaneous updates, so refusing a second
|
||||
* call while the lock is present is consistent with the script's
|
||||
* own behaviour. The script's stdio is redirected so it cannot
|
||||
* block on a closed pipe when the rpcd process exits. */
|
||||
call: function() {
|
||||
const lock = `${RUN_DIR}/update_subscriptions.lock`;
|
||||
if (lstat(lock))
|
||||
return { result: false, error: 'Subscription update already running', running: true };
|
||||
|
||||
/* Trailing '&' is what makes this return immediately: /bin/sh
|
||||
* backgrounds the script before the shell exits, and system()
|
||||
* sees only the (sub-)shell's exit. Redirections (<dev/null,
|
||||
* >dev/null, 2>&1) are the other half: the orphaned script
|
||||
* must not hold open any fd inherited from rpcd, or it
|
||||
* keeps rpcd's stdio alive across the call. */
|
||||
const script = shellQuote(`${HP_DIR}/scripts/update_subscriptions.uc`);
|
||||
system(`sh -c ${shellQuote(`${script} </dev/null >/dev/null 2>&1 &`)}`);
|
||||
|
||||
return { result: true, async: true };
|
||||
}
|
||||
},
|
||||
|
||||
update_subscriptions_status: {
|
||||
/* Status companion to update_subscriptions. Reports whether the
|
||||
* updater is still running (lock file present) and the last ~12
|
||||
* lines of the homeproxy-pro log so the page can show what the run
|
||||
* said without re-reading everything. The log is world-readable
|
||||
* and is rotated by runtime/service.sh -> log-cleaner.sh.
|
||||
*
|
||||
* `readfile()` returns null (not throws) on a missing file in this
|
||||
* ucode build, and the downstream split/slice/join then propagate
|
||||
* that null straight through. Guard with a truthy check on the
|
||||
* raw text so log_tail stays a string even when the log has never
|
||||
* been written - the test_rpc_methods shape contract pins it as
|
||||
* `string`, and the UI otherwise shows "null" in the toast. */
|
||||
call: function() {
|
||||
const lock = `${RUN_DIR}/update_subscriptions.lock`;
|
||||
const running = !!lstat(lock);
|
||||
|
||||
let tail = '';
|
||||
try {
|
||||
const text = readfile(`${LOG_PATH}`);
|
||||
if (text) {
|
||||
const lines = split(text, '\n');
|
||||
const start = length(lines) > 12 ? length(lines) - 12 : 0;
|
||||
tail = join('\n', slice(lines, start)) || '';
|
||||
}
|
||||
} catch (e) {
|
||||
tail = '';
|
||||
}
|
||||
|
||||
return { running, log_tail: tail };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.homeproxy-pro': methods };
|
||||
Reference in new issue
Block a user