⛄ Sync 2026-09-15 17:04:17

This commit is contained in:
github-actions[bot] committed 2026-09-15 17:04:17 +08:00
1 parent 569d3c8c73
commit 6840e76cc4
62 files changed
+16332 -19628

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
.DS_Store
/homeproxy
+15 -17
View File
@@ -1,31 +1,29 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-homeproxy
PKG_VERSION:=2.0.0
PKG_RELEASE:=13
LUCI_TITLE:=Sing-box proxy management platform for OpenWrt
LUCI_TITLE:=The modern ImmortalWrt proxy platform for ARM64/AMD64 (sing-box 1.14)
# Pure ucode/JS payload with no compiled code, so the package itself is arch
# independent. The real arch constraint comes from the +sing-box dependency,
# which the feed builds for every architecture Go supports (aarch64, arm,
# mipsel, riscv64, x86_64, ...); where no sing-box package exists, dependency
# resolution refuses the install. Checked against the ImmortalWrt 25.12.1
# package index (sing-box 1.14.0-r1 published for all of the above).
LUCI_PKGARCH:=all
LUCI_DEPENDS:= \
+sing-box \
+firewall4 \
+dnsmasq-full \
+ip-full \
+kmod-tun \
+kmod-nft-tproxy \
+ca-bundle \
+curl \
+jsonfilter \
+unzip \
+ucode-mod-digest \
+ucode-mod-fs \
+ucode-mod-math \
+ucode-mod-ubus \
+ucode-mod-uci
+ip-full \
+kmod-tun \
+ucode-mod-digest
PKG_NAME:=luci-app-homeproxy
PKG_VERSION:=27.911.1.14
PKG_RELEASE:=14
define Package/luci-app-homeproxy/conffiles
/etc/config/homeproxy
+94 -60
View File
@@ -1,68 +1,102 @@
适用于 OpenWrt 24.10 的 HomeProxy LuCI 管理界面,按 `官方内核sing-box 1.13.13` 进行二次定制适配,在官方内核基础上增加功能:
1、远程规则集本地持久化
2、Clash API 代理供应商
3、Clash API 中文名称
<div align="center">
## 一、安装
上传 zip 到 OpenWrt /tmp目录后解压并执行(文件名homeproxy-2.0.1-x86_64.zip自行替换):
```sh
cd /tmp
unzip homeproxy-2.0.1-x86_64.zip
cd homeproxy-2.0.1-x86_64
sh install.sh
```
# luci-app-homeproxy
## 二、卸载
```sh
/etc/init.d/homeproxy stop 2>/dev/null || true
/etc/init.d/homeproxy disable 2>/dev/null || true
opkg remove luci-i18n-homeproxy-zh-cn 2>/dev/null || true
opkg remove luci-app-homeproxy 2>/dev/null || true
rm -rf /etc/config/homeproxy
rm -rf /etc/homeproxy
rm -rf /usr/share/homeproxy
rm -rf /www/luci-static/resources/view/homeproxy
rm -f /www/luci-static/resources/homeproxy.js
rm -f /usr/share/luci/menu.d/luci-app-homeproxy.json
rm -f /usr/share/rpcd/acl.d/luci-app-homeproxy.json
rm -f /usr/share/rpcd/ucode/luci.homeproxy
rm -f /tmp/luci-indexcache
rm -rf /tmp/luci-modulecache/*
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
```
**The modern ImmortalWrt proxy platform for ARM64 / AMD64**
## 三、维护工作流
- `Rescan-Translation.yml`:代码变更后自动重新扫描并更新翻译文件,也支持手动运行。
- `Update-Geodata.yml`:每周自动更新内置 geodata 资源,也支持手动运行。
基于 sing-box 1.14 内核的现代代理平台 — 简洁、高效、开箱即用。
## 四、常见问题
1、面版下载失败解决办法
第一种:面版设置-UI下载地址,下拉选择需要的UI,点击更新面版手动触发面版下载。
第二种:下载面版ZIP包,手动上传面版ZIP,下载地址如下。
镜像
```sh
https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip
```
或者直连
```sh
https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip
```
2、远程规则集是否下载成功判断
连接SSH查看/etc/homeproxy/ruleset/目录下是否有规则集文件,有则表示下载成功,反之则没下载成功,检查规则集远程连接。
---
3、安装新包后如果页面仍显示旧文字,建议清浏览器缓存,或执行:
```sh
rm -f /tmp/luci-indexcache
rm -rf /tmp/luci-modulecache/*
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
```
</div>
## 五、感谢作者
immortalwrt
https://github.com/immortalwrt/homeproxy
## 项目定位
VIKINGYFY
https://github.com/VIKINGYFY/homeproxy
本项目是 [luci-app-homeproxy](https://github.com/szwjp/homeproxy) 的**分拆版本线**:以 sing-box **1.14** 内核为唯一目标,充分结合 1.14 引入的新特性进行升级,不再兼容 1.13 及更早内核。
| 版本线 | 内核要求 | 演进方式 |
| --- | --- | --- |
| **homeproxy1.14**(本项目) | sing-box ≥ 1.14 | 1.14 特性驱动,配置生成直接使用 1.14 新格式 |
## 已融入的 sing-box 1.14 特性
| 领域 | 特性 |
| --- | --- |
| DNS | `evaluate` / `match_response` / `race` 响应匹配,乐观缓存与 `store_dns`,统一 `dns.timeout` |
| 规则集 | `initial_path`、多 tag、`http_clients` 下载客户端 |
| 连接 | TUN `dns_mode` / `dns_address`,UDP NAT 参数(`udp_mapping` / `udp_filtering` / `udp_nat_max`) |
| 证书 | `tls.acme` → `certificate_provider`(`key_type` / `profile` / `account_key`) |
| 工程化 | 生成配置内置 `$schema`;运行要求 sing-box ≥ 1.14 |
## 已修复:国内域名走代理问题(r4 / r6 / r7)
**背景**
`bypass_mainland_china` 的数据路由原本只用 `geoip-cn`(按 IP 匹配国内目标)。sing-box 不会把「域名连接」的域名解析成 IP 再去匹配 IP 规则集,因此按域名访问的国内站(如字节、百度等)落到 `main-out` 走了代理,只有「裸 IP 访问国内 IP」才直连。
**修复方式**
数据路由改用 `action: resolve (prefer_ipv4)` + `geoip-cn → direct-out`——先解析域名再做 IP 匹配,由真实 IP 决定国内 / 国外(国内 IP → 直连,其余 → 代理);数据判定不再依赖 `geosite-cn` / `geosite-noncn` 域名规则(`geosite-cn` 仍用于 DNS 路由)。
对字节、百度等国内域名,以及苹果等全球跨国企业站点更稳更友好;默认使用公共 DNS(223.5.5.5)作为国内 DNS 以降低污染概率。
**可选增强 `cn_ip_fallback`**
启用后使用 1.14 原生 `evaluate` / `match_response`:对非 `geosite-cn` 域名,若 `main-dns` 返回国内 IP,再回退到 `china-dns` 解析。
> 这版块确实很难两全,改来改去也都存在缺憾,就这样不改了,照顾大多数为准。
## 新增:规则集自动更新(r5)
为 `bypass_mainland_china` 的三个远程规则集(`geoip-cn` / `geosite-cn` / `geosite-noncn`)增加 `update_interval: '24h'`,sing-box 每 24 小时自动重新拉取 `.srs`:
- 热替换,不中断既有连接;
- 下载失败时保留旧规则集,不影响分流。
## 新增:Snell 协议与 Hysteria2 增强(r9)
面向 sing-box 1.14 的一轮收敛:补齐新协议、暴露传输层新参数,并修复 1.14 DNS 迁移的一个行为缺陷。
| 领域 | 变更 |
| --- | --- |
| **Snell(1.14 新协议)** | 客户端出站 + 自建服务端 inbound 全支持(v4 / v6、psk、多用户 `userkey`、HTTP 混淆、v6 流量整形);订阅导入 `snell://` |
| **Hysteria2** | 新增 `disable_chrome_parrot`(Ed25519 证书服务器兼容开关)、`bbr_profile`、`hop_interval_max`(跳变间隔随机化)、`gecko` 混淆与 min/max 包长;服务端同步支持 gecko |
| **Hysteria v1** | 移除上游已废弃的 QUIC 流控调参(上游 1.16 将删除),回归 sing-box 默认 |
| **DNS** | 修正 1.14 自动迁移:legacy `ip_cidr` / `ip_is_private` 包装 `evaluate` 时继承原规则查询条件,不再对全部查询预解析 |
| **连接与交互** | TLS `handshake_timeout`;TUN `dns_mode` 缺省(hijack)与平台 DNS 劫持叠加的界面指引;规则集 merged / OR 匹配语义与 `query_type` 兼容性提示 |
> 设计取舍:Chrome QUIC 指纹(1.14 默认开启)仅在服务器证书为 Ed25519 等握手失败时才需关闭;Snell 采用 sing-box 官方实现(v5 无线协议视同 v4,不支持 v5 QUIC 代理模式)。
## 新增:规则分流与 TLS 能力补全(r10)
补齐 sing-box 1.14 在路由规则与规则集加载侧的剩余能力,并加固一处规则集配置的校验提示。
| 领域 | 变更 |
| --- | --- |
| **route `resolve`** | 新增 `disable_optimistic_cache`、`timeout`两个控件,乐观缓存增至四个选项 |
| **route / route-options** | 新增 `tls_spoof` / `tls_spoof_method`(注入伪造 ClientHello 干扰按 SNI 过滤的中间盒,需特权),LuCI 提供 SNI 与方式选择(默认 wrong-sequence) |
| **规则集加载** | 多 tag 时若 `path`/`url`/`initial_path` 缺少 `{tag}` 占位,生成器给出 `warn` 提示,配合 LuCI 表单校验与 sing-box 的硬拒绝(`missing {tag} placeholder`),三层防护避免误配 |
| **i18n** | 补齐新增文案的 zh_Hans 译文 |
## 工程化加固(r11)
一轮以可维护性与可验证性为目标的加固:重构不改变行为,测试锁住结果,经实机验证。
| 轮次 | 做了什么 | 结果 |
| --- | --- | --- |
| 重构和修复错误 | 重构:前端 TLS/传输表单收敛、generator 共享 TLS/transport 构建、`parse_uri` 拆为 13 个协议函数<br>测试:协议单测 153 条、generator 回归、LuCI 表单快照<br>修复:`executeCommand` 清理、dnsmasq 路径告警、fw4 清单单源、启动日志、PEM 校验、`wGET` 失败原因<br>工程:提高中文翻译率 CI、架构核实 | <br>parse_uri 对比零差异、generator JSON 逐字节一致、表单快照逐字段一致<br>顺带修复 3 个既有缺陷:证书上传、fw4 清理回滚、dnsmasq 路径 |
## 运行要求
- ImmortalWrt / OpenWrt ≥ 24.10+(apk 或 opkg 均可安装)
- sing-box ≥ 1.14.0(ImmortalWrt 25.12 源对应 sing-box 1.14.0-r1)
- 低于 1.14 时服务会拒绝启动并记录明确日志
<div align="center">
[![License](https://img.shields.io/badge/License-GPL--2.0--only-blue.svg)](LICENSE) 版权归 ImmortalWrt.org 与各贡献者
</div>
@@ -12,157 +12,21 @@
'require uci';
'require ui';
function pad2(value) {
value = (value == null) ? '' : String(value).trim();
return value.length === 1 ? '0' + value : value;
}
/* Shared wording for the TLS / transport form blocks. Keeping the strings here
makes node.js and server.js consume a single definition instead of two
copies that have to be edited in lockstep. */
const TRANSPORT_NONE_HINT = _('No TCP transport, plain HTTP is merged into the HTTP transport.');
const TRANSPORT_HTTP_HINT = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.');
const TRANSPORT_QUIC_HINT = _('No additional encryption support: It\'s basically duplicate encryption.');
function normalizeTimePart(value, min, max) {
value = parseInt(String(value || '').trim(), 10);
if (isNaN(value))
value = min;
value = Math.max(min, Math.min(max, value));
return pad2(value);
}
const HTTP_IDLE_HEALTH_CHECK_HINT = _('Specifies the period of time (in seconds) after which a health check will be performed using a ping frame if no frames have been received on the connection.<br/>' +
'Please note that a ping response is considered a received frame, so if there is no other traffic on the connection, the health check will be executed every interval.');
const HTTP_IDLE_GOAWAY_HINT = _('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.');
const HTTP_IDLE_KEEPALIVE_HINT = _('If the transport doesn\'t see any activity after a duration of this time (in seconds), it pings the client to check if the connection is still active.');
function parseCron(value) {
const matched = String(value || '').trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+([0-7*])$/);
if (!matched)
return null;
const minute = +matched[1],
hour = +matched[2];
if (minute < 0 || minute > 59 || hour < 0 || hour > 23)
return null;
return {
minute: normalizeTimePart(matched[1], 0, 59),
hour: normalizeTimePart(matched[2], 0, 23),
day: matched[3] === '*' ? '*' : (matched[3] === '7' ? '0' : matched[3])
};
}
function buildCron(day, hour, minute) {
return [
normalizeTimePart(minute, 0, 59),
normalizeTimePart(hour, 0, 23),
'*',
'*',
String(day || '*')
].join(' ');
}
function alignCronEditorRow(wrap) {
let field = wrap?.parentNode;
while (field && !field.classList?.contains('cbi-value-field'))
field = field.parentNode;
let row = field?.parentNode;
while (row && !row.classList?.contains('cbi-value'))
row = row.parentNode;
if (!row || !field)
return false;
if (wrap._dailyRow) {
wrap._dailyRow.style.display = row.style.display;
return true;
}
let dailyRow = E('div', { 'class': 'cbi-value homeproxy-cron-daily-row' }, [
E('div', { 'class': 'cbi-value-title' }, _('Update time (daily)')),
E('div', { 'class': 'cbi-value-field' }, [ wrap._dailyField ])
]);
wrap._dailyRow = dailyRow;
row.parentNode.insertBefore(dailyRow, row.nextSibling);
return true;
}
function watchCronEditorRow(wrap) {
const align = () => alignCronEditorRow(wrap);
[ 0, 50, 200, 500, 1000, 2000 ].forEach(delay => window.setTimeout(align, delay));
if (typeof MutationObserver !== 'undefined' && document?.body) {
const observer = new MutationObserver(() => {
if (align())
observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
window.setTimeout(() => observer.disconnect(), 3000);
}
}
function renderCronEditor(input) {
if (!input)
return null;
const parsed = parseCron(input.value);
input.type = 'text';
input.readOnly = true;
input.tabIndex = -1;
input.style.position = 'absolute';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
input.style.width = '1px';
input.style.height = '1px';
input.style.minWidth = '1px';
input.style.maxWidth = '1px';
input.style.padding = '0';
input.style.border = '0';
let day = E('select', { 'class': 'cbi-input-select', 'style': 'width: 12em !important; min-width: 12em !important; max-width: 12em !important; box-sizing: border-box;' }),
hour = E('select', { 'class': 'cbi-input-select', 'style': 'width: 4.25em !important; min-width: 4.25em !important; max-width: 4.25em !important; box-sizing: border-box;' }),
minute = E('select', { 'class': 'cbi-input-select', 'style': 'width: 4.25em !important; min-width: 4.25em !important; max-width: 4.25em !important; box-sizing: border-box;' }),
dailyField = E('div', { 'style': 'display: flex; align-items: center; gap: .25em; width: 9.5em;' }, [
hour,
E('span', ':'),
minute
]),
wrap = E('div', { 'class': 'homeproxy-cron-editor' }, [ day ]);
wrap._dailyField = dailyField;
for (let i = 0; i < 24; i++)
hour.appendChild(E('option', { value: pad2(i) }, pad2(i)));
for (let i = 0; i < 60; i++)
minute.appendChild(E('option', { value: pad2(i) }, pad2(i)));
[
['*', _('Every day')],
['1', _('Every Monday')],
['2', _('Every Tuesday')],
['3', _('Every Wednesday')],
['4', _('Every Thursday')],
['5', _('Every Friday')],
['6', _('Every Saturday')],
['0', _('Every Sunday')]
].forEach(([value, label]) => day.appendChild(E('option', { value }, label)));
day.value = parsed ? parsed.day : '*';
hour.value = parsed ? parsed.hour : '00';
minute.value = parsed ? parsed.minute : '00';
input.value = buildCron(day.value, hour.value, minute.value);
input.setAttribute('value', input.value);
const sync = () => {
input.value = buildCron(day.value, hour.value, minute.value);
input.setAttribute('value', input.value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
};
day.addEventListener('change', sync);
hour.addEventListener('change', sync);
minute.addEventListener('change', sync);
wrap.appendChild(input);
window.setTimeout(() => watchCronEditorRow(wrap), 0);
return wrap;
}
const HTTP_PING_HEALTH_CHECK_HINT = _('Specifies the timeout duration (in seconds) after sending a PING frame, within which a response must be received.<br/>' +
'If a response to the PING frame is not received within the specified timeout duration, the connection will be closed.');
const HTTP_PING_KEEPALIVE_HINT = _('The timeout (in seconds) that after performing a keepalive check, the client will wait for activity. If no activity is detected, the connection will be closed.');
return baseclass.extend({
dns_strategy: {
@@ -238,28 +102,225 @@ return baseclass.extend({
}
}),
CBIMultiValue: form.MultiValue.extend({
__name__: 'CBI.HomeProxyMultiValue',
/* Build the transport option group shared by the node (client) and server
forms. `options.side` selects the client/server wording and the handful of
fields that only exist on one side; option names, defaults and dependency
sets are otherwise identical. */
renderTransportOptions(section, options) {
const features = options.features || {},
side = options.side || 'client';
let o;
renderWidget(section_id, _option_index, cfgvalue) {
let value = (cfgvalue != null) ? cfgvalue : this.default,
choices = this.transformChoices() || {},
widget = new ui.Dropdown(L.toArray(value), choices, {
id: this.cbid(section_id),
sort: this.keylist,
multiple: true,
optional: this.optional || this.rmempty,
select_placeholder: this.placeholder,
create: this.create,
display_items: this.display_size ?? this.size ?? 3,
dropdown_items: this.dropdown_size ?? this.size ?? -1,
validate: this.getValidator(section_id),
disabled: (this.readonly != null) ? this.readonly : this.map.readonly
});
o = section.option(form.ListValue, 'transport', _('Transport'), TRANSPORT_NONE_HINT);
o.value('', _('None'));
o.value('grpc', _('gRPC'));
o.value('http', _('HTTP'));
o.value('httpupgrade', _('HTTPUpgrade'));
o.value('quic', _('QUIC'));
o.value('ws', _('WebSocket'));
o.depends('type', 'trojan');
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.onchange = function(ev, section_id, value) {
let desc = this.map.findElement('id', 'cbid.homeproxy.%s.transport'.format(section_id)).nextElementSibling;
if (value === 'http')
desc.innerHTML = TRANSPORT_HTTP_HINT;
else if (value === 'quic')
desc.innerHTML = TRANSPORT_QUIC_HINT;
else
desc.innerHTML = TRANSPORT_NONE_HINT;
return widget.render();
let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
if ((value === 'http' && tls.checked) || (value === 'grpc' && !features.with_grpc)) {
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
(side === 'server') ? HTTP_IDLE_GOAWAY_HINT : HTTP_IDLE_HEALTH_CHECK_HINT;
if (side !== 'server')
this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML =
HTTP_PING_HEALTH_CHECK_HINT;
} else if (value === 'grpc' && features.with_grpc) {
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
HTTP_IDLE_KEEPALIVE_HINT;
if (side !== 'server')
this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML =
HTTP_PING_KEEPALIVE_HINT;
}
}
}),
o.modalonly = true;
o = section.option(form.Value, 'grpc_servicename', _('gRPC service name'));
o.depends('transport', 'grpc');
o.modalonly = true;
if (side !== 'server' && features.with_grpc) {
o = section.option(form.Flag, 'grpc_permit_without_stream', _('gRPC permit without stream'),
_('If enabled, the client transport sends keepalive pings even with no active connections.'));
o.depends('transport', 'grpc');
o.modalonly = true;
}
o = section.option(form.DynamicList, 'http_host', _('Host'));
o.datatype = 'hostname';
o.depends('transport', 'http');
o.modalonly = true;
o = section.option(form.Value, 'httpupgrade_host', _('Host'));
o.datatype = 'hostname';
o.depends('transport', 'httpupgrade');
o.modalonly = true;
o = section.option(form.Value, 'http_path', _('Path'));
o.depends('transport', 'http');
o.depends('transport', 'httpupgrade');
o.modalonly = true;
o = section.option(form.Value, 'http_method', _('Method'));
if (side !== 'server') {
o.value('GET', _('GET'));
o.value('PUT', _('PUT'));
}
o.depends('transport', 'http');
o.modalonly = true;
o = section.option(form.Value, 'http_idle_timeout', _('Idle timeout'),
(side === 'server') ? HTTP_IDLE_GOAWAY_HINT : HTTP_IDLE_HEALTH_CHECK_HINT);
o.datatype = 'uinteger';
o.depends('transport', 'grpc');
o.depends({'transport': 'http', 'tls': '1'});
o.modalonly = true;
if (side !== 'server' || features.with_grpc) {
o = section.option(form.Value, 'http_ping_timeout', _('Ping timeout'),
(side === 'server') ? HTTP_PING_KEEPALIVE_HINT : HTTP_PING_HEALTH_CHECK_HINT);
o.datatype = 'uinteger';
o.depends('transport', 'grpc');
if (side !== 'server')
o.depends({'transport': 'http', 'tls': '1'});
o.modalonly = true;
}
o = section.option(form.Value, 'ws_host', _('Host'));
o.depends('transport', 'ws');
o.modalonly = true;
o = section.option(form.Value, 'ws_path', _('Path'));
o.depends('transport', 'ws');
o.modalonly = true;
o = section.option(form.Value, 'websocket_early_data', _('Early data'),
_('Allowed payload size is in the request.'));
o.datatype = 'uinteger';
o.value('2048');
o.depends('transport', 'ws');
o.modalonly = true;
o = section.option(form.Value, 'websocket_early_data_header', _('Early data header name'),
(side === 'server') ? (_('Early data is sent in path instead of header by default.') +
'<br/>' +
_('To be compatible with Xray-core, set this to <code>Sec-WebSocket-Protocol</code>.')) : undefined);
o.value('Sec-WebSocket-Protocol');
o.depends('transport', 'ws');
o.modalonly = true;
if (side !== 'server') {
o = section.option(form.ListValue, 'packet_encoding', _('Packet encoding'));
o.value('', _('none'));
o.value('packetaddr', _('packet addr (v2ray-core v5+)'));
o.value('xudp', _('Xudp (Xray-core)'));
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.modalonly = true;
}
return o;
},
/* Build the TLS option group shared by the node (client) and server forms.
`options.type_depends` lists the node types the TLS flag applies to,
`options.tls_forced_types` the types that force TLS on, and
`options.oninsecurechange` the client-side confirm handler. */
renderTlsOptions(section, options) {
const side = options.side || 'client';
let o;
o = section.option(form.Flag, 'tls', _('TLS'));
for (let t of (options.type_depends || []))
o.depends('type', t);
if (side === 'server')
o.rmempty = false;
o.validate = function(section_id, _value) {
if (section_id) {
let type = this.map.lookupOption('type', section_id)[0].formvalue(section_id);
let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
if ((options.tls_forced_types || []).includes(type)) {
tls.checked = true;
tls.disabled = true;
} else {
tls.disabled = null;
}
}
return true;
}
o.modalonly = true;
o = section.option(form.Value, 'tls_sni', _('TLS SNI'),
_('Used to verify the hostname on the returned certificates unless insecure is given.'));
o.depends('tls', '1');
o.modalonly = true;
o = section.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'),
_('List of supported application level protocols, in order of preference.'));
o.depends('tls', '1');
o.modalonly = true;
if (side !== 'server') {
o = section.option(form.Flag, 'tls_insecure', _('Allow insecure'),
_('Allow insecure connection at TLS client.') +
'<br/>' +
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
o.depends('tls', '1');
o.onchange = options.oninsecurechange;
o.modalonly = true;
}
o = section.option(form.ListValue, 'tls_min_version', _('Minimum TLS version'),
_('The minimum TLS version that is acceptable.'));
o.value('', _('default'));
for (let i of this.tls_versions)
o.value(i);
o.depends('tls', '1');
o.modalonly = true;
o = section.option(form.ListValue, 'tls_max_version', _('Maximum TLS version'),
_('The maximum TLS version that is acceptable.'));
o.value('', _('default'));
for (let i of this.tls_versions)
o.value(i);
o.depends('tls', '1');
o.modalonly = true;
o = section.option(this.CBIStaticList, 'tls_cipher_suites', _('Cipher suites'),
_('The elliptic curves that will be used in an ECDHE handshake, in preference order. If empty, the default will be used.'));
for (let i of this.tls_cipher_suites)
o.value(i);
o.depends('tls', '1');
o.optional = true;
o.modalonly = true;
if (side !== 'server') {
o = section.option(form.Value, 'tls_handshake_timeout', _('Handshake timeout (1.14)'),
_('TLS handshake timeout in seconds. 15s is used by default.'));
o.datatype = 'uinteger';
o.placeholder = '15';
o.depends('tls', '1');
o.modalonly = true;
}
return o;
},
calcStringMD5(e) {
/* Thanks to https://stackoverflow.com/a/41602636 */
@@ -360,49 +421,6 @@ return baseclass.extend({
return L.resolveDefault(callGetSingBoxFeatures(), {});
},
renderCronSelector(/* ... */) {
if (!this._homeproxyCronOriginalFormvalue) {
this._homeproxyCronOriginalFormvalue = this.formvalue;
this.formvalue = function(section_id) {
let cbid = this.cbid(section_id),
ids = [ cbid, 'widget.' + cbid ],
roots = [ document.getElementById('modal_overlay'), document ],
input = null;
for (let root of roots) {
if (!root)
continue;
for (let el of root.querySelectorAll('input')) {
if (ids.includes(el.id) || ids.includes(el.name)) {
input = el;
break;
}
}
if (input)
break;
}
let value = input?.value ||
this._homeproxyCronOriginalFormvalue?.call(this, section_id) ||
this.default ||
'0 0 * * *';
return parseCron(value) ? value : (this.default || '0 0 * * *');
};
}
let node = form.Value.prototype.renderWidget.apply(this, arguments),
editor = renderCronEditor(node.querySelector('input'));
return editor || node;
},
renderCronSelectorRow(/* ... */) {
return form.Value.prototype.render.apply(this, arguments);
},
generateRand(type, length) {
let byteArr;
if (['base64', 'hex'].includes(type))
@@ -426,9 +444,6 @@ return baseclass.extend({
},
loadDefaultLabel(uciconfig, ucisection) {
if (!ucisection)
return '';
let label = uci.get(uciconfig, ucisection, 'label');
if (label) {
return label;
@@ -439,127 +454,32 @@ return baseclass.extend({
},
loadModalTitle(title, addtitle, uciconfig, ucisection) {
if (!ucisection)
return addtitle;
let label = uci.get(uciconfig, ucisection, 'label');
return label ? title + ' » ' + label : addtitle;
},
normalizeSectionId(label, prefix) {
let section_id = (label || '').trim().replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
return (prefix || 'cfg') + '_' + (section_id || 'section');
},
renderSectionAdd(section, extra_class) {
let el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]),
nameEl = el.querySelector('.cbi-section-create-name'),
button = el.querySelector('.cbi-section-create > .cbi-button-add'),
uciconfig = section.uciconfig || section.map.config,
sectiontype = section.sectiontype,
labelEl = E('input', {
'type': 'text',
'class': nameEl.className
}),
hintEl = E('div', { 'class': 'cbi-value-description' });
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, (v) => {
let button = el.querySelector('.cbi-section-create > .cbi-button-add');
let uciconfig = section.uciconfig || section.map.config;
nameEl.style.display = 'none';
nameEl.parentNode.insertBefore(labelEl, nameEl);
nameEl.parentNode.appendChild(hintEl);
button.disabled = true;
const syncSectionName = () => {
let label = (labelEl.value || '').trim();
hintEl.textContent = '';
if (!label) {
nameEl.value = '';
if (!v) {
button.disabled = true;
return;
}
let duplicate = false;
uci.sections(uciconfig, sectiontype, (res) => {
if ((res.label || res['.name']) === label)
duplicate = true;
});
if (duplicate) {
nameEl.value = '';
return true;
} else if (uci.get(uciconfig, v)) {
button.disabled = true;
hintEl.textContent = _('Expecting: %s').format(_('unique value'));
return;
return _('Expecting: %s').format(_('unique UCI identifier'));
} else {
button.disabled = null;
return true;
}
let normalized = (label || '').trim().replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '') || 'section',
section_id = (sectiontype || 'cfg') + '_' + normalized,
suffix = 1;
while (uci.get(uciconfig, section_id))
section_id = (sectiontype || 'cfg') + '_' + normalized + '_' + suffix++;
nameEl.value = section_id;
nameEl.dataset.sectionId = section_id;
nameEl.dataset.sectionLabel = label;
button.disabled = null;
};
labelEl.addEventListener('input', syncSectionName);
labelEl.addEventListener('blur', syncSectionName);
button.addEventListener('click', () => {
syncSectionName();
let label = nameEl.dataset.sectionLabel,
section_id = nameEl.dataset.sectionId;
window.setTimeout(() => {
if (label && section_id && uci.get(uciconfig, section_id))
uci.set(uciconfig, section_id, 'label', label);
}, 0);
});
}, 'blur', 'keyup');
return el;
},
installCloseButtonText() {
if (window.__homeproxyCloseButtonText)
return;
window.__homeproxyCloseButtonText = true;
let fix = (root) => {
for (let el of (root || document).querySelectorAll('button, .btn, input[type="button"], input[type="submit"]')) {
let text = (el.textContent || el.value || '').trim();
if (text !== 'Dismiss' && text !== '\u5ffd\u7565')
continue;
if ('value' in el && !el.textContent.trim())
el.value = _('Close');
else
el.textContent = _('Close');
}
};
let observe = () => {
if (!document.body)
return;
fix(document);
new MutationObserver((mutations) => {
for (let mutation of mutations)
for (let node of mutation.addedNodes)
if (node.nodeType === 1)
fix(node);
fix(document);
}).observe(document.body, { childList: true, subtree: true });
};
if (document.body)
observe();
else
document.addEventListener('DOMContentLoaded', observe, { once: true });
},
uploadCertificate(_option, type, filename, ev) {
const callWriteCertificate = rpc.declare({
object: 'luci.homeproxy',
@@ -580,44 +500,6 @@ return baseclass.extend({
.catch((e) => { ui.addNotification(null, E('p', e.message)) });
},
uploadPanel(_option, ev) {
const callInstallPanel = rpc.declare({
object: 'luci.homeproxy',
method: 'clash_api_install_panel',
expect: { '': {} }
});
const normalizeMessage = (message) => {
switch (message) {
case 'panel_backup_failed':
return _('Backup panel failed.');
case 'panel_restore_failed':
return _('Restore panel failed.');
case 'empty_panel_zip':
return _('Panel ZIP file is empty.');
case 'unzip_unavailable':
return _('The unzip command is unavailable.');
case 'invalid_panel_zip':
return _('Invalid panel ZIP file.');
case 'panel_install_failed':
return _('Install panel failed.');
default:
return message || _('unknown error');
}
};
return ui.uploadFile('/tmp/homeproxy_panel.tmp', ev.target)
.then(L.bind((_btn, res) => {
return L.resolveDefault(callInstallPanel(), {}).then((ret) => {
if (ret.result === true)
ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(_('panel ZIP package'), res.size)), 'info');
else
ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(_('panel ZIP package'), normalizeMessage(ret.error))), 'danger');
});
}, this, ev.target))
.catch((e) => { ui.addNotification(null, E('p', e.message || e), 'danger') });
},
validateBase64Key(length, section_id, value) {
/* Thanks to luci-proto-wireguard */
if (section_id && value)
@@ -644,7 +526,8 @@ return baseclass.extend({
else if (!value[2])
value[2] = 65535;
if (value[1] < value[2] && value[2] <= 65535)
/* numeric comparison: string '<' does lexicographic ordering */
if (parseInt(value[1], 10) < parseInt(value[2], 10) && value[2] <= 65535)
return true;
}
@@ -658,7 +541,7 @@ return baseclass.extend({
if (section_id) {
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
if (ucioption === 'node' && (value === 'urltest' || value === 'selector'))
if (ucioption === 'node' && value === 'urltest')
return true;
let duplicate = false;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -126,8 +126,6 @@ return view.extend({
let m, s, o;
let features = data[1];
hp.installCloseButtonText();
m = new form.Map('homeproxy', _('HomeProxy Server'),
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
@@ -184,13 +182,13 @@ return view.extend({
}
o.value('mixed', _('Mixed'));
o.value('shadowsocks', _('Shadowsocks'));
o.value('snell', _('Snell (1.14)'));
o.value('socks', _('Socks'));
o.value('trojan', _('Trojan'));
if (features.with_quic)
o.value('tuic', _('Tuic'));
o.value('vless', _('VLESS'));
o.value('vmess', _('VMess'));
o.default = 'mixed';
o.rmempty = false;
o = s.option(form.Value, 'address', _('Listen address'));
@@ -216,12 +214,13 @@ return view.extend({
o.depends({'type': /^(http|mixed|naive|socks)$/, 'username': /[\s\S]/});
o.depends('type', 'hysteria2');
o.depends('type', 'shadowsocks');
o.depends('type', 'snell');
o.depends('type', 'trojan');
o.depends('type', 'tuic');
o.validate = function(section_id, value) {
if (section_id) {
let type = this.section.formvalue(section_id, 'type');
let required_type = [ 'anytls', 'http', 'mixed', 'naive', 'socks', 'shadowsocks', 'trojan' ];
let required_type = [ 'anytls', 'http', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan' ];
if (required_type.includes(type)) {
if (type === 'shadowsocks') {
@@ -291,6 +290,7 @@ return view.extend({
o = s.option(form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
o.value('', _('Disable'));
o.value('salamander', _('Salamander'));
o.value('gecko', _('Gecko (1.14)'));
o.depends('type', 'hysteria2');
o.modalonly = true;
@@ -300,30 +300,18 @@ return view.extend({
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': /[\s\S]/});
o.modalonly = true;
o = s.option(form.Value, 'hysteria_recv_window_conn', _('QUIC stream receive window'),
_('The QUIC stream-level flow control window for receiving data.'));
o = s.option(form.Value, 'hysteria_obfs_min_packet_size', _('Min obfs packet size (1.14)'),
_('Minimum on-wire packet size in bytes. Gecko only.'));
o.datatype = 'uinteger';
o.default = '67108864';
o.depends('type', 'hysteria');
o.placeholder = '512';
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': 'gecko'});
o.modalonly = true;
o = s.option(form.Value, 'hysteria_recv_window_client', _('QUIC connection receive window'),
_('The QUIC connection-level flow control window for receiving data.'));
o = s.option(form.Value, 'hysteria_obfs_max_packet_size', _('Max obfs packet size (1.14)'),
_('Maximum on-wire packet size in bytes. Gecko only.'));
o.datatype = 'uinteger';
o.default = '15728640';
o.depends('type', 'hysteria');
o.modalonly = true;
o = s.option(form.Value, 'hysteria_max_conn_client', _('QUIC maximum concurrent bidirectional streams'),
_('The maximum number of QUIC concurrent bidirectional streams that a peer is allowed to open.'));
o.datatype = 'uinteger';
o.default = '1024';
o.depends('type', 'hysteria');
o.modalonly = true;
o = s.option(form.Flag, 'hysteria_disable_mtu_discovery', _('Disable Path MTU discovery'),
_('Disables Path MTU Discovery (RFC 8899). Packets will then be at most 1252 (IPv4) / 1232 (IPv6) bytes in size.'));
o.depends('type', 'hysteria');
o.placeholder = '1200';
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': 'gecko'});
o.modalonly = true;
o = s.option(form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
@@ -337,6 +325,31 @@ return view.extend({
o.modalonly = true;
/* Hysteria (2) config end */
/* Snell config start */
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
_('The pre-shared key (password) above must be 12-255 bytes for v6.'));
o.value('5', _('v5'));
o.value('6', _('v6'));
o.default = '5';
o.depends('type', 'snell');
o.modalonly = true;
o = s.option(form.ListValue, 'snell_obfs_mode', _('Obfuscation mode'),
_('HTTP obfuscation. v5 only.'));
o.value('', _('none'));
o.value('http', _('http'));
o.depends({'type': 'snell', 'snell_version': '5'});
o.modalonly = true;
o = s.option(form.ListValue, 'snell_mode', _('Traffic shaping mode'),
_('v6 only.'));
o.value('', _('default'));
o.value('unshaped', _('unshaped'));
o.value('unsafe-raw', _('unsafe-raw'));
o.depends({'type': 'snell', 'snell_version': '6'});
o.modalonly = true;
/* Snell config end */
/* Shadowsocks config */
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
for (let i of hp.shadowsocks_encrypt_methods)
@@ -399,104 +412,7 @@ return view.extend({
/* VMess config end */
/* Transport config start */
o = s.option(form.ListValue, 'transport', _('Transport'),
_('No TCP transport, plain HTTP is merged into the HTTP transport.'));
o.value('', _('None'));
o.value('grpc', _('gRPC'));
o.value('http', _('HTTP'));
o.value('httpupgrade', _('HTTPUpgrade'));
o.value('quic', _('QUIC'));
o.value('ws', _('WebSocket'));
o.depends('type', 'trojan');
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.onchange = function(ev, section_id, value) {
let desc = this.map.findElement('id', 'cbid.homeproxy.%s.transport'.format(section_id)).nextElementSibling;
if (value === 'http')
desc.innerHTML = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.');
else if (value === 'quic')
desc.innerHTML = _('No additional encryption support: It\'s basically duplicate encryption.');
else
desc.innerHTML = _('No TCP transport, plain HTTP is merged into the HTTP transport.');
let tls_element = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
if ((value === 'http' && tls_element.checked) || (value === 'grpc' && !features.with_grpc))
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
_('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.');
else if (value === 'grpc' && features.with_grpc)
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
_('If the transport doesn\'t see any activity after a duration of this time (in seconds), it pings the client to check if the connection is still active.');
}
o.modalonly = true;
/* gRPC config start */
o = s.option(form.Value, 'grpc_servicename', _('gRPC service name'));
o.depends('transport', 'grpc');
o.modalonly = true;
/* gRPC config end */
/* HTTP(Upgrade) config start */
o = s.option(form.DynamicList, 'http_host', _('Host'));
o.datatype = 'hostname';
o.depends('transport', 'http');
o.modalonly = true;
o = s.option(form.Value, 'httpupgrade_host', _('Host'));
o.datatype = 'hostname';
o.depends('transport', 'httpupgrade');
o.modalonly = true;
o = s.option(form.Value, 'http_path', _('Path'));
o.depends('transport', 'http');
o.depends('transport', 'httpupgrade');
o.modalonly = true;
o = s.option(form.Value, 'http_method', _('Method'));
o.depends('transport', 'http');
o.modalonly = true;
o = s.option(form.Value, 'http_idle_timeout', _('Idle timeout'),
_('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.'));
o.datatype = 'uinteger';
o.depends('transport', 'grpc');
o.depends({'transport': 'http', 'tls': '1'});
o.modalonly = true;
if (features.with_grpc) {
o = s.option(form.Value, 'http_ping_timeout', _('Ping timeout'),
_('The timeout (in seconds) that after performing a keepalive check, the client will wait for activity. If no activity is detected, the connection will be closed.'));
o.datatype = 'uinteger';
o.depends('transport', 'grpc');
o.modalonly = true;
}
/* HTTP config end */
/* WebSocket config start */
o = s.option(form.Value, 'ws_host', _('Host'));
o.depends('transport', 'ws');
o.modalonly = true;
o = s.option(form.Value, 'ws_path', _('Path'));
o.depends('transport', 'ws');
o.modalonly = true;
o = s.option(form.Value, 'websocket_early_data', _('Early data'),
_('Allowed payload size is in the request.'));
o.datatype = 'uinteger';
o.value('2048');
o.depends('transport', 'ws');
o.modalonly = true;
o = s.option(form.Value, 'websocket_early_data_header', _('Early data header name'),
_('Early data is sent in path instead of header by default.') +
'<br/>' +
_('To be compatible with Xray-core, set this to <code>Sec-WebSocket-Protocol</code>.'));
o.value('Sec-WebSocket-Protocol');
o.depends('transport', 'ws');
o.modalonly = true;
/* WebSocket config end */
hp.renderTransportOptions(s, { features: features, side: 'server' });
/* Transport config end */
/* Mux config start */
@@ -532,67 +448,11 @@ return view.extend({
/* Mux config end */
/* TLS config start */
o = s.option(form.Flag, 'tls', _('TLS'));
o.depends('type', 'anytls');
o.depends('type', 'http');
o.depends('type', 'hysteria');
o.depends('type', 'hysteria2');
o.depends('type', 'naive');
o.depends('type', 'trojan');
o.depends('type', 'tuic');
o.depends('type', 'vless');
o.depends('type', 'vmess');
o.rmempty = false;
o.validate = function(section_id, value) {
if (section_id) {
let type = this.map.lookupOption('type', section_id)[0].formvalue(section_id);
let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
if (['hysteria', 'hysteria2', 'tuic'].includes(type)) {
tls.checked = true;
tls.disabled = true;
} else {
tls.disabled = null;
}
}
return true;
}
o.modalonly = true;
o = s.option(form.Value, 'tls_sni', _('TLS SNI'),
_('Used to verify the hostname on the returned certificates unless insecure is given.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'),
_('List of supported application level protocols, in order of preference.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.ListValue, 'tls_min_version', _('Minimum TLS version'),
_('The minimum TLS version that is acceptable.'));
o.value('', _('default'));
for (let i of hp.tls_versions)
o.value(i);
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.ListValue, 'tls_max_version', _('Maximum TLS version'),
_('The maximum TLS version that is acceptable.'));
o.value('', _('default'));
for (let i of hp.tls_versions)
o.value(i);
o.depends('tls', '1');
o.modalonly = true;
o = s.option(hp.CBIStaticList, 'tls_cipher_suites', _('Cipher suites'),
_('The elliptic curves that will be used in an ECDHE handshake, in preference order. If empty, the default will be used.'));
for (let i of hp.tls_cipher_suites)
o.value(i);
o.depends('tls', '1');
o.optional = true;
o.modalonly = true;
hp.renderTlsOptions(s, {
side: 'server',
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'trojan', 'tuic', 'vless', 'vmess' ],
tls_forced_types: [ 'hysteria', 'hysteria2', 'tuic' ]
});
if (features.with_acme) {
o = s.option(form.Flag, 'tls_acme', _('Enable ACME'),
@@ -627,6 +487,29 @@ return view.extend({
}
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_key_type', _('ACME key type'),
_('Private key type for new certificates (1.14). Empty uses the default.'));
o.value('ed25519');
o.value('p256');
o.value('p384');
o.value('rsa2048');
o.value('rsa4096');
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_profile', _('ACME profile'),
_('ACME profile for certificate issuance (1.14). Example: shortlived.'));
o.placeholder = 'shortlived';
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.TextValue, 'tls_acme_account_key', _('ACME account key'),
_('PEM-encoded private key of an existing ACME account (1.14).'));
o.monospace = true;
o.rows = 3;
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_provider', _('CA provider'),
_('The ACME CA provider to use.'));
o.value('letsencrypt', _('Let\'s Encrypt'));
@@ -640,10 +523,10 @@ return view.extend({
o.modalonly = true;
o = s.option(form.ListValue, 'tls_dns01_provider', _('DNS provider'));
o.value('alidns', _('AliYun'));
o.value('cfdns', _('CloudFlare'));
o.value('alidns', _('Alibaba Cloud DNS'));
o.value('cloudflare', _('Cloudflare'));
o.depends('tls_dns01_challenge', '1');
o.default = 'cfdns';
o.default = 'cloudflare';
o.rmempty = false;
o.modalonly = true;
@@ -666,7 +549,7 @@ return view.extend({
o = s.option(form.Value, 'tls_dns01_cf_api_token', _('API token'));
o.password = true;
o.depends('tls_dns01_provider', 'cfdns');
o.depends('tls_dns01_provider', 'cloudflare');
o.rmempty = false;
o.modalonly = true;
@@ -14,8 +14,6 @@
'require ui';
'require view';
'require homeproxy as hp';
/* Thanks to luci-app-aria2 */
const css = ' \
#log_textarea { \
@@ -29,53 +27,6 @@ const css = ' \
} \
.description { \
background-color: #33ccff; \
} \
.homeproxy-connect-grid { \
display: grid; \
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));\
gap: 12px; \
} \
.homeproxy-connect-card { \
display: grid; \
grid-template-columns: 44px 1fr; \
gap: 10px; \
align-items: center; \
min-height: 64px; \
padding: 8px 10px; \
border-radius: 6px; \
background: #fff; \
box-shadow: 0 1px 4px rgba(0, 0, 0, .08);\
} \
.homeproxy-connect-icon { \
display: flex; \
align-items: center; \
justify-content: center; \
width: 38px; \
height: 38px; \
border-radius: 50%; \
color: #fff; \
font-weight: 700; \
font-size: 15px; \
} \
.homeproxy-connect-info { \
min-height: 42px; \
padding: 7px 12px; \
border-radius: 8px; \
background: #e9edf2; \
} \
.homeproxy-connect-title { \
color: #7d8aa0; \
font-weight: 700; \
font-size: 13px; \
line-height: 1.2; \
} \
.homeproxy-connect-result { \
margin-top: 2px; \
color: #ff4d35; \
font-weight: 700; \
font-size: 13px; \
cursor: pointer; \
line-height: 1.2; \
}';
const hp_dir = '/var/run/homeproxy';
@@ -93,13 +44,13 @@ function getConnStat(o, site) {
'class': 'btn cbi-button cbi-button-action',
'click': ui.createHandlerFn(this, () => {
return L.resolveDefault(callConnStat(site), {}).then((ret) => {
let ele = o.default.firstElementChild.nextElementSibling;
let ele = o.default.firstElementChild.nextElementSibling;
if (ret.result) {
ele.style.setProperty('color', 'green');
ele.innerHTML = _('passed');
ele.innerHTML = _('passed');
} else {
ele.style.setProperty('color', 'red');
ele.innerHTML = _('failed');
ele.innerHTML = _('failed');
}
});
})
@@ -109,53 +60,6 @@ function getConnStat(o, site) {
]);
}
function renderConnCards() {
const callConnStat = rpc.declare({
object: 'luci.homeproxy',
method: 'connection_check',
params: ['site'],
expect: { '': {} }
});
const items = [
{ site: 'baidu', title: _('Baidu Connection'), icon: 'B', color: '#ff7a00' },
{ site: 'google', title: _('Google Connection'), icon: 'G', color: '#4285f4' },
{ site: 'github', title: _('GitHub Connection'), icon: 'GH', color: '#7b3bb3' },
{ site: 'youtube', title: _('YouTube Connection'), icon: 'YT', color: '#ff0033' }
];
return E('div', { 'class': 'homeproxy-connect-grid' }, items.map((item) => {
let result = E('div', {
'class': 'homeproxy-connect-result',
'click': ui.createHandlerFn(this, () => {
result.textContent = _('Checking...');
result.style.setProperty('color', '#f0ad4e');
return L.resolveDefault(callConnStat(item.site), {}).then((ret) => {
if (ret.result && ret.latency != null) {
result.style.setProperty('color', 'green');
result.textContent = _('%s ms').format(ret.latency);
} else {
result.style.setProperty('color', '#ff4d35');
result.textContent = _('failed');
}
});
})
}, [ _('Click to check') ]);
return E('div', { 'class': 'homeproxy-connect-card' }, [
E('div', {
'class': 'homeproxy-connect-icon',
'style': 'background:%s'.format(item.color)
}, [ item.icon ]),
E('div', { 'class': 'homeproxy-connect-info' }, [
E('div', { 'class': 'homeproxy-connect-title' }, [ item.title ]),
result
])
]);
}));
}
function getResVersion(o, type) {
const callResVersion = rpc.declare({
object: 'luci.homeproxy',
@@ -323,16 +227,16 @@ return view.extend({
render() {
let m, s, o;
hp.installCloseButtonText();
m = new form.Map('homeproxy');
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Connection check'));
s.anonymous = true;
o = s.option(form.DummyValue, '_connection_check');
o.rawhtml = true;
o.render = renderConnCards;
o = s.option(form.DummyValue, '_check_baidu', _('BaiDu'));
o.cfgvalue = L.bind(getConnStat, this, o, 'baidu');
o = s.option(form.DummyValue, '_check_google', _('Google'));
o.cfgvalue = L.bind(getConnStat, this, o, 'google');
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
s.anonymous = true;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+24 -32
View File
@@ -8,7 +8,6 @@ config homeproxy 'infra'
option dns_port '5333'
option dns_redirect '1'
option ntp_server 'nil'
option sniff_override '1'
option udp_timeout ''
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
@@ -25,68 +24,61 @@ config homeproxy 'migration'
config homeproxy 'config'
option main_node 'nil'
option main_udp_node 'same'
option dns_server 'https://dns.quad9.net/dns-query'
option china_dns_server 'https://dns.alidns.com/dns-query'
option dns_server '8.8.8.8'
option china_dns_server '223.5.5.5'
option routing_mode 'bypass_mainland_china'
option routing_port 'common'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option github_token ''
option log_level 'warn'
option cn_ip_fallback '0'
option tun_dns_mode ''
option udp_mapping ''
option udp_filtering ''
option udp_nat_max ''
config homeproxy 'control'
option lan_proxy_mode 'disabled'
list wan_proxy_ipv4_ips '91.105.192.0/23'
list wan_proxy_ipv4_ips '91.108.4.0/22'
list wan_proxy_ipv4_ips '91.108.8.0/21'
list wan_proxy_ipv4_ips '91.108.16.0/21'
list wan_proxy_ipv4_ips '91.108.8.0/22'
list wan_proxy_ipv4_ips '91.108.16.0/22'
list wan_proxy_ipv4_ips '91.108.12.0/22'
list wan_proxy_ipv4_ips '91.108.20.0/22'
list wan_proxy_ipv4_ips '91.108.56.0/22'
list wan_proxy_ipv4_ips '95.161.64.0/20'
list wan_proxy_ipv4_ips '149.154.160.0/20'
list wan_proxy_ipv4_ips '185.76.151.0/24'
list wan_proxy_ipv4_ips '203.208.50.66/32'
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
list wan_proxy_ipv6_ips '2001:b28:f23c::/47'
list wan_proxy_ipv6_ips '2001:b28:f23c::/48'
list wan_proxy_ipv6_ips '2001:b28:f23d::/48'
list wan_proxy_ipv6_ips '2001:b28:f23f::/48'
list wan_proxy_ipv6_ips '2a0a:f280::/32'
config homeproxy 'clash_api'
option external_ui '/etc/homeproxy/run/ui'
option external_ui_download_url 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip'
option external_ui_download_detour 'direct-out'
option external_controller '0.0.0.0:9095'
option secret 'homeproxy'
option default_mode 'rule'
config homeproxy 'ntp'
option enabled '1'
option server 'ntp.aliyun.com'
option server_port '123'
option interval '30m'
config homeproxy 'cache'
option enabled '1'
option path '/etc/homeproxy/cache.db'
option store_rdrc '1'
config homeproxy 'routing'
option sniff_override '1'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
option find_neighbor '0'
config homeproxy 'dns'
option dns_strategy 'prefer_ipv4'
option default_server 'local-dns'
option default_strategy 'prefer_ipv4'
option default_server 'default-dns'
option disable_cache '0'
option disable_cache_expire '0'
option optimistic_cache '0'
option optimistic_timeout ''
option dns_timeout ''
option cache_file_store_dns '0'
config homeproxy 'subscription'
option auto_update '0'
option allow_insecure '0'
option latency_test_mode 'icmp'
option packet_encoding 'xudp'
option update_via_proxy '0'
option auto_update_time '0 0 * * *'
option filter_nodes 'disabled'
option filter_nodes 'blacklist'
list filter_keywords '重置|到期|过期|剩余|套餐'
list filter_keywords 'Expiration|Remaining'
config homeproxy 'server'
option enabled '0'
File diff suppressed because it is too large. Load diff
@@ -1 +1 @@
202609130005
20260704060757
File diff suppressed because it is too large. Load diff
@@ -1 +1 @@
202609130005
20260704060757
File diff suppressed because it is too large. Load diff
@@ -1 +1 @@
202609130005
202607052256
@@ -4,8 +4,6 @@
1000giri.net
10beasts.net
10musume.com
123av.com
123av.me
12bet.com
12vpn.com
12vpn.net
@@ -15,7 +13,6 @@
173ng.com
18board.com
18comic.org
18comic.vip
18mh.net
1984bbs.com
1991way.com
@@ -130,7 +127,6 @@ agoogleaday.com
agro.hk
ai.dev
ai.studio
aihub.top
aiosearch.com
aiph.net
airconsole.com
@@ -215,15 +211,18 @@ aoxvpn.com
apartmentratings.com
apartments.com
apat1989.org
api-gl.lbkrs.com
api-secure.recaptcha.net
api-verify.recaptcha.net
api.ai
api.futunn.com
api.lbkrs.com
api.moomoobull.com
api.palworldgame.com
api.pureapk.com
api.recaptcha.net
api.shuaiapi.com
api.steampowered.com
api5.futunn.com
apiary.io
apigee.com
apk.support
@@ -295,6 +294,7 @@ atlanta168.com
atnext.com
auctions.yahoo.co.jp
audacy.com
auntology.fandom.com
autodraw.com
av.movie
av01.tv
@@ -342,7 +342,6 @@ baramangaonline.com
barnabu.co.uk
bartender.dowjones.com
barton.de
basemaps.arcgis.com
bastillepost.com
bayvoice.net
bb-chat.tv
@@ -561,7 +560,6 @@ break.com
breakgfw.com
breakingtweets.com
breakwall.net
briarproject.org
brill.com
brizzly.com
broadpressinc.com
@@ -667,6 +665,7 @@ cdjp.org
cdn-images.mailchimp.com
cdn-telegram.org
cdn.arstechnica.net
cdn.futustatic.com
cdn.jwplayer.com
cdn.printfriendly.com
cdn.seatguru.com
@@ -687,7 +686,6 @@ centauro.com.br
centerforhumanreprod.com
centralnation.com
centurys.net
cerebras.ai
certificate-transparency.org
certificate.revocationcheck.com
cfr.org
@@ -703,7 +701,6 @@ character.ai
chat.com
chat.lmsys.org
chatgpt.com
chatgpt.site
chatpdf.com
chaturbate.com
checkgfw.com
@@ -769,12 +766,10 @@ chrome.com
chromecast.com
chromeexperiments.com
chromestatus.com
cht.com.tw
ci-en.jp
cia.gov
cici.com
ciciai.com
circle19.org
cirosantilli.com
citizencn.com
citizenlab.ca
@@ -787,7 +782,6 @@ civilmedia.tw
civitai.com
cixiaoya.club
ck101.com
clashyun.com
classicalguitarblog.net
claude.ai
claude.com
@@ -816,6 +810,7 @@ cn.fmnnow.com
cn.sandscotaicentral.com
cn.shafaqna.com
cn.theaustralian.com.au
cn.uptodown.com
cna.com.tw
cnbbnews.wordpress.com
cnbeta.com.tw
@@ -896,7 +891,6 @@ css.pixnet.in
csuchen.de
csw.org.uk
ct.org.tw
ctee.com.tw
ctinets.com
ctinews.com
ctinsider.com
@@ -997,7 +991,6 @@ disneyplus.com
disp.cc
disqus.com
dit-inc.us
divdyn.com
diyin.org
dizhuzhishang.com
dl-laby.jp
@@ -1044,6 +1037,7 @@ dougscripts.com
doujincafe.com
dowjones.io
download.dappcdn.com
download.futunn.com
dpool.top
dpp.org.tw
dpr.info
@@ -1146,7 +1140,6 @@ eromanga-kingdom.com
eromangadouzin.com
eromon.net
esg.t91y.com
esim.redteago.com
eslite.com
esurance.com
etherdelta.com
@@ -1169,7 +1162,6 @@ exmormon.org
expatshield.com
expecthim.com
expekt.com
experience.arcgis.com
exploader.net
expressnews.com
expressvpn.com
@@ -1209,7 +1201,6 @@ falundafaindia.org
falunhr.org
famunion.com
fanbox.cc
fandom.com
fangeqiang.com
fanglizhi.info
fangong.org
@@ -1448,7 +1439,6 @@ geti2p.net
getlantern.org
getmalus.com
getmdl.io
getmonero.org
getoutline.org
getsession.org
getsync.com
@@ -1838,7 +1828,6 @@ help.byspotify.com
helpeachpeople.com
helpster.de
hembed.com
hentai.tv
heqinglian.net
heritage.org
herokuapp.com
@@ -1864,7 +1853,6 @@ himalayan-foundation.org
himemix.com
hindustantimes.com
hinet.net
historylines.net
hitbtc.com
hitomi.la
hive.blog
@@ -1881,7 +1869,6 @@ hkcnews.com
hkcoc.com
hkcoc.weather.com.hk
hkdc.us
hkej.com
hket.com
hkfaa.com
hkgalden.com
@@ -1905,6 +1892,7 @@ holyspiritspeaks.org
home.saxo
homedepot.com
homeservershow.com
hongkong.fandom.com
hongkongfp.com
hongzhi.li
honven.xyz
@@ -2183,7 +2171,6 @@ kanshifang.com
kantie.org
kaotic.com
karayou.com
kards.com
kathmandupost.com
kawaiikawaii.jp
kawase.com
@@ -2208,7 +2195,6 @@ kinmen.org.tw
kinmen.travel
kinokuniya.com
kiwi.kz
kiwix.org
kk-whys.co.jp
kkbox.com
kknews.cc
@@ -2269,7 +2255,6 @@ lenwhite.com
leonardo.ai
lesoir.be
letscorp.net
level-plus.net
lexica.art
lhakar.org
li.taipei
@@ -2304,7 +2289,6 @@ linkedin.com
linktr.ee
linux.do
linux.org.hk
linux.sb
liquiditytp.com
liquidvpn.com
listennotes.com
@@ -2317,7 +2301,6 @@ liveleak.com
livemint.com
livestream.com
livevideo.com
livingatlas.arcgis.com
livingstream.com
lmarena.ai
localbitcoins.com
@@ -2359,6 +2342,7 @@ lvv2.com
lyfhk.net
lzjscript.com
lzmtnews.org
m.hkgalden.com
m.me
m.moegirl.org
macgamestore.com
@@ -2404,7 +2388,6 @@ mattwilcox.net
maven.neoforged.net
mcadforums.com
mcaf.ee
mceindex.com
mcusercontent.com
md-t.org
me.me
@@ -2535,6 +2518,7 @@ mosavi.io
motherless.com
movements.org
moviefap.com
mozilla.ai
mpettis.com
mpfinance.com
mpinews.com
@@ -2649,7 +2633,6 @@ nga.mil
nhentai.net
nic.cz.cc
nic.gov
nicoseiga.jp
nicovideo.jp
nightswatch.top
nikke-en.com
@@ -2690,7 +2673,6 @@ ntd.tv
ntdtv.com
ntdtv.com.tw
ntdtv.cz
nthlink.com
nuexpo.com
nurgo-software.com
nusatrip.com
@@ -2735,7 +2717,6 @@ oklink.com
okpool.me
okx.com
old.honeynet.org
oldmapsonline.org
olehdtv.com
olelive.com
olevod.com
@@ -2767,8 +2748,12 @@ ooni.org
open.firstory.me
openai.com
openapi-quote.longbridge.cn
openapi-quote.longbridge.com
openapi-trade.longbridge.cn
openapi-trade.longbridge.com
openapi.futunn.com
openapi.longbridge.cn
openapi.longbridge.com
openart.ai
opencritic.com
opendemocracy.net
@@ -3002,6 +2987,7 @@ puffinbrowser.com
pullfolio.co
pure18.com
pureconcepts.net
puredns.org
pureinsight.org
purepdf.com
purevpn.com
@@ -3024,8 +3010,9 @@ qmp4.com
qoos.com
qq.co.za
qstatus.com
qtcard.futunn.com
qtcardfthk.futufin.com
qtrac.eu
quakemachinex.com
questvisual.com
quitccp.org
quiz.directory
@@ -3045,6 +3032,7 @@ radio.co
radio.garden
radioaustralia.net.au
radiohilight.net
radiojar.com
radioline.co
radiovaticana.org
radiovncr.com
@@ -3116,7 +3104,6 @@ rlwlw.com
rmbl.ws
roblox.com
robustnessiskey.com
roc-taiwan.org
rocket-inc.net
rocket.chat
rocksdb.org
@@ -3126,7 +3113,6 @@ rolia.net
rolsociety.org
ronjoneswriter.com
rou.video
roushuwo.com
rsdlmonitor.com
rsf-chinese.org
rsf.org
@@ -3152,7 +3138,6 @@ rxhj.net
s-cute.com
s.yimg.com
s3-ap-northeast-1.amazonaws.com
s3.dualstack.us-west-2.amazonaws.com
sacks.com
sacom.hk
sadistic-v.com
@@ -3160,6 +3145,7 @@ sadpanda.us
safechat.com
safeguarddefenders.com
safervpn.com
sagernet.org
saintyculture.com
sakuralive.com
salvation.org.hk
@@ -3172,7 +3158,6 @@ savetibet.de
savetibet.org
savetibetstore.org
saveuighur.org
sb.sb
sbme.me
sbti.unun.dev
schema.org
@@ -3429,7 +3414,7 @@ supchina.com
superpages.com
supervpn.net
superzooi.com
supjav.com
support.futunn.com
supremecourt.gov
suprememastertv.com
surfeasy.com
@@ -3450,7 +3435,6 @@ synapse.org
syncback.com
synergyse.com
syosetu.com
sysub.com
t-g.com
t.co
t.me
@@ -3459,12 +3443,10 @@ taa-usa.org
taaze.tw
taedp.org.tw
tagwalk.com
taiav.com
tails.net
taipeisociety.org
taipeitimes.com
taisounds.com
taiwanembassy.org
taiwanhot.net
taiwanjustice.net
taiwanncf.org.tw
@@ -3666,7 +3648,6 @@ to-porno.com
togetter.com
token.im
tokenlon.im
tokenplus.app
tokyo-porn-tube.com
tokyocn.com
tomp3.cc
@@ -3693,6 +3674,8 @@ totalvpn.com
tou.tv
tpi.org.tw
tracfone.com
trade.futunn.com
trade.longportapp.com
tradeup.com
tradingview.com
translate.goog
@@ -3706,7 +3689,6 @@ trtworld.com
truebuddha-md.org
trustwallet.com
truthsocial.com
tryai.dev
tryheart.jp
tsdr.uspto.gov
tt1069.com
@@ -3888,7 +3870,6 @@ uyghurstudies.org
uyghurtribunal.com
uygur.fc2web.com
uymaarip.com
v.recipes
v2.help
v2.hysteria.network
v2ex.com
@@ -4099,7 +4080,6 @@ wiktionary.org
willw.net
wilsoncenter.org
windscribe.com
windy.com
wingamestore.com
wingy.site
wionews.com
@@ -4142,11 +4122,9 @@ woyaolian.org
wp.com
wplace.live
wpoforum.com
write.as
writesonic.com
wsj.com
wsj.net
wtfpass.com
wufafangwen.com
wufi.org.tw
wujieliulan.com
@@ -4157,6 +4135,7 @@ www.ajsands.com
www.antd.org
www.aolnews.com
www.bing.com
www.clashverge.dev
www.cmoinc.org
www.dmm.com
www.eastturkistan.net
@@ -4192,7 +4171,6 @@ x.co
x.com
x.company
x3guide.com
x86.fr
xbabe.com
xbookcn.com
xbtce.com
@@ -4335,7 +4313,6 @@ zdnet.com.tw
zello.com
zenmate.com
zenmate.com.ru
zenmux.ai
zerohedge.com
zeronet.io
zh-hans.cfsh99.com
@@ -1 +1 @@
202609130005
202607052256
@@ -14,6 +14,6 @@ while true; do
sleep 180
for i in "$main_log_file" "$singc_log_file" "$sings_log_file"; do
[ -s "$i" ] || continue
[ "$(( $(ls -l "$i" | awk -F ' ' '{print $5}') / 1024 >= log_max_size))" -eq "0" ] || echo "" > "$i"
[ "$(( $(wc -c < "$i") / 1024 >= log_max_size))" -eq "0" ] || : > "$i"
done
done
@@ -1,9 +1,20 @@
#!/usr/bin/utpl -S
{%-
/*
* Declares every homeproxy_* fw4 chain/set. The inventory of those objects is
* tracked in scripts/fw4_names.sh, which init.d/homeproxy uses to clean them up
* on stop; add/remove entries there together with any change below.
* tests/ucode/test_fw4_names.sh compares both lists, and
* tests/ucode/test_firewall_template.sh renders this template and checks the
* objects actually survive into the generated nftables script.
*
* Keep this comment inside the ucode block: text placed before `{%-` has its
* trailing newline trimmed, which would glue it onto the first nft statement.
*/
import { readfile } from 'fs';
import { cursor } from 'uci';
import { isEmpty } from '/etc/homeproxy/scripts/homeproxy.uc';
import { isEmpty, isValidCIDR } from '/etc/homeproxy/scripts/homeproxy.uc';
const fw4 = require('fw4');
@@ -25,7 +36,24 @@ function resolve_ipv6(str) {
if (ipv6.bits > -1)
return `${ipv6.addr}/${ipv6.bits}`;
else
return `& ${ipv6.mask} == ${ipv6.addr}`;
return ipv6.addr;
}
/* Normalize a list of IPv6 entries into an nftables anonymous set
(or null when empty), so IPv6 ACLs match in a single set lookup
instead of one rule per address. */
function ipv6_to_nftarr(list) {
if (isEmpty(list))
return null;
let out = [];
for (let ip in list) {
const r = resolve_ipv6(ip);
if (!isEmpty(r))
push(out, r);
}
return isEmpty(out) ? null : `{ ${join(', ', out)} }`;
}
function resolve_mark(str) {
@@ -182,7 +210,9 @@ set homeproxy_mainland_addr_v4 {
auto-merge
elements = {
{% for (let cnip4 in split(trim(readfile(resources_dir + '/china_ip4.txt')), /[\r\n]/)): %}
{% if (isValidCIDR(cnip4, 4)): %}
{{ cnip4 }},
{% endif %}
{% endfor %}
}
}
@@ -193,7 +223,9 @@ set homeproxy_mainland_addr_v6 {
auto-merge
elements = {
{% for (let cnip6 in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)): %}
{% if (isValidCIDR(cnip6, 6)): %}
{{ cnip6 }},
{% endif %}
{% endfor %}
}
}
@@ -286,9 +318,9 @@ chain homeproxy_redirect_lanac {
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_mac_addrs */ %}
@@ -296,9 +328,9 @@ chain homeproxy_redirect_lanac {
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
{% endif /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
@@ -326,9 +358,9 @@ chain homeproxy_redirect {
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect_proxy_port
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_mac_addrs */ %}
@@ -359,9 +391,9 @@ chain homeproxy_redirect {
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect_proxy
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_mac_addrs */ %}
@@ -409,9 +441,9 @@ chain homeproxy_mangle_lanac {
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_prerouting
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_mac_addrs */ %}
@@ -419,9 +451,9 @@ chain homeproxy_mangle_lanac {
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
{% endif /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
@@ -447,9 +479,9 @@ chain homeproxy_mangle_prerouting {
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tproxy_port
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_mac_addrs */ %}
@@ -465,14 +497,14 @@ chain homeproxy_mangle_prerouting {
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
@@ -484,9 +516,9 @@ chain homeproxy_mangle_prerouting {
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tproxy
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_mac_addrs */ %}
@@ -555,9 +587,9 @@ chain homeproxy_mangle_lanac {
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tun
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_mac_addrs */ %}
@@ -565,9 +597,9 @@ chain homeproxy_mangle_lanac {
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
{% endif /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
@@ -606,9 +638,9 @@ chain homeproxy_mangle_tun {
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tun_mark
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_mac_addrs */ %}
@@ -626,14 +658,14 @@ chain homeproxy_mangle_tun {
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Fuck you QUIC"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
@@ -645,9 +677,9 @@ chain homeproxy_mangle_tun {
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter meta mark set {{ tun_mark }}
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_mac_addrs */ %}
@@ -4,7 +4,7 @@
import { writefile } from 'fs';
import { cursor } from 'uci';
import { isEmpty, RUN_DIR } from 'homeproxy';
import { isEmpty, RUN_DIR, validation } from 'homeproxy';
const cfgname = 'homeproxy';
const uci = cursor();
@@ -39,7 +39,17 @@ if (server_enabled === '1') {
if (s.enabled !== '1' || s.firewall !== '1')
return;
if (!validation('port', s.port)) {
print(`WARN: skipping server ${s['.name']}: invalid port "${s.port}".`);
return;
}
let proto = s.network || '{ tcp, udp }';
if (!(proto in ['tcp', 'udp', '{ tcp, udp }'])) {
print(`WARN: skipping server ${s['.name']}: invalid network "${proto}".`);
return;
}
push(input, `meta l4proto ${proto} th dport ${s.port} counter accept comment "!${cfgname}: accept server ${s['.name']}"`);
});
}
@@ -0,0 +1,48 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Single source of truth for every fw4 chain and set owned by homeproxy.
#
# * root/etc/homeproxy/scripts/firewall_post.ut declares these objects and
# decides which of them actually exist for the current proxy mode.
# * root/etc/init.d/homeproxy flushes and deletes them on stop.
#
# When you add or remove a chain/set in firewall_post.ut, update this list in
# the same commit -- tests/ucode/test_fw4_names.sh compares both sides and
# fails when they drift.
#
# Each object is cleaned up independently: the cleanup loop deletes one at a
# time so that a chain/set which does not exist in the current mode cannot
# abort the whole batch. A single `nft -f` batch is atomic and would roll back
# every preceding command in that case.
HP_FW4_CHAINS="
homeproxy_output_redir
homeproxy_redirect
homeproxy_redirect_proxy
homeproxy_redirect_proxy_port
homeproxy_redirect_lanac
homeproxy_mangle_prerouting
homeproxy_mangle_output
homeproxy_mangle_tproxy
homeproxy_mangle_tproxy_port
homeproxy_mangle_lanac
homeproxy_mangle_mark
homeproxy_mangle_tun
homeproxy_mangle_tun_mark
"
HP_FW4_SETS="
homeproxy_local_addr_v4
homeproxy_local_addr_v6
homeproxy_gfw_list_v4
homeproxy_gfw_list_v6
homeproxy_mainland_addr_v4
homeproxy_mainland_addr_v6
homeproxy_wan_proxy_addr_v4
homeproxy_wan_proxy_addr_v6
homeproxy_wan_direct_addr_v4
homeproxy_wan_direct_addr_v6
homeproxy_routing_port
"
File diff suppressed because it is too large. Load diff
@@ -11,8 +11,8 @@ import { writefile } from 'fs';
import { cursor } from 'uci';
import {
isEmpty, strToBool, strToInt, strToTime,
removeBlankAttrs, HP_DIR, RUN_DIR
strToBool, strToInt, strToTime,
removeBlankAttrs, buildTLSObject, buildTransportObject, HP_DIR, RUN_DIR
} from 'homeproxy';
/* UCI config start */
@@ -42,6 +42,26 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
if (cfg.enabled !== '1')
return;
/* Snell (1.14): single-psk inbound; shape differs from the generic users[] block below */
if (cfg.type === 'snell') {
push(config.inbounds, {
type: 'snell',
tag: 'cfg-' + cfg['.name'] + '-in',
listen: cfg.address || '::',
listen_port: strToInt(cfg.port),
bind_interface: cfg.bind_interface,
reuse_addr: strToBool(cfg.reuse_addr),
tcp_fast_open: strToBool(cfg.tcp_fast_open),
tcp_multi_path: strToBool(cfg.tcp_multi_path),
version: strToInt(cfg.snell_version) || 5,
psk: cfg.password,
obfs_mode: cfg.snell_obfs_mode,
mode: cfg.snell_mode
});
return;
}
push(config.inbounds, {
type: cfg.type,
tag: 'cfg-' + cfg['.name'] + '-in',
@@ -59,17 +79,15 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
/* AnyTLS */
padding_scheme: cfg.anytls_padding_scheme,
/* Hysteria */
/* Hysteria (2) */
up_mbps: strToInt(cfg.hysteria_up_mbps),
down_mbps: strToInt(cfg.hysteria_down_mbps),
obfs: cfg.hysteria_obfs_type ? {
type: cfg.hysteria_obfs_type,
password: cfg.hysteria_obfs_password
password: cfg.hysteria_obfs_password,
min_packet_size: strToInt(cfg.hysteria_obfs_min_packet_size),
max_packet_size: strToInt(cfg.hysteria_obfs_max_packet_size)
} : cfg.hysteria_obfs_password,
recv_window_conn: strToInt(cfg.hysteria_recv_window_conn),
recv_window_client: strToInt(cfg.hysteria_recv_window_client),
max_conn_client: strToInt(cfg.hysteria_max_conn_client),
disable_mtu_discovery: strToBool(cfg.hysteria_disable_mtu_discovery),
ignore_client_bandwidth: strToBool(cfg.hysteria_ignore_client_bandwidth),
masquerade: cfg.hysteria_masquerade,
@@ -113,73 +131,22 @@ uci.foreach(uciconfig, uciserver, (cfg) => {
} : null
} : null,
tls: (cfg.tls === '1') ? {
enabled: true,
server_name: cfg.tls_sni,
alpn: cfg.tls_alpn,
min_version: cfg.tls_min_version,
max_version: cfg.tls_max_version,
cipher_suites: cfg.tls_cipher_suites,
certificate_path: cfg.tls_cert_path,
key_path: cfg.tls_key_path,
acme: (cfg.tls_acme === '1') ? {
domain: cfg.tls_acme_domain,
data_directory: HP_DIR + '/certs',
default_server_name: cfg.tls_acme_dsn,
email: cfg.tls_acme_email,
provider: cfg.tls_acme_provider,
disable_http_challenge: strToBool(cfg.tls_acme_dhc),
disable_tls_alpn_challenge: (cfg.tls_acme_dtac),
alternative_http_port: strToInt(cfg.tls_acme_ahp),
alternative_tls_port: strToInt(cfg.tls_acme_atp),
external_account: (cfg.tls_acme_external_account === '1') ? {
key_id: cfg.tls_acme_ea_keyid,
mac_key: cfg.tls_acme_ea_mackey
} : null,
dns01_challenge: (cfg.tls_dns01_challenge === '1') ? {
provider: cfg.tls_dns01_provider,
access_key_id: cfg.tls_dns01_ali_akid,
access_key_secret: cfg.tls_dns01_ali_aksec,
region_id: cfg.tls_dns01_ali_rid,
api_token: cfg.tls_dns01_cf_api_token
} : null
} : null,
ech: (cfg.tls_ech_key) ? {
enabled: true,
key: split(cfg.tls_ech_key, '\n'),
// config: split(cfg.tls_ech_config, '\n')
} : null,
reality: (cfg.tls_reality === '1') ? {
enabled: true,
private_key: cfg.tls_reality_private_key,
short_id: cfg.tls_reality_short_id,
max_time_difference: strToTime(cfg.tls_reality_max_time_difference),
handshake: {
server: cfg.tls_reality_server_addr,
server_port: strToInt(cfg.tls_reality_server_port)
}
} : null
} : null,
tls: buildTLSObject(cfg, true),
transport: !isEmpty(cfg.transport) ? {
type: cfg.transport,
host: cfg.http_host || cfg.httpupgrade_host,
path: cfg.http_path || cfg.ws_path,
headers: cfg.ws_host ? {
Host: cfg.ws_host
} : null,
method: cfg.http_method,
max_early_data: strToInt(cfg.websocket_early_data),
early_data_header_name: cfg.websocket_early_data_header,
service_name: cfg.grpc_servicename,
idle_timeout: strToTime(cfg.http_idle_timeout),
ping_timeout: strToTime(cfg.http_ping_timeout)
} : null
transport: buildTransportObject(cfg, true)
});
});
if (length(config.inbounds) === 0)
exit(1);
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
system('mkdir -p ' + RUN_DIR);
writefile(RUN_DIR + '/sing-box-s.json', sprintf('%.J\n', removeBlankAttrs(config)));
const server_tmp = RUN_DIR + '/sing-box-s.json.tmp';
writefile(server_tmp, sprintf('%.J\n', removeBlankAttrs(config)));
if (system('/usr/bin/sing-box check --config ' + server_tmp) !== 0) {
system('rm -f ' + server_tmp);
exit(1);
}
system('mv -f ' + server_tmp + ' ' + RUN_DIR + '/sing-box-s.json');
+278 -41
View File
@@ -26,30 +26,55 @@ export function isBinary(str) {
return false;
};
/* Close a file descriptor, swallowing secondary errors (e.g. an already
closed fd). ucode has no `finally` clause, so both the normal and the
exceptional path of executeCommand() have to close explicitly. */
function closeFD(fd) {
if (fd) {
try {
fd.close();
} catch (e) {
/* already closed */
}
}
};
export function executeCommand(...args) {
let outfd = mkstemp();
let errfd = mkstemp();
let outfd = null, errfd = null;
const exitcode = system(`${join(' ', args)} >&${outfd.fileno()} 2>&${errfd.fileno()}`);
try {
outfd = mkstemp();
errfd = mkstemp();
outfd.seek(0);
errfd.seek(0);
const exitcode = system(`${join(' ', args)} >&${outfd.fileno()} 2>&${errfd.fileno()}`);
const stdout = outfd.read(1024 * 512) ?? '';
const stderr = errfd.read(1024 * 512) ?? '';
outfd.seek(0);
errfd.seek(0);
outfd.close();
errfd.close();
const stdout = outfd.read(1024 * 512) ?? '';
const stderr = errfd.read(1024 * 512) ?? '';
const binary = isBinary(stdout);
const binary = isBinary(stdout);
return {
command: join(' ', args),
stdout: binary ? null : stdout,
stderr,
exitcode,
binary
};
/* mkstemp() returns delete-on-close files, so closing is enough */
closeFD(outfd);
closeFD(errfd);
return {
command: join(' ', args),
stdout: binary ? null : stdout,
stderr,
exitcode,
binary
};
} catch (e) {
/* Never leak the temporary descriptors on a failing run. ucode has
no `finally` clause, so the exception is re-raised by hand. */
closeFD(outfd);
closeFD(errfd);
die(e);
}
};
export function getTime(epoch) {
@@ -66,34 +91,35 @@ export function getTime(epoch) {
};
export function wGETResponse(url, ua) {
/*
* 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.
*/
export function wGETVerbose(url, ua) {
if (!url || type(url) !== 'string')
return null;
return { content: null, error: 'invalid URL' };
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
const output = executeCommand(`/usr/bin/wget -O- -S --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
return {
body: trim(output.stdout),
headers: output.stderr || '',
exitcode: output.exitcode
};
/* -nv (not -q) so wget still reports *why* a fetch failed on stderr */
const output = executeCommand(`/usr/bin/wget -nv -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
if (output.exitcode !== 0) {
let reason = trim(output.stderr || '');
reason = reason ? replace(reason, /\s+/g, ' ') : 'no error output';
if (length(reason) > 200)
reason = substr(reason, 0, 200) + '...';
return { content: null, error: `wget exited with status ${output.exitcode}: ${reason}` };
}
return { content: trim(output.stdout), error: null };
};
export function wGET(url, ua) {
return wGETResponse(url, ua)?.body;
};
export function wGETHeaders(url, ua) {
if (!url || type(url) !== 'string')
return null;
if (!ua)
ua = 'clash.meta';
const output = executeCommand(`/usr/bin/wget -O /dev/null -S --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
return output.stderr || '';
return wGETVerbose(url, ua).content;
};
/* Utilities end */
@@ -103,7 +129,7 @@ export function isEmpty(res) {
};
export function strToBool(str) {
return (str === '1') || null;
return str === '1' ? true : (str === '0' ? false : null);
};
export function strToInt(str) {
@@ -111,7 +137,11 @@ export function strToInt(str) {
};
export function strToTime(str) {
return !isEmpty(str) ? (str + 's') : null;
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');
};
export function removeBlankAttrs(res) {
@@ -152,6 +182,77 @@ export function validation(datatype, data) {
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 */
const parts = split(addr, '/');
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 */
if (prefix && (int(prefix) < 0 || 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;
}
if (prefix && (int(prefix) < 0 || int(prefix) > 128))
return false;
} else {
return false;
}
return true;
};
/* String helper end */
/* String parser start */
@@ -160,8 +261,8 @@ export function decodeBase64Str(str) {
return null;
str = trim(str);
str = replace(str, '_', '/');
str = replace(str, '-', '+');
str = replace(str, /_/g, '/');
str = replace(str, /-/g, '+');
const padding = length(str) % 4;
if (padding)
@@ -247,3 +348,139 @@ export function parseURL(url) {
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.
*/
export function buildTLSObject(cfg, is_server) {
if (cfg.tls !== '1')
return null;
return {
enabled: true,
server_name: cfg.tls_sni,
insecure: is_server ? null : strToBool(cfg.tls_insecure),
alpn: cfg.tls_alpn,
min_version: cfg.tls_min_version,
max_version: cfg.tls_max_version,
handshake_timeout: is_server ? null : strToTime(cfg.tls_handshake_timeout),
cipher_suites: cfg.tls_cipher_suites,
certificate_path: cfg.tls_cert_path,
key_path: is_server ? cfg.tls_key_path : null,
certificate_provider: (is_server && cfg.tls_acme === '1') ? {
type: 'acme',
domain: (type(cfg.tls_acme_domain) === 'array') ? cfg.tls_acme_domain
: (isEmpty(cfg.tls_acme_domain) ? [] : [cfg.tls_acme_domain]),
data_directory: HP_DIR + '/certs',
default_server_name: cfg.tls_acme_dsn,
email: cfg.tls_acme_email,
provider: cfg.tls_acme_provider,
account_key: cfg.tls_acme_account_key,
key_type: cfg.tls_acme_key_type,
profile: cfg.tls_acme_profile,
disable_http_challenge: strToBool(cfg.tls_acme_dhc),
disable_tls_alpn_challenge: strToBool(cfg.tls_acme_dtac),
alternative_http_port: strToInt(cfg.tls_acme_ahp),
alternative_tls_port: strToInt(cfg.tls_acme_atp),
external_account: (cfg.tls_acme_external_account === '1') ? {
key_id: cfg.tls_acme_ea_keyid,
mac_key: cfg.tls_acme_ea_mackey
} : null,
dns01_challenge: (cfg.tls_dns01_challenge === '1') ? {
provider: cfg.tls_dns01_provider,
access_key_id: cfg.tls_dns01_ali_akid,
access_key_secret: cfg.tls_dns01_ali_aksec,
region_id: cfg.tls_dns01_ali_rid,
api_token: cfg.tls_dns01_cf_api_token
} : null
} : null,
ech: is_server ? (cfg.tls_ech_key ? {
enabled: true,
key: split(cfg.tls_ech_key, '\n')
/* config: split(cfg.tls_ech_config, '\n') */
} : null) : ((cfg.tls_ech === '1') ? {
enabled: true,
config: cfg.tls_ech_config,
config_path: cfg.tls_ech_config_path
} : null),
utls: (is_server || isEmpty(cfg.tls_utls)) ? null : {
enabled: true,
fingerprint: cfg.tls_utls
},
reality: (cfg.tls_reality !== '1') ? null : (is_server ? {
enabled: true,
private_key: cfg.tls_reality_private_key,
short_id: cfg.tls_reality_short_id,
max_time_difference: strToTime(cfg.tls_reality_max_time_difference),
handshake: {
server: cfg.tls_reality_server_addr,
server_port: strToInt(cfg.tls_reality_server_port)
}
} : {
enabled: true,
public_key: cfg.tls_reality_public_key,
short_id: cfg.tls_reality_short_id
})
};
};
/* Shared sing-box transport object builder; the client transport additionally
supports the gRPC keepalive hint, the server one does not. */
export function buildTransportObject(cfg, is_server) {
if (isEmpty(cfg.transport))
return null;
return {
type: cfg.transport,
host: cfg.http_host || cfg.httpupgrade_host,
path: cfg.http_path || cfg.ws_path,
headers: cfg.ws_host ? {
Host: cfg.ws_host
} : null,
method: cfg.http_method,
max_early_data: strToInt(cfg.websocket_early_data),
early_data_header_name: cfg.websocket_early_data_header,
service_name: cfg.grpc_servicename,
idle_timeout: strToTime(cfg.http_idle_timeout),
ping_timeout: strToTime(cfg.http_ping_timeout),
permit_without_stream: is_server ? null : strToBool(cfg.grpc_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.
*/
export function isValidPEM(content, is_private_key) {
if (isEmpty(content))
return false;
const beg = is_private_key ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
end = is_private_key ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/,
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;
};
/* PEM validation end */
@@ -7,34 +7,14 @@
'use strict';
import { popen } from 'fs';
import { cursor } from 'uci';
import { isEmpty, parseURL } from 'homeproxy';
import { isEmpty, parseURL, validation } from 'homeproxy';
const uci = cursor();
const uciconfig = 'homeproxy';
uci.load(uciconfig);
function random_secret() {
const fd = popen('tr -dc "A-Za-z0-9" < /dev/urandom 2>/dev/null | head -c 16');
let random = '';
if (fd) {
random = trim(fd.read('all') || '');
fd.close();
}
if (isEmpty(random)) {
const fallback = popen('awk \'BEGIN{srand(); printf "%06d", int(rand() * 1000000)}\'');
if (fallback) {
random = trim(fallback.read('all') || '');
fallback.close();
}
}
return random;
}
const uciinfra = 'infra',
ucimigration = 'migration',
ucimain = 'config',
@@ -45,16 +25,8 @@ const uciinfra = 'infra',
ucirouting = 'routing',
uciroutingnode = 'routing_node',
uciroutingrule = 'routing_rule',
uciclashapi = 'clash_api',
ucintp = 'ntp',
ucicache = 'cache',
uciserver = 'server';
function is_update_cron(value) {
let matched = match(value || '', /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+([0-7*])$/);
return !isEmpty(matched) && int(matched[1]) <= 59 && int(matched[2]) <= 23;
}
/* chinadns-ng has been removed */
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
uci.delete(uciconfig, uciinfra, 'china_dns_port');
@@ -64,7 +36,9 @@ const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
if (type(china_dns_server) === 'array') {
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
} else {
if (match(china_dns_server, /,/))
if (china_dns_server === 'wan_114')
uci.set(uciconfig, ucimain, 'china_dns_server', '114.114.114.114');
else if (match(china_dns_server, /,/))
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
}
@@ -83,93 +57,6 @@ if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
uci.delete(uciconfig, uciinfra, 'tun_gso');
/* endpoint_independent_nat was removed in sing-box 1.13 */
if (!isEmpty(uci.get(uciconfig, ucirouting, 'endpoint_independent_nat')))
uci.delete(uciconfig, ucirouting, 'endpoint_independent_nat');
/* direct outbound proxy_protocol was removed in sing-box 1.13 */
uci.foreach(uciconfig, ucinode, (cfg) => {
if (cfg.type === 'direct' && !isEmpty(cfg.proxy_protocol))
uci.delete(uciconfig, cfg['.name'], 'proxy_protocol');
});
const legacy_panel_url = 'https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip';
const legacy_panel_proxy_url = 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip';
const old_panel_url = 'https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip';
const old_panel_proxy_url = 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip';
const default_panel_url = 'https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip';
/* clash api panel options were introduced */
if (!uci.get(uciconfig, uciclashapi))
uci.set(uciconfig, uciclashapi, uciconfig);
if (isEmpty(uci.get(uciconfig, uciclashapi, 'external_ui')))
uci.set(uciconfig, uciclashapi, 'external_ui', '/etc/homeproxy/run/ui');
const panel_url = uci.get(uciconfig, uciclashapi, 'external_ui_download_url');
if (isEmpty(panel_url) || panel_url === legacy_panel_url || panel_url === legacy_panel_proxy_url || panel_url === old_panel_proxy_url)
uci.set(uciconfig, uciclashapi, 'external_ui_download_url', default_panel_url);
if (isEmpty(uci.get(uciconfig, uciclashapi, 'external_ui_download_detour')))
uci.set(uciconfig, uciclashapi, 'external_ui_download_detour', 'direct-out');
if (isEmpty(uci.get(uciconfig, uciclashapi, 'external_controller')))
uci.set(uciconfig, uciclashapi, 'external_controller', '0.0.0.0:9095');
if (isEmpty(uci.get(uciconfig, uciclashapi, 'secret')) || uci.get(uciconfig, uciclashapi, 'secret') === 'homeproxy') {
let random = random_secret();
if (!isEmpty(random))
uci.set(uciconfig, uciclashapi, 'secret', random);
}
if (isEmpty(uci.get(uciconfig, uciclashapi, 'default_mode')))
uci.set(uciconfig, uciclashapi, 'default_mode', 'rule');
/* ntp options were moved into a dedicated section */
if (!uci.get(uciconfig, ucintp))
uci.set(uciconfig, ucintp, uciconfig);
const legacy_ntp_server = uci.get(uciconfig, uciinfra, 'ntp_server');
if (isEmpty(uci.get(uciconfig, ucintp, 'enabled')))
uci.set(uciconfig, ucintp, 'enabled', '1');
if (isEmpty(uci.get(uciconfig, ucintp, 'server')))
uci.set(uciconfig, ucintp, 'server', !isEmpty(legacy_ntp_server) ? legacy_ntp_server : 'ntp.aliyun.com');
if (isEmpty(uci.get(uciconfig, ucintp, 'server_port')))
uci.set(uciconfig, ucintp, 'server_port', '123');
if (isEmpty(uci.get(uciconfig, ucintp, 'interval')))
uci.set(uciconfig, ucintp, 'interval', '30m');
/* cache file options were moved into a dedicated section */
if (!uci.get(uciconfig, ucicache))
uci.set(uciconfig, ucicache, uciconfig);
if (isEmpty(uci.get(uciconfig, ucicache, 'enabled')))
uci.set(uciconfig, ucicache, 'enabled', '1');
const old_cache_path = '/var/run/homeproxy/cache.db';
const default_cache_path = '/etc/homeproxy/cache.db';
const cache_path = uci.get(uciconfig, ucicache, 'path');
if (isEmpty(cache_path) || cache_path === old_cache_path)
uci.set(uciconfig, ucicache, 'path', default_cache_path);
if (!isEmpty(uci.get(uciconfig, ucicache, 'store_fakeip')))
uci.delete(uciconfig, ucicache, 'store_fakeip');
if (isEmpty(uci.get(uciconfig, ucicache, 'store_rdrc')))
uci.set(uciconfig, ucicache, 'store_rdrc', isEmpty(uci.get(uciconfig, ucidns, 'cache_file_store_rdrc')) ? '1' : uci.get(uciconfig, ucidns, 'cache_file_store_rdrc'));
if (isEmpty(uci.get(uciconfig, ucicache, 'rdrc_timeout')) && !isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
uci.set(uciconfig, ucicache, 'rdrc_timeout', uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout'));
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_store_rdrc')))
uci.delete(uciconfig, ucidns, 'cache_file_store_rdrc');
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
uci.delete(uciconfig, ucidns, 'cache_file_rdrc_timeout');
/* create migration section */
if (!uci.get(uciconfig, ucimigration))
uci.set(uciconfig, ucimigration, uciconfig);
@@ -177,7 +64,7 @@ if (!uci.get(uciconfig, ucimigration))
/* delete old crontab command */
const migration_crontab = uci.get(uciconfig, ucimigration, 'crontab');
if (!migration_crontab) {
system('sed -i "/update_crond.sh/d" "/etc/crontabs/root" 2>"/dev/null"');
system('sed -i "/update_crond.sh/d" "/etc/crontabs/root" 2>/dev/null');
uci.set(uciconfig, ucimigration, 'crontab', '1');
}
@@ -188,10 +75,6 @@ if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
uci.set(uciconfig, uciserver, 'log_level', 'warn');
if (!isEmpty(uci.get(uciconfig, 'subscription', 'auto_update_time')) &&
!is_update_cron(uci.get(uciconfig, 'subscription', 'auto_update_time')))
uci.set(uciconfig, 'subscription', 'auto_update_time', '0 0 * * *');
/* empty value defaults to all ports now */
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
uci.delete(uciconfig, ucimain, 'routing_port');
@@ -211,6 +94,8 @@ if (default_dns_server === 'block-dns') {
uci.set(uciconfig, '_migration_dns_final_block', 'action', 'reject');
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
}
else if (default_dns_server === 'local-dns')
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
const dns_server_migration = {};
/* DNS servers options */
@@ -288,7 +173,7 @@ uci.foreach(uciconfig, ucidnsrule, (cfg) => {
uci.set(uciconfig, ucirouting, 'default_outbound_dns', cfg.server);
break;
default:
uci.set(uciconfig, cfg.outbound, 'domain_resolver', cfg.server);
uci.set(uciconfig, outbound, 'domain_resolver', cfg.server);
break;
}
});
@@ -338,6 +223,29 @@ uci.foreach(uciconfig, ucinode, (cfg) => {
/* wireguard_gso was deprecated in sb 1.11 */
if (!isEmpty(cfg.wireguard_gso))
uci.delete(uciconfig, cfg['.name'], 'wireguard_gso');
/* hysteria_revc_window was a typo, renamed to hysteria_recv_window */
if (!isEmpty(cfg.hysteria_revc_window))
uci.rename(uciconfig, cfg['.name'], 'hysteria_revc_window', 'hysteria_recv_window');
});
/* sing-box 1.14: remove deprecated DNS semantics */
if (!isEmpty(uci.get(uciconfig, ucidns, 'independent_cache')))
uci.delete(uciconfig, ucidns, 'independent_cache');
const legacy_store_rdrc = uci.get(uciconfig, ucidns, 'cache_file_store_rdrc');
if (!isEmpty(legacy_store_rdrc)) {
uci.set(uciconfig, ucidns, 'cache_file_store_dns', legacy_store_rdrc);
uci.delete(uciconfig, ucidns, 'cache_file_store_rdrc');
}
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
uci.delete(uciconfig, ucidns, 'cache_file_rdrc_timeout');
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
if (!isEmpty(cfg.domain_strategy))
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
if (!isEmpty(cfg.rule_set_ip_cidr_accept_empty))
uci.delete(uciconfig, cfg['.name'], 'rule_set_ip_cidr_accept_empty');
});
/* routing rules options */
@@ -356,47 +264,6 @@ uci.foreach(uciconfig, uciroutingrule, (cfg) => {
}
});
/* routing node options */
uci.foreach(uciconfig, uciroutingnode, (cfg) => {
if (cfg.node === 'urltest' && !isEmpty(cfg.urltest_nodes)) {
let custom_nodes = [],
subscription_nodes = [];
for (let node in (type(cfg.urltest_nodes) === 'array' ? cfg.urltest_nodes : [ cfg.urltest_nodes ])) {
let node_cfg = uci.get_all(uciconfig, node);
if (isEmpty(node_cfg))
continue;
if (!isEmpty(node_cfg.grouphash))
push(subscription_nodes, node);
else
push(custom_nodes, node);
}
if (isEmpty(cfg.selected_nodes) && !isEmpty(custom_nodes))
uci.set(uciconfig, cfg['.name'], 'selected_nodes', custom_nodes);
if (isEmpty(cfg.subscription_nodes) && !isEmpty(subscription_nodes))
uci.set(uciconfig, cfg['.name'], 'subscription_nodes', subscription_nodes);
uci.delete(uciconfig, cfg['.name'], 'urltest_nodes');
}
});
/* rule set options */
uci.foreach(uciconfig, uciruleset, (cfg) => {
if (isEmpty(cfg.tag))
uci.set(uciconfig, cfg['.name'], 'tag', 'cfg-' + cfg['.name'] + '-rule');
if (cfg.type === 'remote') {
if (isEmpty(cfg.auto_update))
uci.set(uciconfig, cfg['.name'], 'auto_update', '1');
if (!is_update_cron(cfg.update_interval))
uci.set(uciconfig, cfg['.name'], 'update_interval', '0 0 * * *');
}
});
/* server options */
/* auto_firewall was moved into server options */
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
@@ -404,13 +271,6 @@ if (!isEmpty(auto_firewall))
uci.delete(uciconfig, uciserver, 'auto_firewall');
uci.foreach(uciconfig, uciserver, (cfg) => {
if (!isEmpty(cfg.hysteria_revc_window_client) && isEmpty(cfg.hysteria_recv_window_client)) {
uci.set(uciconfig, cfg['.name'], 'hysteria_recv_window_client', cfg.hysteria_revc_window_client);
}
if (!isEmpty(cfg.hysteria_revc_window_client))
uci.delete(uciconfig, cfg['.name'], 'hysteria_revc_window_client');
/* auto_firewall was moved into server options */
if (auto_firewall === '1')
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
@@ -0,0 +1,500 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023-2025 ImmortalWrt.org
*/
'use strict';
import { urldecode, urlencode } from 'luci.http';
import { decodeBase64Str, parseURL, isEmpty, validation } from 'homeproxy';
/*
* Share-link parsers. Every protocol has its own parse_<scheme>_uri() so the
* branches can be read, changed and unit-tested in isolation; parse_uri() only
* dispatches on the scheme and applies the common address/port validation and
* label fallback afterwards.
*
* `features` is the sing-box feature map (with_quic/with_utls/...), `log` is a
* logging callback, both injected so the parsers stay free of ubus/uci.
*/
/* https://shadowsocks.org/guide/sip008.html */
export function parse_sip008_uri(uri) {
return {
label: uri.remarks,
type: 'shadowsocks',
address: uri.server,
port: uri.server_port,
shadowsocks_encrypt_method: uri.method,
password: uri.password,
shadowsocks_plugin: uri.plugin,
shadowsocks_plugin_opts: uri.plugin_opts
};
};
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
export function parse_anytls_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'anytls',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
tls: '1',
tls_sni: params.sni,
tls_insecure: (params.insecure === '1') ? '1' : '0'
};
};
export function parse_http_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'http',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
tls: (uri[0] === 'https') ? '1' : '0'
};
};
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
export function parse_hysteria_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic || (params.protocol && params.protocol !== 'udp')) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria',
address: url.hostname,
port: url.port,
hysteria_protocol: params.protocol || 'udp',
hysteria_auth_type: params.auth ? 'string' : null,
hysteria_auth_payload: params.auth,
hysteria_obfs_password: params.obfsParam,
hysteria_down_mbps: params.downmbps,
hysteria_up_mbps: params.upmbps,
tls: '1',
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
tls_sni: params.peer,
tls_alpn: params.alpn
};
};
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
export function parse_hysteria2_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria2',
address: url.hostname,
port: url.port,
password: url.username ? (
urldecode(url.username + (url.password ? (':' + url.password) : ''))
) : null,
hysteria_obfs_type: params.obfs,
hysteria_obfs_password: params['obfs-password'],
tls: '1',
tls_insecure: (params.insecure === '1') ? '1' : '0',
tls_sni: params.sni
};
};
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
export function parse_snell_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'snell',
address: url.hostname,
port: url.port,
password: url.username ? urldecode(url.username)
: (params.psk ? urldecode(params.psk) : null),
snell_version: params.version || '4',
snell_userkey: params.userkey,
snell_obfs_mode: (params.obfs === 'http') ? 'http' : null,
snell_obfs_host: params['obfs-host'],
snell_reuse: (params.reuse === '1') ? '1' : '0'
};
};
export function parse_socks_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'socks',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
socks_version: (match(uri[0], /4/)) ? '4' : '5'
};
};
export function parse_ss_uri(uri) {
uri = split(trim(uri), '://');
/* "Lovely" Shadowrocket format */
const ss_suri = split(uri[1], '#');
let ss_slabel = '';
if (length(ss_suri) <= 2) {
if (length(ss_suri) === 2)
ss_slabel = '#' + urlencode(ss_suri[1]);
if (decodeBase64Str(ss_suri[0]))
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
const url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = {};
if (url.username && url.password)
/* User info encoded with URIComponent */
ss_userinfo = [url.username, urldecode(url.password)];
else if (url.username)
/* User info encoded with base64 */
ss_userinfo = split(decodeBase64Str(urldecode(url.username)), ':', 2);
let ss_plugin, ss_plugin_opts;
if (url.search && url.searchParams.plugin) {
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
ss_plugin = ss_plugin_info[0];
if (ss_plugin === 'simple-obfs')
/* Fix non-standard plugin name */
ss_plugin = 'obfs-local';
ss_plugin_opts = ss_plugin_info[1];
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'shadowsocks',
address: url.hostname,
port: url.port,
shadowsocks_encrypt_method: ss_userinfo[0],
password: ss_userinfo[1],
shadowsocks_plugin: ss_plugin,
shadowsocks_plugin_opts: ss_plugin_opts
};
};
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
export function parse_trojan_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
const config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'trojan',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
transport: (params.type !== 'tcp') ? params.type : null,
tls: '1',
tls_sni: params.sni
};
switch (params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
/* https://github.com/daeuniverse/dae/discussions/182 */
export function parse_tuic_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'tuic',
address: url.hostname,
port: url.port,
uuid: url.username,
password: url.password ? urldecode(url.password) : null,
tuic_congestion_control: params.congestion_control,
tuic_udp_relay_mode: params.udp_relay_mode,
tls: '1',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
};
};
/* https://github.com/XTLS/Xray-core/discussions/716 */
export function parse_vless_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
/* Unsupported protocol */
if (params.type === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
return null;
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
const config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'vless',
address: url.hostname,
port: url.port,
uuid: url.username,
transport: (params.type !== 'tcp') ? params.type : null,
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
tls_reality: (params.security === 'reality') ? '1' : '0',
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: features.with_utls ? params.fp : null,
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
};
switch (params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'http':
case 'tcp':
if (params.type === 'http' || params.headerType === 'http') {
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
config.http_path = params.path ? urldecode(params.path) : null;
}
break;
case 'httpupgrade':
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
config.http_path = params.path ? urldecode(params.path) : null;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
export function parse_vmess_uri(uri, features, log) {
uri = split(trim(uri), '://');
/* "Lovely" shadowrocket format */
if (match(uri, /&/)) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
let payload;
try {
payload = json(decodeBase64Str(uri[1])) || {};
} catch(e) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
if (payload.v != '2') {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
/* Unsupported protocol */
} else if (payload.net === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
return null;
} else if (payload.net === 'quic' && ((payload.type && payload.type !== 'none') || payload.path || !features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
/*
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (payload.aid && int(payload.aid) !== 0) {
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
* return null;
* }
*/
const config = {
label: payload.ps ? urldecode(payload.ps) : null,
type: 'vmess',
address: payload.add,
port: payload.port,
uuid: payload.id,
vmess_alterid: payload.aid,
vmess_encrypt: payload.scy || 'auto',
vmess_global_padding: '1',
transport: (payload.net !== 'tcp') ? payload.net : null,
tls: (payload.tls === 'tls') ? '1' : '0',
tls_sni: payload.sni || payload.host,
tls_alpn: payload.alpn ? split(payload.alpn, ',') : null,
tls_utls: features.with_utls ? payload.fp : null
};
switch (payload.net) {
case 'grpc':
config.grpc_servicename = payload.path;
break;
case 'h2':
case 'tcp':
if (payload.net === 'h2' || payload.type === 'http') {
config.transport = 'http';
config.http_host = payload.host ? split(payload.host, ',') : null;
config.http_path = payload.path;
}
break;
case 'httpupgrade':
config.httpupgrade_host = payload.host;
config.http_path = payload.path;
break;
case 'ws':
config.ws_host = payload.host;
config.ws_path = payload.path;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
export function parse_uri(uri, features, log) {
if (!features) features = {};
if (!log) log = function() {};
let config;
if (type(uri) === 'object') {
if (uri.nodetype === 'sip008')
config = parse_sip008_uri(uri);
} else if (type(uri) === 'string') {
switch (split(trim(uri), '://')[0]) {
case 'anytls':
config = parse_anytls_uri(uri, features, log);
break;
case 'http':
case 'https':
config = parse_http_uri(uri, features, log);
break;
case 'hysteria':
config = parse_hysteria_uri(uri, features, log);
break;
case 'hysteria2':
case 'hy2':
config = parse_hysteria2_uri(uri, features, log);
break;
case 'snell':
config = parse_snell_uri(uri, features, log);
break;
case 'socks':
case 'socks4':
case 'socks4a':
case 'socks5':
case 'socks5h':
config = parse_socks_uri(uri, features, log);
break;
case 'ss':
config = parse_ss_uri(uri, features, log);
break;
case 'trojan':
config = parse_trojan_uri(uri, features, log);
break;
case 'tuic':
config = parse_tuic_uri(uri, features, log);
break;
case 'vless':
config = parse_vless_uri(uri, features, log);
break;
case 'vmess':
config = parse_vmess_uri(uri, features, log);
break;
}
}
if (!isEmpty(config)) {
if (config.address)
config.address = replace(config.address, /\[|\]/g, '');
if (!validation('host', config.address) || !validation('port', config.port)) {
log(sprintf('Skipping invalid %s node: %s.', config.type, config.label || 'NULL'));
return null;
} else if (!config.label)
config.label = (validation('ip6addr', config.address) ?
`[${config.address}]` : config.address) + ':' + config.port;
}
return config;
};
@@ -1,46 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
set -eu
NAME="homeproxy"
RUN_DIR="/var/run/$NAME"
REQUEST_FILE="$RUN_DIR/provider-update-id"
LOCK_FILE="$RUN_DIR/provider-update-v2.lock"
LOG_FILE="$RUN_DIR/homeproxy.log"
PROVIDER_ID="${1:-}"
log() {
printf '%s [SUBSCRIBE] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
case "$PROVIDER_ID" in
*[!0-9a-f]*|'') exit 1 ;;
esac
[ "${#PROVIDER_ID}" -eq 32 ] || exit 1
mkdir -p "$RUN_DIR"
if ! lock -n "$LOCK_FILE" 2>/dev/null; then
log "Another provider update is running; waiting for it to finish..."
lock "$LOCK_FILE"
fi
umask 077
printf '%s\n' "$PROVIDER_ID" > "$REQUEST_FILE"
cleanup() {
rm -f "$REQUEST_FILE"
lock -u "$LOCK_FILE" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Give the Clash API response time to reach the panel before HomeProxy restarts.
log "Updating provider $PROVIDER_ID from Clash API..."
sleep 1
if /etc/homeproxy/scripts/update_subscriptions.uc; then
log "Provider $PROVIDER_ID update completed."
else
log "Provider $PROVIDER_ID update failed."
exit 4
fi
@@ -21,65 +21,89 @@ to_upper() {
}
check_list_update() {
local LIST_FILE="$1"
local REPO_NAME="$2"
local REPO_BRANCH="$3"
local REPO_FILE="$4"
local LOCK_FILE="$RUN_DIR/update_resources-$LIST_FILE.lock"
local GITHUB_TOKEN="$(uci -q get homeproxy.config.github_token)"
local listtype="$1"
local listrepo="$2"
local listref="$3"
local listname="$4"
local lock="$RUN_DIR/update_resources-$listtype.lock"
local github_token="$(uci -q get homeproxy.config.github_token)"
local wget="wget --timeout=10 -q"
if ! lock -n "$LOCK_FILE" 2>"/dev/null"; then
log "[$(to_upper "$LIST_FILE")] A task is already running."
exec 200>"$lock"
if ! flock -n 200 &> "/dev/null"; then
log "[$(to_upper "$listtype")] A task is already running."
return 2
fi
local NEW_VER=$(curl -sL ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} "https://api.github.com/repos/$REPO_NAME/releases/latest" | jsonfilter -e "@.tag_name")
if [ -z "$NEW_VER" ]; then
log "[$(to_upper "$LIST_FILE")] Failed to get the latest version, please retry later."
return 1
local github_header_file=""
if [ -n "$github_token" ]; then
github_header_file="$RUN_DIR/.gh_header_${listtype}"
( umask 077; printf 'Authorization: Bearer %s\n' "$github_token" > "$github_header_file" )
trap "[ -n \"$github_header_file\" ] && rm -f \"$github_header_file\"" EXIT INT TERM
fi
local OLD_VER=$(cat "$RESOURCES_DIR/$LIST_FILE.ver" 2>/dev/null || echo "NOT FOUND")
if [ "$OLD_VER" = "$NEW_VER" ]; then
log "[$(to_upper "$LIST_FILE")] Current version: $NEW_VER."
log "[$(to_upper "$LIST_FILE")] You're already at the latest version."
local list_info="$($wget ${github_header_file:+--header-file=$github_header_file} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
local wget_exit=$?
[ -n "$github_header_file" ] && rm -f "$github_header_file"
trap - EXIT INT TERM
if [ $wget_exit -ne 0 ]; then
log "[$(to_upper "$listtype")] Failed to fetch version info (wget exit $wget_exit)."
return 1
fi
local list_sha="$(echo -e "$list_info" | jsonfilter -qe "@[0].sha")"
local list_date="$(echo -e "$list_info" | jsonfilter -qe "@[0].commit.committer.date" | cut -d 'T' -f1)"
if [ -z "$list_sha" ]; then
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
return 1
fi
local list_ver="${list_date:+$list_date }$list_sha"
local local_list_ver="$(cat "$RESOURCES_DIR/$listtype.ver" 2>"/dev/null" || echo "NOT_FOUND")"
local local_list_sha="${local_list_ver##* }"
local local_list_disp="${local_list_ver%% *}"
if [ "$local_list_sha" = "$list_sha" ]; then
[ "$local_list_ver" = "$local_list_sha" ] && [ -n "$list_date" ] && \
echo -e "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
log "[$(to_upper "$listtype")] Current version: ${list_ver%% *}."
log "[$(to_upper "$listtype")] You're already at the latest version."
return 3
else
log "[$(to_upper "$LIST_FILE")] Local version: $OLD_VER, latest version: $NEW_VER."
log "[$(to_upper "$listtype")] Local version: $local_list_disp, latest version: ${list_ver%% *}."
fi
if ! curl -sL -o "$RUN_DIR/$REPO_FILE" "https://cdn.jsdelivr.net/gh/$REPO_NAME@$REPO_BRANCH/$REPO_FILE" || [ ! -s "$RUN_DIR/$REPO_FILE" ]; then
rm -f "$RUN_DIR/$REPO_FILE"
log "[$(to_upper "$LIST_FILE")] Update failed."
if ! $wget "https://fastly.jsdelivr.net/gh/$listrepo@$list_sha/$listname" -O "$RUN_DIR/$listname" || [ ! -s "$RUN_DIR/$listname" ]; then
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Download failed."
return 1
fi
mv -f "$RUN_DIR/$REPO_FILE" "$RESOURCES_DIR/$LIST_FILE.${REPO_FILE##*.}"
echo -e "$NEW_VER" > "$RESOURCES_DIR/$LIST_FILE.ver"
log "[$(to_upper "$LIST_FILE")] Successfully updated."
if mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"; then
echo -e "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
log "[$(to_upper "$listtype")] Successfully updated."
else
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Failed to install update (mv failed)."
return 1
fi
return 0
}
case "$1" in
"china_ip4")
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "cncidr.txt" && \
sed -i "/IP-CIDR6,/d; s/IP-CIDR,//g" "$RESOURCES_DIR/china_ip4.txt"
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
;;
"china_ip6")
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "cncidr.txt" && \
sed -i "/IP-CIDR,/d; s/IP-CIDR6,//g" "$RESOURCES_DIR/china_ip6.txt"
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv6.txt"
;;
"gfw_list")
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "gfw.txt" && \
sed -i "s/^\.//g" "$RESOURCES_DIR/gfw_list.txt"
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
;;
"china_list")
check_list_update "$1" "Loyalsoldier/surge-rules" "release" "direct.txt" && \
sed -i "s/^\.//g" "$RESOURCES_DIR/china_list.txt"
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \
sed -i -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt"
;;
*)
echo -e "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
@@ -1,10 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
CACHE_PATH="$(uci -q get homeproxy.cache.path)"
[ -n "$CACHE_PATH" ] || CACHE_PATH="/etc/homeproxy/cache.db"
rm -f "$CACHE_PATH"
/etc/init.d/homeproxy restart
File diff suppressed because it is too large. Load diff
+176 -150
View File
@@ -15,94 +15,60 @@ HP_DIR="/etc/homeproxy"
RUN_DIR="/var/run/homeproxy"
LOG_PATH="$RUN_DIR/homeproxy.log"
# we don't know which is the default server, just take the first one
DNSMASQ_UCI_CONFIG="$(uci -q show "dhcp.@dnsmasq[0]" | awk 'NR==1 {split($0, conf, /[.=]/); print conf[2]}')"
if [ -f "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG" ]; then
DNSMASQ_DIR="$(awk -F '=' '/^conf-dir=/ {print $2}' "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG")/dnsmasq-homeproxy.d"
else
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
fi
# fw4 chain/set inventory; single source of truth shared with firewall_post.ut
[ -f "$HP_DIR/scripts/fw4_names.sh" ] && . "$HP_DIR/scripts/fw4_names.sh"
log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
}
init_logs() {
mkdir -p "$RUN_DIR"
mkdir -p "$HP_DIR/run/ui"
touch "$LOG_PATH" "$RUN_DIR/sing-box-c.log" "$RUN_DIR/sing-box-s.log"
chown -R sing-box:sing-box "$RUN_DIR" 2>"/dev/null"
chown -R sing-box:sing-box "$HP_DIR/run" 2>"/dev/null"
}
# Locate the directory dnsmasq reads the homeproxy snippets from. The UCI
# section name of the dnsmasq config is part of /tmp/etc/dnsmasq.conf.<name>,
# but `uci get` cannot return a section name (there is no `__name__`), so it is
# derived from the first `uci show` line. Either step can fail on an unusual
# setup; fall back to the generic directory and say so in the log instead of
# silently writing dnsmasq snippets nobody reads.
DNSMASQ_UCI_CONFIG="$(uci -q show "dhcp.@dnsmasq[0]" 2>"/dev/null" | head -n1)"
DNSMASQ_UCI_CONFIG="${DNSMASQ_UCI_CONFIG#*.}"
DNSMASQ_UCI_CONFIG="${DNSMASQ_UCI_CONFIG%%=*}"
valid_update_cron() {
echo "$1" | grep -Eq '^([0-9]|[0-5][0-9]) ([0-9]|[01][0-9]|2[0-3]) \* \* ([0-7]|\*)$'
}
DNSMASQ_DIR=""
if [ -n "$DNSMASQ_UCI_CONFIG" ] && [ -f "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG" ]; then
DNSMASQ_CONF_DIR="$(awk -F '=' '/^conf-dir=/ {print $2; exit}' "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG")"
[ -n "$DNSMASQ_CONF_DIR" ] && DNSMASQ_DIR="$DNSMASQ_CONF_DIR/dnsmasq-homeproxy.d"
fi
has_ruleset_cron() {
printf '%s\n' "$ruleset_cron_list" | grep -Fxq -- "$1"
}
if [ -z "$DNSMASQ_DIR" ]; then
log "WARNING: unable to derive the dnsmasq conf-dir (section '${DNSMASQ_UCI_CONFIG:-unknown}'), using /tmp/dnsmasq.d/dnsmasq-homeproxy.d."
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
fi
add_ruleset_cron() {
local section="$1"
local enabled auto_update type update_time
config_get_bool enabled "$section" "enabled" "0"
config_get_bool auto_update "$section" "auto_update" "1"
config_get type "$section" "type" "local"
config_get update_time "$section" "update_interval" ""
[ "$enabled" = "1" ] || return
[ "$auto_update" = "1" ] || return
[ "$type" = "remote" ] || return
[ -n "$update_time" ] || return
valid_update_cron "$update_time" || return
has_ruleset_cron "$update_time" && return
echo -e "$update_time sh $HP_DIR/scripts/update_rulesets.sh #${CONF}_autosetup" >> "/etc/crontabs/root"
ruleset_cron_list="${ruleset_cron_list}
${update_time}"
cron_reload="1"
}
setup_auto_update_cron() {
local cron_reload="0"
mkdir -p "/etc/crontabs"
touch "/etc/crontabs/root"
grep -q "#${CONF}_autosetup" "/etc/crontabs/root" 2>"/dev/null" && cron_reload="1"
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
local auto_update auto_update_time
config_get_bool auto_update "subscription" "auto_update" "0"
if [ "$auto_update" = "1" ]; then
config_get auto_update_time "subscription" "auto_update_time" "0 0 * * *"
if valid_update_cron "$auto_update_time"; then
echo -e "$auto_update_time sh $HP_DIR/scripts/update_crond.sh #${CONF}_autosetup" >> "/etc/crontabs/root"
cron_reload="1"
fi
fi
local ruleset_cron_list=""
config_foreach add_ruleset_cron "ruleset"
[ "$cron_reload" = "1" ] && /etc/init.d/cron restart
restore_upnp_mappings() {
[ -x /etc/init.d/miniupnpd ] && /etc/init.d/miniupnpd enabled && /etc/init.d/miniupnpd running \
&& /etc/init.d/miniupnpd restart >"/dev/null" 2>&1
}
start_service() {
init_logs
config_load "$CONF"
setup_auto_update_cron
local sb_ver sb_major sb_minor
sb_ver="$(sing-box version -n 2>/dev/null)"
if [ -z "$sb_ver" ]; then
log "Error: cannot detect sing-box version, abort."
return 1
fi
sb_major="${sb_ver%%.*}"
sb_minor="${sb_ver#*.}"
sb_minor="${sb_minor%%.*}"
if [ "$sb_major" -lt 1 ] || { [ "$sb_major" -eq 1 ] && [ "$sb_minor" -lt 14 ]; }; then
log "Error: sing-box >= 1.14.0 required, found ${sb_ver}."
return 1
fi
local routing_mode proxy_mode
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
local cache_path
config_get cache_path "cache" "path" "$HP_DIR/cache.db"
mkdir -p "$(dirname "$cache_path")"
[ -e "$cache_path" ] || touch "$cache_path"
chown sing-box:sing-box "$cache_path" 2>"/dev/null"
local outbound_node
if [ "$routing_mode" != "custom" ]; then
config_get outbound_node "config" "main_node" "nil"
@@ -114,11 +80,28 @@ start_service() {
config_get_bool server_enabled "server" "enabled" "0"
if [ "$outbound_node" = "nil" ] && [ "$server_enabled" = "0" ]; then
log "Neither client nor server is configured. Service not started."
return 1
fi
mkdir -p "$RUN_DIR"
local disable_gso=0
uname -r | grep -Eq "^6\.6" && disable_gso=1
if [ "$outbound_node" != "nil" ]; then
# Generate/Validate client config
local wan_waited=0
while ! { ip route show default 2>"/dev/null" | grep -q . \
|| ip -6 route show default 2>"/dev/null" | grep -q . \
|| ifstatus wan 2>"/dev/null" | grep -q '"up": true'; }; do
wan_waited=$((wan_waited + 1))
if [ "$wan_waited" -ge 60 ]; then
log "WARNING: No network route after 60s, starting sing-box anyway."
break
fi
sleep 1
done
ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"
if [ ! -e "$RUN_DIR/sing-box-c.json" ]; then
@@ -129,15 +112,26 @@ start_service() {
return 1
fi
# DNSMasq rules
local auto_update auto_update_time
config_get_bool auto_update "subscription" "auto_update" "0"
if [ "$auto_update" = "1" ]; then
config_get auto_update_time "subscription" "auto_update_time" "2"
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null" \
|| log "Warning: failed to drop the previous auto-update cron entry."
echo -e "0 $auto_update_time * * * $HP_DIR/scripts/update_crond.sh #${CONF}_autosetup" >> "/etc/crontabs/root" \
|| log "Warning: failed to install the auto-update cron entry."
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
fi
local ipv6_support dns_port
config_get_bool ipv6_support "config" "ipv6_support" "0"
config_get dns_port "infra" "dns_port" "5333"
mkdir -p "$DNSMASQ_DIR"
echo -e "conf-dir=$DNSMASQ_DIR" > "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf"
mkdir -p "$DNSMASQ_DIR" || log "Warning: failed to create ${DNSMASQ_DIR}."
echo -e "conf-dir=$DNSMASQ_DIR" > "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" \
|| log "Warning: failed to write the dnsmasq conf-dir file."
case "$routing_mode" in
"bypass_mainland_china"|"custom"|"global")
cat <<-EOF >> "$DNSMASQ_DIR/redirect-dns.conf"
cat <<-EOF > "$DNSMASQ_DIR/redirect-dns.conf"
no-poll
no-resolv
server=127.0.0.1#$dns_port
@@ -161,7 +155,9 @@ start_service() {
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \
"$HP_DIR/resources/proxy_list.txt" > "$DNSMASQ_DIR/proxy_list.conf"
fi
# Setup routing table
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|| log "Warning: failed to restart dnsmasq, DNS-based routing may be stale."
local table_mark
config_get table_mark "infra" "table_mark" "100"
case "$proxy_mode" in
@@ -172,12 +168,16 @@ start_service() {
local tproxy_mark
config_get tproxy_mark "infra" "tproxy_mark" "101"
ip rule add fwmark "$tproxy_mark" table "$table_mark"
ip route add local 0.0.0.0/0 dev lo table "$table_mark"
ip rule add fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null" \
|| log "Error: failed to add the tproxy ip rule (fwmark ${tproxy_mark}, table ${table_mark})."
ip route add local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null" \
|| log "Error: failed to add the tproxy local route (table ${table_mark})."
if [ "$ipv6_support" -eq "1" ]; then
ip -6 rule add fwmark "$tproxy_mark" table "$table_mark"
ip -6 route add local ::/0 dev lo table "$table_mark"
ip -6 rule add fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null" \
|| log "Error: failed to add the tproxy IPv6 rule (fwmark ${tproxy_mark}, table ${table_mark})."
ip -6 route add local ::/0 dev lo table "$table_mark" 2>"/dev/null" \
|| log "Error: failed to add the tproxy IPv6 local route (table ${table_mark})."
fi
fi
;;
@@ -186,33 +186,38 @@ start_service() {
config_get tun_name "infra" "tun_name" "singtun0"
config_get tun_mark "infra" "tun_mark" "102"
ip tuntap add mode tun user root name "$tun_name"
ip tuntap add mode tun user root name "$tun_name" 2>"/dev/null" \
|| log "Error: failed to create the TUN device ${tun_name}."
sleep 1s
ip link set "$tun_name" up
ip link set "$tun_name" up 2>"/dev/null" \
|| log "Error: failed to bring up the TUN device ${tun_name}."
ip route replace default dev "$tun_name" table "$table_mark"
ip rule add fwmark "$tun_mark" lookup "$table_mark"
ip route replace default dev "$tun_name" table "$table_mark" 2>"/dev/null" \
|| log "Error: failed to set the TUN default route (table ${table_mark})."
ip rule add fwmark "$tun_mark" lookup "$table_mark" 2>"/dev/null" \
|| log "Error: failed to add the TUN ip rule (fwmark ${tun_mark}, table ${table_mark})."
ip -6 route replace default dev "$tun_name" table "$table_mark"
ip -6 rule add fwmark "$tun_mark" lookup "$table_mark"
if [ "$ipv6_support" -eq "1" ]; then
ip -6 route replace default dev "$tun_name" table "$table_mark" 2>"/dev/null" \
|| log "Error: failed to set the TUN IPv6 default route (table ${table_mark})."
ip -6 rule add fwmark "$tun_mark" lookup "$table_mark" 2>"/dev/null" \
|| log "Error: failed to add the TUN IPv6 rule (fwmark ${tun_mark}, table ${table_mark})."
fi
;;
esac
# sing-box (client)
procd_open_instance "sing-box-c"
procd_set_param command "$PROG"
procd_append_param command run --config "$RUN_DIR/sing-box-c.json"
# QUIC-GO GSO is broken on kernel 6.6 currently
uname -r | grep -Eq "^6\.6" && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
if [ -x "/sbin/ujail" ] && [ "$routing_mode" != "custom" ] && ! grep -Eq '"type": "(wireguard|tun)"' "$RUN_DIR/sing-box-c.json"; then
procd_add_jail "sing-box-c" log procfs
procd_add_jail_mount "$RUN_DIR/sing-box-c.json"
procd_add_jail_mount_rw "$RUN_DIR/sing-box-c.log"
procd_add_jail_mount_rw "$cache_path"
procd_add_jail_mount_rw "$HP_DIR/run/"
[ "$routing_mode" != "bypass_mainland_china" ] || procd_add_jail_mount_rw "$HP_DIR/cache.db"
procd_add_jail_mount "$HP_DIR/certs/"
procd_add_jail_mount "/etc/ssl/"
procd_add_jail_mount "/etc/localtime"
@@ -232,7 +237,6 @@ start_service() {
fi
if [ "$server_enabled" = "1" ]; then
# Generate/Validate server config
ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"
if [ ! -e "$RUN_DIR/sing-box-s.json" ]; then
@@ -243,14 +247,12 @@ start_service() {
return 1
fi
# sing-box (server)
procd_open_instance "sing-box-s"
procd_set_param command "$PROG"
procd_append_param command run --config "$RUN_DIR/sing-box-s.json"
# QUIC-GO GSO is broken on kernel 6.6 currently
uname -r | grep -Eq "^6\.6" && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
if [ -x "/sbin/ujail" ]; then
procd_add_jail "sing-box-s" log procfs
@@ -275,43 +277,46 @@ start_service() {
procd_close_instance
fi
# log-cleaner
procd_open_instance "log-cleaner"
procd_set_param command "$HP_DIR/scripts/clean_log.sh"
procd_set_param respawn
procd_close_instance
case "$routing_mode" in
"bypass_mainland_china")
[ -e "$HP_DIR/cache.db" ] || touch "$HP_DIR/cache.db" \
|| log "Warning: failed to create ${HP_DIR}/cache.db."
;;
"custom")
# Prepare ruleset directory
[ -d "$HP_DIR/ruleset" ] || mkdir -p "$HP_DIR/ruleset"
[ -d "$HP_DIR/ruleset" ] || mkdir -p "$HP_DIR/ruleset" \
|| log "Warning: failed to create ${HP_DIR}/ruleset."
;;
esac
[ "$outbound_node" = "nil" ] || : > "$RUN_DIR/sing-box-c.log"
[ "$outbound_node" = "nil" ] || echo > "$RUN_DIR/sing-box-c.log"
if [ "$server_enabled" = "1" ]; then
: > "$RUN_DIR/sing-box-s.log"
mkdir -p "$HP_DIR/certs"
echo > "$RUN_DIR/sing-box-s.log"
mkdir -p "$HP_DIR/certs" || log "Warning: failed to create ${HP_DIR}/certs."
fi
# Update permissions for ujail
chown -R sing-box:sing-box "$RUN_DIR"
chown sing-box:sing-box "$RUN_DIR"/sing-box-*.json "$RUN_DIR"/sing-box-*.log "$HP_DIR"/cache.db 2>"/dev/null" \
|| log "Warning: failed to change the ownership of the runtime files to sing-box."
# Setup firewall
ucode "$HP_DIR/scripts/firewall_pre.uc"
[ "$outbound_node" = "nil" ] || utpl -S "$HP_DIR/scripts/firewall_post.ut" > "$RUN_DIR/fw4_post.nft"
fw4 reload >"/dev/null" 2>&1
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
ucode "$HP_DIR/scripts/firewall_pre.uc" 2>"/dev/null" || log "Error: firewall pre-script failed."
if [ "$outbound_node" != "nil" ]; then
utpl -S "$HP_DIR/scripts/firewall_post.ut" > "$RUN_DIR/fw4_post.nft" 2>"/dev/null" || log "Error: firewall post-script failed."
fi
fw4 reload >"/dev/null" 2>&1 || log "Error: fw4 reload failed."
restore_upnp_mappings
log "sing-box $(sing-box version -n) started."
log "sing-box $(sing-box version -n 2>/dev/null || echo "unknown") started."
}
stop_service() {
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
/etc/init.d/cron restart >"/dev/null" 2>&1
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null" \
|| log "Warning: failed to drop the auto-update cron entry."
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
# Setup firewall
# Load config
config_load "$CONF"
local table_mark tproxy_mark tun_mark tun_name
config_get table_mark "infra" "table_mark" "100"
@@ -319,61 +324,53 @@ stop_service() {
config_get tun_mark "infra" "tun_mark" "102"
config_get tun_name "infra" "tun_name" "singtun0"
# Tproxy
ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
# ip rule del removes a single matching rule, so a repeated start (or an
# upgrade that starts before the old instance stopped) leaves duplicates
# behind. Delete every match instead of only the first one.
while ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
while ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
# TUN
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
while ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"
while ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
# Nftables rules
for i in "homeproxy_dstnat_redir" "homeproxy_output_redir" \
"homeproxy_redirect" "homeproxy_redirect_proxy" \
"homeproxy_redirect_proxy_port" "homeproxy_redirect_lanac" \
"homeproxy_mangle_prerouting" "homeproxy_mangle_output" \
"homeproxy_mangle_tproxy" "homeproxy_mangle_tproxy_port" \
"homeproxy_mangle_tproxy_lanac" "homeproxy_mangle_mark" \
"homeproxy_mangle_tun" "homeproxy_mangle_tun_mark"; do
nft flush chain inet fw4 "$i"
nft delete chain inet fw4 "$i"
done 2>"/dev/null"
for i in "homeproxy_local_addr_v4" "homeproxy_local_addr_v6" \
"homeproxy_gfw_list_v4" "homeproxy_gfw_list_v6" \
"homeproxy_mainland_addr_v4" "homeproxy_mainland_addr_v6" \
"homeproxy_wan_proxy_addr_v4" "homeproxy_wan_proxy_addr_v6" \
"homeproxy_wan_direct_addr_v4" "homeproxy_wan_direct_addr_v6" \
"homeproxy_routing_port"; do
nft flush set inet fw4 "$i"
nft delete set inet fw4 "$i"
done 2>"/dev/null"
echo 2>"/dev/null" > "$RUN_DIR/fw4_forward.nft"
echo 2>"/dev/null" > "$RUN_DIR/fw4_input.nft"
echo 2>"/dev/null" > "$RUN_DIR/fw4_post.nft"
fw4 reload >"/dev/null" 2>&1
# Flush and delete every fw4 object homeproxy owns. Deleting one at a
# time matters: `nft -f` batches are atomic, so a single object that
# does not exist in the current proxy mode would roll back the rest.
local fw4_name
for fw4_name in $HP_FW4_CHAINS; do
nft flush chain inet fw4 "$fw4_name" 2>"/dev/null"
nft delete chain inet fw4 "$fw4_name" 2>"/dev/null"
done
for fw4_name in $HP_FW4_SETS; do
nft flush set inet fw4 "$fw4_name" 2>"/dev/null"
nft delete set inet fw4 "$fw4_name" 2>"/dev/null"
done
: > "$RUN_DIR/fw4_forward.nft"
: > "$RUN_DIR/fw4_input.nft"
: > "$RUN_DIR/fw4_post.nft"
fw4 reload >"/dev/null" 2>&1 || log "Warning: fw4 reload failed during stop."
restore_upnp_mappings
# Remove DNS hijack
rm -rf "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" "$DNSMASQ_DIR"
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|| log "Warning: failed to restart dnsmasq after removing the homeproxy snippets."
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-s.json"
init_logs
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-c.log" \
"$RUN_DIR/sing-box-s.json" "$RUN_DIR/sing-box-s.log"
log "Service stopped."
}
service_stopped() {
# Load config
config_load "$CONF"
local tun_name
config_get tun_name "infra" "tun_name" "singtun0"
# TUN
ip link set "$tun_name" down 2>"/dev/null"
ip tuntap del mode tun name "$tun_name" 2>"/dev/null"
}
@@ -381,6 +378,35 @@ service_stopped() {
reload_service() {
log "Reloading service..."
config_load "$CONF"
local routing_mode
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
local outbound_node
if [ "$routing_mode" != "custom" ]; then
config_get outbound_node "config" "main_node" "nil"
else
config_get outbound_node "routing" "default_outbound" "nil"
fi
if [ "$outbound_node" != "nil" ]; then
ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"
if ! "$PROG" check --config "$RUN_DIR/sing-box-c.json" 2>>"$LOG_PATH"; then
log "Error: new client config is invalid, reload aborted."
return 1
fi
fi
local server_enabled
config_get_bool server_enabled "server" "enabled" "0"
if [ "$server_enabled" = "1" ]; then
ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"
if ! "$PROG" check --config "$RUN_DIR/sing-box-s.json" 2>>"$LOG_PATH"; then
log "Error: new server config is invalid, reload aborted."
return 1
fi
fi
stop
start
}
-30
View File
@@ -3,36 +3,6 @@
[ -f "/www/luci-static/resources/icons/loading.gif" ] && \
sed -i "s,/loading.svg,/loading.gif,g" "/www/luci-static/resources/view/homeproxy/status.js"
uci -q set luci.main.lang='zh_cn'
uci -q commit luci
api_secret="$(uci -q get homeproxy.clash_api.secret)"
if [ -z "$api_secret" ] || [ "$api_secret" = "homeproxy" ]; then
api_secret="$(tr -dc 'A-Za-z0-9' < /dev/urandom 2>"/dev/null" | head -c 16)"
[ -n "$api_secret" ] || api_secret="$(awk 'BEGIN{srand(); printf "%06d", int(rand() * 1000000)}')"
uci -q set homeproxy.clash_api.secret="$api_secret"
uci -q commit homeproxy
fi
legacy_panel_url="https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip"
legacy_panel_proxy_url="https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip"
old_panel_url="https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
old_panel_proxy_url="https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
default_panel_url="https://gh-proxy.com/https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
panel_url="$(uci -q get homeproxy.clash_api.external_ui_download_url)"
if [ -z "$panel_url" ] || [ "$panel_url" = "$legacy_panel_url" ] || [ "$panel_url" = "$legacy_panel_proxy_url" ] || [ "$panel_url" = "$old_panel_proxy_url" ]; then
uci -q set homeproxy.clash_api.external_ui_download_url="$default_panel_url"
uci -q commit homeproxy
fi
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>"/dev/null"
mkdir -p /var/run/homeproxy
touch /var/run/homeproxy/homeproxy.log \
/var/run/homeproxy/sing-box-c.log \
/var/run/homeproxy/sing-box-s.log
chown -R sing-box:sing-box /var/run/homeproxy 2>"/dev/null"
uci -q batch <<-EOF >"/dev/null"
delete firewall.homeproxy_pre
View File
File mode changed.
@@ -9,7 +9,6 @@
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
},
"ubus": {
"rc": [ "*" ],
"service": [ "list" ],
"luci.homeproxy": [ "*" ]
},
@@ -17,11 +16,7 @@
},
"write": {
"file": {
"/tmp/homeproxy_certificate.tmp": [ "write" ],
"/tmp/homeproxy_panel.tmp": [ "write" ]
},
"ubus": {
"rc": [ "*" ]
"/tmp/homeproxy_certificate.tmp": [ "write" ]
},
"uci": [ "homeproxy" ]
}
File diff suppressed because it is too large. Load diff
+60
View File
@@ -0,0 +1,60 @@
# luci-app-homeproxy tests
Automated checks for the pieces that `sing-box check` cannot validate: the
share-link parsers, the UCI→JSON generators and the LuCI form definitions.
## Running
```sh
tests/run.sh
```
* The **LuCI form snapshots** run locally and only need `node`. They re-render
the node/server views through a mock LuCI runtime and diff the result against
`tests/snapshots/*.json`.
* The **ucode tests** need `ucode`; the generator cases additionally run
`sing-box check`, so they need sing-box ≥ 1.14 as well (available on an
OpenWrt/ImmortalWrt target). When the host running `tests/run.sh` has neither,
the checkout is copied over ssh to `$HP_TEST_HOST` (default
`root@192.168.1.1`) and the tests run there:
```sh
HP_TEST_HOST=root@192.168.1.1 tests/run.sh
HP_TEST_DIR=/tmp/hp-tests tests/run.sh
```
`tests/run.sh` exits non-zero if anything fails. The ucode part can also be run
on its own on a target:
```sh
sh tests/ucode/run.sh "$PWD"
```
## What is covered
| Path | Checks |
| --- | --- |
| `tests/i18n-coverage.py` | Reports how many `po/templates/homeproxy.pot` strings have a non-fuzzy, non-empty `po/zh_Hans/homeproxy.po` translation. Technical tokens that stay as-is are listed in `tests/i18n-ignore.txt`; anything else missing produces a warning annotation (`--warn-below 100` by default) and a job-summary entry. Run by `.github/workflows/i18n.yml` on push/PR and before a release. |
| `tests/luci-form-snapshot.js` | Dumps every option (name, kind, title, description, depends, values, datatype/default/…) of the node and server views. Diffs against `tests/snapshots/{node,server}.json`, so a refactor that changes a field or its visibility fails. |
| `tests/ucode/test_homeproxy_utils.uc` | `executeCommand()` return shape, stderr/exit-code capture, binary detection, and a descriptor-leak check (200 calls). |
| `tests/ucode/test_homeproxy_utils_inject.uc` | The failure path of `executeCommand()`: the script stages a copy of `homeproxy.uc` whose `system()` call is replaced by `die()`, then checks that the exception still propagates and that neither descriptor leaks. |
| `tests/ucode/test_fw4_names.sh` | Keeps the fw4 chain/set inventory in `scripts/fw4_names.sh` (used by `init.d/homeproxy` to clean up on stop) in sync with the objects declared in `scripts/firewall_post.ut`. |
| `tests/ucode/test_parse_uri.uc` | Per-scheme assertions on the `parse_uri()` result: anytls, http/https, hysteria, hysteria2/hy2, snell, socks(4/4a/5/5h), shadowsocks (SIP002 base64 + plain + plugin, Shadowrocket), trojan (ws/grpc), tuic, vless (reality/ws/http/httpupgrade), vmess (ws/h2/httpupgrade/grpc) and SIP008 objects, plus the rejection paths (unsupported kcp/quic, no QUIC support, invalid port, unknown scheme). |
| `tests/ucode/test_generators.sh` | Runs `generate_client.uc`/`generate_server.uc` against the UCI fixtures in `tests/fixtures/generators/` inside a scratch directory (the `uci` cursor and `HP_DIR`/`RUN_DIR` are redirected) and validates each result with `sing-box check`. |
`tests/ucode/mocks/homeproxy.uc` is a test double for the real module: only
`validation()` is stubbed (the real one runs `/sbin/validate_data`, which does
not exist outside OpenWrt), everything else is a copy of the production code.
## Adding cases
* New share link → add an `expect_fields(...)` case to `test_parse_uri.uc`.
* New protocol option that reaches the generator → extend the fixtures in
`tests/fixtures/generators/`, keeping values schema-valid so `sing-box check`
still passes.
* Intentional LuCI form change → regenerate the snapshots:
```sh
node tests/luci-form-snapshot.js . node > tests/snapshots/node.json
node tests/luci-form-snapshot.js . server > tests/snapshots/server.json
```
+170
View File
@@ -0,0 +1,170 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for generate_client.uc: a node of every supported
# protocol reachable through a single urltest main node, so one run exercises
# all outbound builders (TLS, transport, reality, mux, obfuscation).
config homeproxy 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'tun'
option ipv6_support '0'
option log_level 'error'
option main_node 'urltest'
option main_udp_node 'nil'
option dns_server 'https://cloudflare-dns.com/dns-query'
list main_urltest_nodes 'n_anytls'
list main_urltest_nodes 'n_vless_reality_ws'
list main_urltest_nodes 'n_vmess_http'
list main_urltest_nodes 'n_trojan_grpc'
list main_urltest_nodes 'n_ss'
list main_urltest_nodes 'n_hy2'
list main_urltest_nodes 'n_snell'
list main_urltest_nodes 'n_http'
list main_urltest_nodes 'n_socks'
list main_urltest_nodes 'n_shadowtls'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option self_mark '100'
option tun_mark '102'
option table_mark '100'
option ntp_server 'nil'
config node 'n_anytls'
option label 'anytls'
option type 'anytls'
option address 'a.example.com'
option port '443'
option password 'secret'
option tls '1'
option tls_sni 'a.example.com'
option tls_insecure '0'
option anytls_idle_session_check_interval '30'
option anytls_idle_session_timeout '30'
option anytls_min_idle_session '0'
config node 'n_vless_reality_ws'
option label 'vless-reality-ws'
option type 'vless'
option address 'b.example.com'
option port '443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'ws'
option ws_host 'b.example.com'
option ws_path '/ws?ed=2048'
option tls '1'
option tls_sni 'b.example.com'
option tls_reality '1'
option tls_reality_public_key '4UAg690QziXeelpUJoAmXiim0gfQESSiJkNfvF-o8Uw'
option tls_reality_short_id 'abcd'
option tls_utls 'chrome'
option vless_flow 'xtls-rprx-vision'
option packet_encoding 'xudp'
config node 'n_vmess_http'
option label 'vmess-http'
option type 'vmess'
option address 'c.example.com'
option port '80'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'http'
option http_host 'c.example.com'
option http_path '/path'
option http_method 'GET'
option vmess_alterid '0'
option vmess_encrypt 'auto'
option vmess_global_padding '1'
option tls '0'
option packet_encoding 'packetaddr'
config node 'n_trojan_grpc'
option label 'trojan-grpc'
option type 'trojan'
option address 'd.example.com'
option port '443'
option password 'secret'
option transport 'grpc'
option grpc_servicename 'gs'
option grpc_permit_without_stream '1'
option tls '1'
option tls_sni 'd.example.com'
option tls_handshake_timeout '15'
option tls_min_version '1.2'
option tls_max_version '1.3'
option tls_cipher_suites 'TLS_AES_128_GCM_SHA256'
config node 'n_ss'
option label 'ss'
option type 'shadowsocks'
option address 'e.example.com'
option port '8388'
option shadowsocks_encrypt_method 'aes-256-gcm'
option password 'secret'
option multiplex '1'
option multiplex_protocol 'h2mux'
option multiplex_padding '1'
option multiplex_max_connections '4'
option udp_over_tcp '1'
option udp_over_tcp_version '2'
config node 'n_hy2'
option label 'hy2'
option type 'hysteria2'
option address 'f.example.com'
option port '443'
option password 'secret'
option tls '1'
option tls_sni 'f.example.com'
option hysteria_obfs_type 'salamander'
option hysteria_obfs_password 'obfspass'
option hysteria_up_mbps '100'
option hysteria_down_mbps '500'
option hysteria_hopping_port '20000:30000'
option hysteria_hop_interval '30'
option hysteria_bbr_profile 'conservative'
config node 'n_snell'
option label 'snell'
option type 'snell'
option address 'g.example.com'
option port '443'
option password 'psk123456789012'
option snell_version '4'
option snell_obfs_mode 'http'
option snell_obfs_host 'bing.com'
option snell_reuse '1'
option snell_userkey 'ukey'
config node 'n_http'
option label 'http'
option type 'http'
option address 'h.example.com'
option port '8080'
option username 'u'
option password 'p'
option tls '0'
config node 'n_socks'
option label 'socks'
option type 'socks'
option address 'i.example.com'
option port '1080'
option username 'u'
option password 'p'
option socks_version '5'
config node 'n_shadowtls'
option label 'shadowtls'
option type 'shadowtls'
option address 'j.example.com'
option port '443'
option password 'secret'
option shadowtls_version '3'
option tls '1'
option tls_sni 'j.example.com'
+56
View File
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for routing_mode='custom'. It reproduces two crashes
# that only the custom path could hit:
#
# * a direct node with an override forces generate_outbound() to write into
# direct_overrides, and the routing rule below forces get_direct_override()
# to read it -- so the table has to be declared before both helpers;
# * a local rule-set must not carry the legacy download_detour field, which
# sing-box 1.14 rejects on anything but a remote rule-set.
#
# __RULESET_DIR__ is substituted by tests/ucode/test_generators.sh with a
# directory holding a real compiled .srs file.
config homeproxy 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '0'
option log_level 'error'
config homeproxy 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
# direct node: exercises the direct_overrides write path
config node 'n_direct'
option label 'direct-node'
option type 'direct'
option override_address '1.2.3.4'
option override_port '8443'
# routing node + rule: exercises get_direct_override() reading direct_overrides
config routing_node 'rn_direct'
option enabled '1'
option label 'rn-direct'
option node 'n_direct'
config routing_rule 'rr_direct'
option enabled '1'
option label 'rr-direct'
option outbound 'rn_direct'
option action 'route'
# local rule-set: must never carry download_detour
config ruleset 'rs_local'
option enabled '1'
option label 'local-rs'
option type 'local'
option format 'binary'
option path '__RULESET_DIR__/test.srs'
+112
View File
@@ -0,0 +1,112 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Regression fixture for generate_server.uc: one inbound per protocol family,
# covering the shared TLS/transport builders (ACME provider, ECH key, REALITY,
# WebSocket/gRPC/HTTP transports) on the server side.
config homeproxy 'server'
option enabled '1'
option log_level 'warn'
config server 's_vless_ws'
option enabled '1'
option type 'vless'
option listen_port '8443'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option transport 'ws'
option ws_host 'a.example.com'
option ws_path '/ws'
option websocket_early_data '2048'
option tls '1'
option tls_sni 'a.example.com'
option tls_acme '1'
option tls_acme_domain 'a.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'a.example.com'
option tls_acme_key_type 'p256'
option tls_alpn 'h2'
option tls_min_version '1.2'
config server 's_anytls'
option enabled '1'
option type 'anytls'
option listen_port '8444'
option password 'secret'
option anytls_padding_scheme 'stop=8'
option tls '1'
option tls_sni 'b.example.com'
option tls_acme '1'
option tls_acme_domain 'b.example.com'
option tls_acme_provider 'zerossl'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'b.example.com'
config server 's_hy2'
option enabled '1'
option type 'hysteria2'
option listen_port '8445'
option password 'secret'
option hysteria_up_mbps '100'
option hysteria_down_mbps '500'
option hysteria_obfs_type 'salamander'
option hysteria_obfs_password 'obfspass'
option hysteria_masquerade 'https://example.com'
option tls '1'
option tls_sni 'c.example.com'
option tls_acme '1'
option tls_acme_domain 'c.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'c.example.com'
config server 's_trojan_grpc'
option enabled '1'
option type 'trojan'
option listen_port '8446'
option password 'secret'
option transport 'grpc'
option grpc_servicename 'gs'
option multiplex '1'
option multiplex_padding '1'
option tls '1'
option tls_sni 'd.example.com'
option tls_acme '1'
option tls_acme_domain 'd.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'd.example.com'
config server 's_ss'
option enabled '1'
option type 'shadowsocks'
option listen_port '8447'
option network 'tcp'
option shadowsocks_encrypt_method 'aes-256-gcm'
option password 'secret'
option udp_timeout '300'
config server 's_snell'
option enabled '1'
option type 'snell'
option listen_port '8448'
option password 'psk123456789012'
option snell_version '5'
option snell_obfs_mode 'http'
config server 's_tuic'
option enabled '1'
option type 'tuic'
option listen_port '8449'
option uuid '3af88561-9c69-4b19-8f7e-f08d580bc339'
option password 'secret'
option tuic_congestion_control 'bbr'
option tuic_auth_timeout '3'
option tuic_heartbeat '10'
option tls '1'
option tls_sni 'e.example.com'
option tls_acme '1'
option tls_acme_domain 'e.example.com'
option tls_acme_provider 'letsencrypt'
option tls_acme_email 'admin@example.com'
option tls_acme_dsn 'e.example.com'
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Report how much of the gettext template (po/templates/homeproxy.pot) is
# translated in po/zh_Hans/homeproxy.po. Missing, empty and fuzzy entries all
# count as untranslated. The check only warns by default so it does not block
# a build; pass --fail-below to turn the warning into a failure.
#
# Usage:
# tests/i18n-coverage.py # warn below 95%
# tests/i18n-coverage.py --warn-below 90
# tests/i18n-coverage.py --fail-below 95
# tests/i18n-coverage.py --list 20
import argparse
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE = os.path.join(ROOT, "po/templates/homeproxy.pot")
TRANSLATION = os.path.join(ROOT, "po/zh_Hans/homeproxy.po")
IGNORE = os.path.join(ROOT, "tests/i18n-ignore.txt")
def load_ignore(path):
"""msgids that are deliberately not translated (technical tokens)."""
if not os.path.exists(path):
return set()
ignored = set()
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if line and not line.startswith("#"):
ignored.add(line)
return ignored
def unquote(value):
value = value.strip()
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
return value[1:-1]
return value
def parse_po(path):
"""Return {msgid: {'msgstr': str, 'fuzzy': bool}} for non-empty msgids."""
entries = {}
msgid = None
msgstr = ""
state = None
pending_fuzzy = False
entry_fuzzy = False
def flush():
if msgid:
entries[msgid] = {"msgstr": msgstr, "fuzzy": entry_fuzzy}
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.rstrip("\n")
if line.startswith("#,"):
if "fuzzy" in line:
pending_fuzzy = True
continue
if line.startswith("#"):
continue
if line.startswith("msgid "):
flush()
msgid = unquote(line[len("msgid "):])
msgstr = ""
entry_fuzzy = pending_fuzzy
pending_fuzzy = False
state = "msgid"
elif line.startswith("msgstr "):
msgstr = unquote(line[len("msgstr "):])
state = "msgstr"
elif line.startswith('"'):
if state == "msgid":
msgid += unquote(line)
elif state == "msgstr":
msgstr += unquote(line)
flush()
return entries
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--warn-below", type=float, default=100.0,
help="warn when coverage is below this percentage (default: 100)")
parser.add_argument("--fail-below", type=float, default=None,
help="exit non-zero when coverage is below this percentage")
parser.add_argument("--list", type=int, default=10,
help="list at most this many untranslated entries (default: 10)")
args = parser.parse_args()
template = parse_po(TEMPLATE)
translation = parse_po(TRANSLATION)
ignored = load_ignore(IGNORE)
total = 0
untranslated = []
for msgid in template:
if msgid in ignored:
continue
total += 1
entry = translation.get(msgid)
if not entry or entry["fuzzy"] or not entry["msgstr"].strip():
untranslated.append(msgid)
translated = total - len(untranslated)
coverage = (translated / total * 100) if total else 100.0
print(f"zh_Hans translation coverage: {translated}/{total} ({coverage:.1f}%)"
f", {len(ignored)} msgid(s) ignored by tests/i18n-ignore.txt")
if untranslated and args.list:
print(f"{len(untranslated)} untranslated entries, first {min(args.list, len(untranslated))}:")
for msgid in untranslated[:args.list]:
print(f" - {msgid}")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as handle:
handle.write("## zh_Hans translation coverage\n\n")
handle.write(f"**{translated}/{total} ({coverage:.1f}%)** — "
f"{len(untranslated)} untranslated entry/entries.\n\n")
if untranslated:
handle.write("<details><summary>Untranslated</summary>\n\n")
for msgid in untranslated:
handle.write(f"- `{msgid}`\n")
handle.write("\n</details>\n")
if args.fail_below is not None and coverage < args.fail_below:
print(f"::error title=Translation coverage::zh_Hans coverage {coverage:.1f}% "
f"is below {args.fail_below}%")
return 1
if coverage < args.warn_below:
print(f"::warning title=Translation coverage::zh_Hans coverage {coverage:.1f}% "
f"is below {args.warn_below}% ({len(untranslated)} untranslated entry/entries)")
return 0
if __name__ == "__main__":
sys.exit(main())
+13
View File
@@ -0,0 +1,13 @@
# msgids that are intentionally left untranslated (protocol/technical tokens
# whose zh_Hans form is the same as the source). One msgid per line, matching
# the unquoted template string; '#' starts a comment.
http
unsafe-raw
unshaped
v4
v5
v6
wrong-ack
wrong-checksum
wrong-md5
wrong-timestamp
@@ -0,0 +1,214 @@
#!/usr/bin/env node
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Dump the LuCI form definition of the homeproxy node/client/server views as
* JSON, using a minimal mock of the LuCI client runtime. It is meant to be run
* against two checkouts (e.g. HEAD and the working tree) and the resulting
* snapshots diffed, so that a pure refactor can be proven not to change any
* option name, dependency, default or label.
*
* Usage: node tests/luci-form-snapshot.js <repo-root> [node|server]
*/
'use strict';
const fs = require('fs');
const path = require('path');
/* --- LuCI runtime mock start ------------------------------------------- */
String.prototype.format = function (...args) {
const values = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
let i = 0;
return this.replace(/%[sdj%]/g, (m) => m === '%%' ? '%' : (i < values.length ? String(values[i++]) : m));
};
function E(tag, attrs, children) {
return { tag: tag, attrs: attrs, children: children };
}
function _(text) { return text; }
const L = {
bind: (fn, self, ...args) => fn.bind(self, ...args),
resolveDefault: (promise, fallback) => promise.catch(() => fallback),
ui: { hideModal: () => {}, addNotification: () => {}, changes: { apply: () => {} } },
env: { paths: {} }
};
let sectionSeq = 0;
class Option {
constructor(kind, name, title, description) {
this.__kind = kind;
this.__name = name;
this.__title = title;
this.__description = description;
this.__depends = [];
this.__values = [];
}
depends(...args) { this.__depends.push(args.length === 1 ? args[0] : args); }
value(value, label) { this.__values.push([ value, label === undefined ? null : label ]); }
toJSON() {
const props = {};
for (const key of Object.keys(this).sort()) {
if (key.startsWith('__') || key === 'subsection')
continue;
const value = this[key];
if (typeof value === 'function' || value === undefined)
continue;
props[key] = value;
}
return {
kind: this.__kind,
name: this.__name,
title: this.__title,
description: this.__description === undefined ? null : this.__description,
depends: this.__depends,
values: this.__values,
props: props
};
}
}
class Section {
constructor(kind, name) {
this.__kind = kind;
this.__name = name;
this.__id = `${kind}:${name || ''}#${sectionSeq++}`;
this.__options = [];
this.__tabs = [];
if (kind === 'SectionValue')
this.subsection = new Section('GridSection', name);
}
option(kind, name, title, description) {
const kindName = kind && kind.__name__ ? kind.__name__ : String(kind);
const option = new Option(kindName, name, title, description);
if (kindName === 'SectionValue')
option.subsection = new Section('GridSection', name);
this.__options.push(option);
return option;
}
tab(id, title) { this.__tabs.push([ id, title ]); }
taboption(_tab, ...args) { return this.option(...args); }
toJSON() {
return {
id: this.__id,
kind: this.__kind,
name: this.__name,
tabs: this.__tabs,
options: this.__options.map((o) => o.toJSON()),
subsection: this.subsection ? this.subsection.toJSON() : null
};
}
}
const classes = [ 'Value', 'ListValue', 'Flag', 'DynamicList', 'MultiValue', 'TextValue', 'Button', 'TypedSection',
'NamedSection', 'GridSection', 'SectionValue' ];
const form = {};
for (const name of classes) {
form[name] = class {};
form[name].__name__ = name;
}
form.DynamicList.extend = function (obj) {
const cls = class extends form.DynamicList {};
Object.assign(cls.prototype, obj);
cls.__name__ = obj.__name__ || 'DynamicList';
return cls;
};
form.Value.extend = function (obj) {
const cls = class extends form.Value {};
Object.assign(cls.prototype, obj);
cls.__name__ = obj.__name__ || 'Value';
return cls;
};
form.Map = class {
constructor(title) { this.title = title; this.sections = []; }
section(kind, name) {
const section = new Section(kind && kind.__name__ ? kind.__name__ : String(kind), name);
this.sections.push(section);
return section;
}
render() { return { sections: this.sections.map((s) => s.toJSON()) }; }
};
const uci = {
load: () => Promise.resolve(),
get: () => undefined,
get_first: () => null,
get_all: () => ({}),
sections: () => {},
foreach: () => {},
set: () => {}, add: () => 'section', remove: () => {}, save: () => Promise.resolve()
};
const ui = {
addNotification: () => {},
showModal: () => {},
hideModal: () => {},
uploadFile: () => Promise.resolve({}),
addValidator: () => {},
Textarea: class { render() { return E('textarea'); } getValue() { return ''; } },
createHandlerFn: (self, fn) => fn
};
const baseclass = { extend: (obj) => obj };
const view = { extend: (obj) => obj };
const poll = { add: () => {} };
const rpc = { declare: () => () => Promise.resolve({}) };
const fsMock = { exec_direct: () => Promise.resolve(''), read: () => Promise.resolve(''), write: () => Promise.resolve() };
const widgets = { DeviceSelect: class {} };
const deps = {
baseclass, form, fs: fsMock, rpc, uci, ui, view, poll,
'luci.http': { urldecode: (s) => s, urlencode: (s) => s, urldecode_params: () => ({}) },
'luci.sys': { init_action: () => {} },
'tools.widgets': widgets
};
/* --- LuCI runtime mock end --------------------------------------------- */
function loadLuciModule(file, extraDeps) {
let src = fs.readFileSync(file, 'utf8');
src = src.replace(/^'require ([^']+) as (\w+)';$/gm,
(_m, mod, alias) => `const ${alias} = __deps[${JSON.stringify(mod)}];`);
src = src.replace(/^'require ([^']+)';$/gm,
(_m, mod) => `const ${mod.split('.').pop().replace(/[^\w]/g, '_')} = __deps[${JSON.stringify(mod)}];`);
/* eslint-disable-next-line no-new-func */
const factory = new Function('__deps', '_', 'E', 'L', src);
return factory(Object.assign({}, deps, extraDeps), _, E, L);
}
function main() {
const root = process.argv[2];
const target = process.argv[3] || 'node';
if (!root)
throw new Error('usage: luci-form-snapshot.js <repo-root> [node|server]');
const viewDir = path.join(root, 'htdocs/luci-static/resources');
const homeproxy = loadLuciModule(path.join(viewDir, 'homeproxy.js'), {});
const mod = loadLuciModule(path.join(viewDir, 'view/homeproxy', target + '.js'), { homeproxy });
const features = {
version: '1.14.0',
with_acme: true, with_grpc: true, with_gvisor: true, with_quic: true,
with_utls: true, with_wireguard: true, hp_has_tcp_brutal: true
};
const rendered = mod.render([ undefined, features ]);
process.stdout.write(JSON.stringify(rendered, null, '\t') + '\n');
}
main();
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# One-shot entry point for the test suite:
#
# tests/run.sh
#
# Translation coverage and the LuCI form snapshots run locally (they only need
# python3 / node). The ucode tests run locally when ucode and sing-box are
# available, otherwise the checkout is copied to $HP_TEST_HOST and run there.
#
# HP_TEST_HOST=root@192.168.1.1 tests/run.sh
# HP_TEST_DIR=/tmp/hp-tests tests/run.sh
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${HP_TEST_HOST:-root@192.168.1.1}"
REMOTE_DIR="${HP_TEST_DIR:-/tmp/hp-tests}"
FAILED=0
echo "== zh_Hans translation coverage =="
python3 "$ROOT/tests/i18n-coverage.py" --warn-below 100
echo "== LuCI form snapshots =="
for target in node server; do
if ! node "$ROOT/tests/luci-form-snapshot.js" "$ROOT" "$target" > "/tmp/hp-snapshot-$target.json" 2> "/tmp/hp-snapshot-$target.err"; then
echo "FAIL: could not render the $target form"
cat "/tmp/hp-snapshot-$target.err"
FAILED=1
continue
fi
if diff -q "$ROOT/tests/snapshots/$target.json" "/tmp/hp-snapshot-$target.json" > "/dev/null"; then
echo "PASS: $target form snapshot"
else
echo "FAIL: $target form snapshot changed:"
diff "$ROOT/tests/snapshots/$target.json" "/tmp/hp-snapshot-$target.json" | head -40
FAILED=1
fi
done
echo "== ucode tests =="
if command -v ucode > "/dev/null" 2>&1 && command -v sing-box > "/dev/null" 2>&1; then
sh "$ROOT/tests/ucode/run.sh" "$ROOT" || FAILED=1
else
echo "(no local ucode/sing-box, executing on $HOST)"
if tar czf - -C "$ROOT" --exclude .git --exclude node_modules . \
| ssh "$HOST" "rm -rf $REMOTE_DIR && mkdir -p $REMOTE_DIR && tar xzf - -C $REMOTE_DIR"; then
ssh "$HOST" "sh $REMOTE_DIR/tests/ucode/run.sh $REMOTE_DIR" || FAILED=1
else
echo "FAIL: could not stage the tests on $HOST"
FAILED=1
fi
fi
if [ "$FAILED" -eq 0 ]; then
echo "ALL TESTS PASSED"
else
echo "SOME TESTS FAILED"
fi
exit $FAILED
@@ -0,0 +1,293 @@
{
"sections": [
{
"id": "NamedSection:subscription#0",
"kind": "NamedSection",
"name": "subscription",
"tabs": [
[
"node",
"Nodes"
],
[
"subscription",
"Subscriptions"
]
],
"options": [
{
"kind": "SectionValue",
"name": "_node",
"description": "node",
"depends": [],
"values": [],
"props": {}
},
{
"kind": "Flag",
"name": "auto_update",
"title": "Auto update",
"description": "Auto update subscriptions and geodata.",
"depends": [],
"values": [],
"props": {
"rmempty": false
}
},
{
"kind": "ListValue",
"name": "auto_update_time",
"title": "Update time",
"description": null,
"depends": [
[
"auto_update",
"1"
]
],
"values": [
[
0,
"0:00"
],
[
1,
"1:00"
],
[
2,
"2:00"
],
[
3,
"3:00"
],
[
4,
"4:00"
],
[
5,
"5:00"
],
[
6,
"6:00"
],
[
7,
"7:00"
],
[
8,
"8:00"
],
[
9,
"9:00"
],
[
10,
"10:00"
],
[
11,
"11:00"
],
[
12,
"12:00"
],
[
13,
"13:00"
],
[
14,
"14:00"
],
[
15,
"15:00"
],
[
16,
"16:00"
],
[
17,
"17:00"
],
[
18,
"18:00"
],
[
19,
"19:00"
],
[
20,
"20:00"
],
[
21,
"21:00"
],
[
22,
"22:00"
],
[
23,
"23:00"
]
],
"props": {
"default": "2"
}
},
{
"kind": "Flag",
"name": "update_via_proxy",
"title": "Update via proxy",
"description": "Update subscriptions via proxy.",
"depends": [],
"values": [],
"props": {
"rmempty": false
}
},
{
"kind": "DynamicList",
"name": "subscription_url",
"title": "Subscription URL-s",
"description": "Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.",
"depends": [],
"values": [],
"props": {}
},
{
"kind": "ListValue",
"name": "filter_nodes",
"title": "Filter nodes",
"description": "Drop/keep specific nodes from subscriptions.",
"depends": [],
"values": [
[
"disabled",
"Disable"
],
[
"blacklist",
"Blacklist mode"
],
[
"whitelist",
"Whitelist mode"
]
],
"props": {
"default": "disabled",
"rmempty": false
}
},
{
"kind": "DynamicList",
"name": "filter_keywords",
"title": "Filter keywords",
"description": "Drop/keep nodes that contain the specific keywords. <a target=\"_blank\" href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions\">Regex</a> is supported.",
"depends": [
{
"filter_nodes": "disabled",
"!reverse": true
}
],
"values": [],
"props": {
"rmempty": false
}
},
{
"kind": "Value",
"name": "user_agent",
"title": "User-Agent",
"description": null,
"depends": [],
"values": [],
"props": {
"placeholder": "Wget/1.21 (HomeProxy, like v2rayN)"
}
},
{
"kind": "Flag",
"name": "allow_insecure",
"title": "Allow insecure",
"description": "Allow insecure connection by default when add nodes from subscriptions.<br/>This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!",
"depends": [],
"values": [],
"props": {
"rmempty": false
}
},
{
"kind": "ListValue",
"name": "packet_encoding",
"title": "Default packet encoding",
"description": null,
"depends": [],
"values": [
[
"",
"none"
],
[
"packetaddr",
"packet addr (v2ray-core v5+)"
],
[
"xudp",
"Xudp (Xray-core)"
]
],
"props": {}
},
{
"kind": "Button",
"name": "_save_subscriptions",
"title": "Save subscriptions settings",
"description": "NOTE: Save current settings before updating subscriptions.",
"depends": [],
"values": [],
"props": {
"inputstyle": "apply",
"inputtitle": "Save current settings"
}
},
{
"kind": "Button",
"name": "_update_subscriptions",
"title": "Update nodes from subscriptions",
"description": null,
"depends": [],
"values": [],
"props": {
"inputstyle": "apply"
}
},
{
"kind": "Button",
"name": "_remove_subscriptions",
"title": "Remove all nodes from subscriptions",
"description": null,
"depends": [],
"values": [],
"props": {
"inputstyle": "reset"
}
}
],
"subsection": null
}
]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,140 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Test double for root/etc/homeproxy/scripts/homeproxy.uc.
*
* Only `validation()` is stubbed: the real one shells out to
* /sbin/validate_data, which does not exist outside OpenWrt. Everything else
* (isEmpty, decodeBase64Str, parseURL) is a verbatim copy so the parser tests
* exercise the same string handling as production. Keep these in sync when
* homeproxy.uc changes.
*/
import { urldecode_params } from 'luci.http';
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
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);
};
/* Pure-ucode stand-in for /sbin/validate_data (host/port/ip4/ip6/hostname). */
export function validation(datatype, data) {
if (!datatype || !data)
return null;
switch (datatype) {
case 'port':
return match(data, /^\d+$/) != null && int(data) >= 0 && int(data) <= 65535;
case 'ip4addr':
if (match(data, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) == null)
return false;
for (let octet in split(data, '.'))
if (int(octet) > 255)
return false;
return true;
case 'ip6addr':
return match(data, /^[0-9a-fA-F:]+$/) != null && match(data, /::.*::/) == null;
case 'hostname':
return match(data, /^[A-Za-z0-9_][A-Za-z0-9_%-.]*[A-Za-z0-9]$/) != null ||
match(data, /^[A-Za-z0-9_]$/) != null;
case 'host':
return validation('ip4addr', data) === true ||
validation('ip6addr', data) === true ||
validation('hostname', data) === true;
default:
return null;
}
};
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;
};
+87
View File
@@ -0,0 +1,87 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Run the ucode-level tests: the parse_uri unit tests, the fw4 inventory check
# and the generator regression fixtures. Requires ucode; sing-box is needed for
# the generator cases (they validate the emitted config with `sing-box check`).
#
# Usage: sh tests/ucode/run.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-ucode-tests}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
echo "== ucode syntax check =="
for file in "$ROOT"/root/etc/homeproxy/scripts/*.uc "$ROOT"/root/usr/share/rpcd/ucode/*; do
[ -f "$file" ] || continue
# Modules (with export statements) cannot be compiled as a program; they
# are loaded through `import` below instead.
case "$file" in
*homeproxy.uc|*parse_uri.uc) continue ;;
esac
if ! ucode -c -o "/dev/null" "$file" 2> "/tmp/hp-ucode-syntax.err"; then
echo "FAIL: $file"
head -8 "/tmp/hp-ucode-syntax.err"
FAILED=1
fi
done
for module in homeproxy parse_uri; do
if ! ucode -L "$ROOT/root/etc/homeproxy/scripts" -e "import * as m from \"$module\";" 2> "/tmp/hp-ucode-syntax.err"; then
echo "FAIL: module $module"
head -8 "/tmp/hp-ucode-syntax.err"
FAILED=1
fi
done
[ "$FAILED" -eq 0 ] && echo "PASS: all ucode sources compile"
echo "== fw4 chain/set inventory =="
sh "$ROOT/tests/ucode/test_fw4_names.sh" "$ROOT" || FAILED=1
echo "== firewall template rendering =="
sh "$ROOT/tests/ucode/test_firewall_template.sh" "$ROOT" || FAILED=1
echo "== parse_uri unit tests =="
rm -rf "$WORK/parse_uri"
mkdir -p "$WORK/parse_uri"
cp "$ROOT/root/etc/homeproxy/scripts/parse_uri.uc" "$WORK/parse_uri/"
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$WORK/parse_uri/"
cp "$ROOT/tests/ucode/test_parse_uri.uc" "$WORK/parse_uri/"
if ( cd "$WORK/parse_uri" && ucode test_parse_uri.uc ); then
echo "PASS: parse_uri unit tests"
else
echo "FAIL: parse_uri unit tests"
FAILED=1
fi
echo "== homeproxy helper tests =="
rm -rf "$WORK/homeproxy"
mkdir -p "$WORK/homeproxy"
cp "$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" "$WORK/homeproxy/"
cp "$ROOT/tests/ucode/test_homeproxy_utils.uc" "$WORK/homeproxy/"
if ( cd "$WORK/homeproxy" && ucode test_homeproxy_utils.uc ); then
echo "PASS: executeCommand() regression tests"
else
echo "FAIL: executeCommand() regression tests"
FAILED=1
fi
echo "== executeCommand() failure-path test =="
rm -rf "$WORK/homeproxy_inject"
mkdir -p "$WORK/homeproxy_inject"
sed 's|const exitcode = system(.*);|die("injected failure");|' \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/homeproxy_inject/homeproxy.uc"
cp "$ROOT/tests/ucode/test_homeproxy_utils_inject.uc" "$WORK/homeproxy_inject/"
if ( cd "$WORK/homeproxy_inject" && ucode test_homeproxy_utils_inject.uc 2>"/dev/null" ); then
echo "PASS: executeCommand() failure path"
else
echo "FAIL: executeCommand() failure path"
FAILED=1
fi
echo "== generator regression tests =="
sh "$ROOT/tests/ucode/test_generators.sh" "$ROOT" "$WORK/generators" || FAILED=1
exit $FAILED
@@ -0,0 +1,47 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Render firewall_post.ut and verify the homeproxy fw4 objects survive as real
# nft statements.
#
# The template opens with `{%-`, which trims the whitespace in front of it: any
# text placed above that tag loses its trailing newline and is glued onto the
# first generated statement, turning it into a comment. That silently dropped
# every homeproxy chain/set from the fw4 ruleset once, so keep this check.
#
# Usage: sh tests/ucode/test_firewall_template.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
OUT="$(mktemp)"
FAILED=0
if ! utpl -S "$ROOT/root/etc/homeproxy/scripts/firewall_post.ut" > "$OUT" 2>"/tmp/hp-fw4tpl.err"; then
echo "FAIL: could not render firewall_post.ut"
head -5 "/tmp/hp-fw4tpl.err"
rm -f "$OUT"
exit 1
fi
# Declared unconditionally by the template, so they must always be present as
# standalone statements.
for name in homeproxy_local_addr_v4 homeproxy_wan_proxy_addr_v4; do
if ! grep -q "^set $name {" "$OUT"; then
echo "FAIL: 'set $name {' is missing from the rendered firewall template"
grep -n "$name" "$OUT" | head -2
FAILED=1
fi
done
# A statement glued onto the preceding comment looks like
# "# <comment text>.set homeproxy_x {" -- exactly the failure mode above.
if grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" > "/dev/null"; then
echo "FAIL: an nft statement is glued onto a comment line"
grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" | head -2
FAILED=1
fi
[ "$FAILED" -eq 0 ] && echo "PASS: firewall_post.ut renders homeproxy objects as standalone nft statements"
rm -f "$OUT"
exit $FAILED
@@ -0,0 +1,67 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# The fw4 chain/set inventory in scripts/fw4_names.sh must stay in sync with
# the objects declared by scripts/firewall_post.ut: the first drives the
# cleanup in init.d/homeproxy, the second creates them. A drift means either a
# leftover object or a delete of something that never existed (which used to
# abort the whole cleanup batch).
#
# Usage: sh tests/ucode/test_fw4_names.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
NAMES="$ROOT/root/etc/homeproxy/scripts/fw4_names.sh"
POST="$ROOT/root/etc/homeproxy/scripts/firewall_post.ut"
PRE="$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc"
FAILED=0
# shellcheck source=/dev/null
. "$NAMES"
check_group() {
group="$1"
list="$(printf '%s\n' $2 | tr '\n' ' ')"
declared="$(printf '%s\n' $3 | tr '\n' ' ')"
missing=""
extra=""
for name in $declared; do
case " $list " in
*" $name "*) ;;
*) missing="$missing $name" ;;
esac
done
for name in $list; do
case " $declared " in
*" $name "*) ;;
*) extra="$extra $name" ;;
esac
done
if [ -z "$missing$extra" ]; then
echo "PASS: $group inventory matches firewall_post.ut"
else
[ -n "$missing" ] && echo "FAIL: $group declared in firewall_post.ut but missing from fw4_names.sh:$missing"
[ -n "$extra" ] && echo "FAIL: $group listed in fw4_names.sh but not declared in firewall_post.ut:$extra"
FAILED=1
fi
}
post_chains="$(grep -oE '^chain homeproxy_[a-z0-9_]+' "$POST" | awk '{print $2}' | sort -u)"
post_sets="$(grep -oE '^set homeproxy_[a-z0-9_]+' "$POST" | awk '{print $2}' | sort -u)"
check_group "chain" "$HP_FW4_CHAINS" "$post_chains"
check_group "set" "$HP_FW4_SETS" "$post_sets"
# firewall_pre.uc only writes forward/input accept rules; it must not own any
# chain/set of its own.
pre_refs="$(grep -oE 'homeproxy_[a-z0-9_]+' "$PRE" | sort -u)"
if [ -n "$pre_refs" ]; then
echo "NOTE: firewall_pre.uc references fw4 objects:$pre_refs"
fi
exit $FAILED
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Run generate_client.uc / generate_server.uc against the UCI fixtures and let
# sing-box validate the result. The generators read /etc/config and write
# /var/run, so this script materialises an isolated copy of them: homeproxy.uc
# is rewritten to point HP_DIR/RUN_DIR at a scratch directory, and the uci
# cursor is pointed at the fixture config.
#
# Usage: sh tests/ucode/test_generators.sh <repo-root> [work-dir]
ROOT="${1:-.}"
WORK="${2:-/tmp/hp-generator-test}"
ROOT="$(cd "$ROOT" && pwd)"
FAILED=0
run_case() {
name="$1"
fixture="$2"
generator="$3"
outfile="$4"
dir="$WORK/$name"
rm -rf "$dir"
mkdir -p "$dir/config" "$dir/run" "$dir/scripts" "$dir/resources" "$dir/ruleset"
: > "$dir/resources/direct_list.txt"
: > "$dir/resources/proxy_list.txt"
if grep -q "__RULESET_DIR__" "$fixture"; then
# The fixture needs a real local rule-set on disk.
printf '%s' '{"version":1,"rules":[{"domain_suffix":["example.com"]}]}' > "$dir/ruleset/src.json"
if ! sing-box rule-set compile "$dir/ruleset/src.json" -o "$dir/ruleset/test.srs"; then
echo "FAIL: $name: could not compile the local rule-set fixture"
FAILED=1
return
fi
sed "s#__RULESET_DIR__#$dir/ruleset#" "$fixture" > "$dir/config/homeproxy"
else
cp "$fixture" "$dir/config/homeproxy"
fi
sed -e "s#^export const HP_DIR = '/etc/homeproxy';#export const HP_DIR = '$dir';#" \
-e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$dir/run';#" \
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$dir/scripts/homeproxy.uc"
sed -e "s#const uci = cursor();#const uci = cursor('$dir/config');#" \
"$ROOT/root/etc/homeproxy/scripts/$generator" > "$dir/scripts/$generator"
if ! ( cd "$dir/scripts" && ucode "$generator" ); then
echo "FAIL: $name: $generator exited non-zero"
FAILED=1
return
fi
if [ ! -f "$dir/run/$outfile" ]; then
echo "FAIL: $name: $outfile was not generated"
FAILED=1
return
fi
if ! sing-box check --config "$dir/run/$outfile"; then
echo "FAIL: $name: sing-box rejected the generated $outfile"
FAILED=1
return
fi
echo "PASS: $name ($(wc -c < "$dir/run/$outfile") bytes)"
}
run_case client "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json
# The preset remote rule-sets must be fetched through the node. A direct
# download depends on the CDN staying reachable from mainland China and fails
# intermittently under DNS pollution, which shows up as "open connection to
# <ip>:443 using outbound/direct[direct]: i/o timeout" in sing-box-c.log.
if grep -q '"detour": "direct-out"' "$WORK/client/run/sing-box-c.json"; then
echo "FAIL: client: a remote rule-set would still be downloaded directly"
FAILED=1
fi
if ! grep -q '"detour": "main-out"' "$WORK/client/run/sing-box-c.json"; then
echo "FAIL: client: no remote rule-set is configured to download through main-out"
FAILED=1
fi
run_case custom "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json
run_case server "$ROOT/tests/fixtures/generators/server.uci" generate_server.uc sing-box-s.json
exit $FAILED
@@ -0,0 +1,80 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Regression tests for homeproxy.uc helpers, focused on executeCommand():
* return shape, stderr/exit-code capture, binary detection and (most
* importantly) that the temporary descriptors are closed on every run.
*/
'use strict';
import { lsdir } from 'fs';
import { executeCommand, isValidPEM, shellQuote } from 'homeproxy';
let failures = 0,
checks = 0;
function expect(name, actual, want) {
checks++;
if (sprintf('%J', actual) !== sprintf('%J', want)) {
printf('FAIL %s: expected %J, got %J\n', name, want, actual);
failures++;
}
}
function fd_count() {
let n = 0;
for (let _entry in lsdir('/proc/self/fd'))
n++;
return n;
}
/* successful command */
const ok = executeCommand('echo', 'hello');
expect('ok.exitcode', ok.exitcode, 0);
expect('ok.stdout', ok.stdout, 'hello\n');
expect('ok.stderr', ok.stderr, '');
expect('ok.binary', ok.binary, false);
expect('ok.command', ok.command, 'echo hello');
/* failing command keeps stderr and the exit code */
const bad = executeCommand('sh', '-c', shellQuote('echo oops >&2; exit 3'));
expect('bad.exitcode', bad.exitcode, 3);
expect('bad.stderr', bad.stderr, 'oops\n');
expect('bad.stdout', bad.stdout, '');
/* nondescript exit code */
expect('false.exitcode', executeCommand('false').exitcode, 1);
/* binary output is detected and withheld */
const bin = executeCommand('sh', '-c', shellQuote('printf "\\001\\002\\003"'));
expect('bin.binary', bin.binary, true);
expect('bin.stdout', bin.stdout, null);
/* isValidPEM(): certificate vs private key, boundaries and body */
const pem_cert = '-----BEGIN CERTIFICATE-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END CERTIFICATE-----';
const pem_key = '-----BEGIN RSA PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END RSA PRIVATE KEY-----';
expect('pem.cert', isValidPEM(pem_cert, false), true);
expect('pem.cert-as-key', isValidPEM(pem_cert, true), false);
expect('pem.key', isValidPEM(pem_key, true), true);
expect('pem.key-as-cert', isValidPEM(pem_key, false), false);
expect('pem.garbage', isValidPEM('not a pem at all', false), false);
expect('pem.empty', isValidPEM('', false), false);
/* descriptors must not leak across calls */
const before = fd_count();
for (let i = 0; i < 200; i++)
executeCommand('true');
const after = fd_count();
checks++;
if (after > before) {
printf('FAIL descriptor leak: %d -> %d after 200 calls\n', before, after);
failures++;
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,53 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Exceptional-path test for executeCommand(): the copy of homeproxy.uc staged
* next to this file has its `system()` call replaced by `die()`, so every call
* throws before the descriptors are read. ucode has no `finally`, so this
* checks that the catch block still closes both descriptors.
*/
'use strict';
import { lsdir } from 'fs';
import { executeCommand } from 'homeproxy';
let failures = 0;
function fd_count() {
let n = 0;
for (let _entry in lsdir('/proc/self/fd'))
n++;
return n;
}
const before = fd_count();
let caught = 0;
for (let i = 0; i < 50; i++) {
try {
executeCommand('true');
} catch (e) {
caught++;
}
}
const after = fd_count();
if (caught !== 50) {
printf('FAIL: expected 50 propagated exceptions, got %d\n', caught);
failures++;
}
if (after > before) {
printf('FAIL descriptor leak on the exceptional path: %d -> %d\n', before, after);
failures++;
}
if (failures === 0)
printf('PASS: injected failure propagated and both descriptors were closed\n');
exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,170 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* Unit tests for the share-link parsers in parse_uri.uc. Run through
* tests/ucode/run.sh, which stages parse_uri.uc next to the mocked
* homeproxy.uc.
*/
'use strict';
import { parse_uri } from 'parse_uri';
const FEATURES = { with_quic: true, with_utls: true };
const NO_QUIC = { with_quic: false, with_utls: true };
const LOG = (..._args) => {};
let failures = 0,
checks = 0;
function expect_fields(name, cfg, expected) {
if (type(cfg) !== 'object') {
printf('FAIL %s: expected an object, got %J\n', name, cfg);
failures++;
return;
}
for (let key in expected) {
checks++;
if (sprintf('%J', cfg[key]) !== sprintf('%J', expected[key])) {
printf('FAIL %s: %s expected %J, got %J\n', name, key, expected[key], cfg[key]);
failures++;
}
}
}
function expect_null(name, cfg) {
checks++;
if (cfg !== null) {
printf('FAIL %s: expected null, got %J\n', name, cfg);
failures++;
}
}
/* anytls */
expect_fields('anytls', parse_uri('anytls://secret@a.example.com:443?sni=a.example.com&insecure=1#AnyTLS-Node', FEATURES, LOG), {
label: 'AnyTLS-Node', type: 'anytls', address: 'a.example.com', port: '443',
password: 'secret', tls: '1', tls_sni: 'a.example.com', tls_insecure: '1'
});
/* http / https */
expect_fields('https', parse_uri('https://user:pass@b.example.com:8443#HTTPS-Node', FEATURES, LOG), {
label: 'HTTPS-Node', type: 'http', address: 'b.example.com', port: '8443',
username: 'user', password: 'pass', tls: '1'
});
expect_fields('http', parse_uri('http://c.example.com:8080#HTTP-Node', FEATURES, LOG), {
label: 'HTTP-Node', type: 'http', address: 'c.example.com', port: '8080', tls: '0'
});
/* hysteria */
expect_fields('hysteria', parse_uri('hysteria://d.example.com:36712?auth=pass&peer=d.example.com&insecure=1&upmbps=100&downmbps=200&alpn=h3&protocol=udp#Hy1', FEATURES, LOG), {
label: 'Hy1', type: 'hysteria', address: 'd.example.com', port: '36712',
hysteria_protocol: 'udp', hysteria_auth_type: 'string', hysteria_auth_payload: 'pass',
hysteria_up_mbps: '100', hysteria_down_mbps: '200',
tls: '1', tls_insecure: '1', tls_sni: 'd.example.com', tls_alpn: 'h3'
});
/* hysteria2 */
expect_fields('hysteria2', parse_uri('hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2', FEATURES, LOG), {
label: 'Hy2', type: 'hysteria2', address: 'e.example.com', port: '443',
password: 'pass', hysteria_obfs_type: 'salamander', hysteria_obfs_password: 'op',
tls: '1', tls_insecure: '0', tls_sni: 'e.example.com'
});
expect_fields('hy2-alias', parse_uri('hy2://pass@e.example.com:443#Hy2Alias', FEATURES, LOG), {
label: 'Hy2Alias', type: 'hysteria2', address: 'e.example.com', port: '443'
});
/* snell */
expect_fields('snell', parse_uri('snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com&reuse=1#Snell', FEATURES, LOG), {
label: 'Snell', type: 'snell', address: 'f.example.com', port: '443',
password: 'pskvalue', snell_version: '4', snell_obfs_mode: 'http',
snell_obfs_host: 'bing.com', snell_reuse: '1'
});
/* socks */
expect_fields('socks5', parse_uri('socks5://user:pass@g.example.com:1080#Socks', FEATURES, LOG), {
label: 'Socks', type: 'socks', address: 'g.example.com', port: '1080',
username: 'user', password: 'pass', socks_version: '5'
});
expect_fields('socks4', parse_uri('socks4://h.example.com:1080#Socks4', FEATURES, LOG), {
socks_version: '4'
});
/* shadowsocks: SIP002 with base64 user info */
expect_fields('ss-sip002-b64', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS-Node', FEATURES, LOG), {
label: 'SS-Node', type: 'shadowsocks', address: 'i.example.com', port: '8388',
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
});
/* shadowsocks: SIP002 with plugin */
expect_fields('ss-sip002-plugin', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@j.example.com:8388?plugin=simple-obfs%3Bobfs%3Dhttp#SS-Plugin', FEATURES, LOG), {
label: 'SS-Plugin', type: 'shadowsocks', address: 'j.example.com', port: '8388',
shadowsocks_plugin: 'obfs-local', shadowsocks_plugin_opts: 'obfs=http'
});
/* shadowsocks: Shadowrocket whole-payload base64 */
expect_fields('ss-shadowrocket', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmRAay5leGFtcGxlLmNvbTo4Mzg4#SS-SR', FEATURES, LOG), {
label: 'SS-SR', type: 'shadowsocks', address: 'k.example.com', port: '8388',
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
});
/* trojan */
expect_fields('trojan-ws', parse_uri('trojan://pass@l.example.com:443?type=ws&host=l.example.com&path=%2Fpath%3Fed%3D2048&sni=l.example.com#TrojanWs', FEATURES, LOG), {
label: 'TrojanWs', type: 'trojan', address: 'l.example.com', port: '443', password: 'pass',
transport: 'ws', ws_host: 'l.example.com', ws_path: '/path',
websocket_early_data: '2048', websocket_early_data_header: 'Sec-WebSocket-Protocol',
tls: '1', tls_sni: 'l.example.com'
});
expect_fields('trojan-grpc', parse_uri('trojan://pass@l.example.com:443?type=grpc&serviceName=gs#TrojanGrpc', FEATURES, LOG), {
transport: 'grpc', grpc_servicename: 'gs'
});
/* tuic */
expect_fields('tuic', parse_uri('tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&udp_relay_mode=native&alpn=h3&sni=m.example.com#Tuic', FEATURES, LOG), {
label: 'Tuic', type: 'tuic', address: 'm.example.com', port: '443',
uuid: 'tuic-uuid', password: 'pass', tuic_congestion_control: 'bbr',
tuic_udp_relay_mode: 'native', tls: '1', tls_sni: 'm.example.com', tls_alpn: [ 'h3' ]
});
/* vless */
expect_fields('vless-reality-grpc', parse_uri('vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&fp=chrome&type=grpc&serviceName=gs&sni=n.example.com&flow=xtls-rprx-vision#Vless', FEATURES, LOG), {
label: 'Vless', type: 'vless', address: 'n.example.com', port: '443', uuid: 'vless-uuid',
transport: 'grpc', grpc_servicename: 'gs', tls: '1', tls_sni: 'n.example.com',
tls_reality: '1', tls_reality_public_key: 'PUBKEY', tls_reality_short_id: 'abcd',
tls_utls: 'chrome', vless_flow: 'xtls-rprx-vision'
});
expect_fields('vless-ws-early-data', parse_uri('vless://vless-uuid@o.example.com:443?security=tls&type=ws&host=o.example.com&path=%2Fws%3Fed%3D1024&sni=o.example.com&alpn=h2%2Chttp%2F1.1#VlessWs', FEATURES, LOG), {
type: 'vless', transport: 'ws', ws_host: 'o.example.com', ws_path: '/ws',
websocket_early_data: '1024', websocket_early_data_header: 'Sec-WebSocket-Protocol',
tls: '1', tls_alpn: [ 'h2', 'http/1.1' ]
});
/* vmess */
expect_fields('vmess-ws', parse_uri('vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0=', FEATURES, LOG), {
label: 'VMess', type: 'vmess', address: 'p.example.com', port: '443',
uuid: '3af88561-9c69-4b19-8f7e-f08d580bc339', vmess_alterid: '0', vmess_encrypt: 'auto',
vmess_global_padding: '1', transport: 'ws', ws_host: 'p.example.com', ws_path: '/ws',
tls: '1', tls_sni: 'p.example.com'
});
/* sip008 object */
expect_fields('sip008', parse_uri({
nodetype: 'sip008', remarks: 'SIP008', server: 'q.example.com', server_port: 8388,
method: 'aes-256-gcm', password: 'pw'
}, FEATURES, LOG), {
label: 'SIP008', type: 'shadowsocks', address: 'q.example.com', port: 8388,
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'pw'
});
/* rejection paths */
expect_null('vless-kcp-unsupported', parse_uri('vless://u@r.example.com:443?type=kcp#Kcp', FEATURES, LOG));
expect_null('hysteria-without-quic', parse_uri('hysteria://s.example.com:443?auth=a#NoQuic', NO_QUIC, LOG));
expect_null('tuic-without-quic', parse_uri('tuic://u:p@s.example.com:443#NoQuic', NO_QUIC, LOG));
expect_null('invalid-port', parse_uri('vless://u@t.example.com:99999?security=tls#BadPort', FEATURES, LOG));
expect_null('unknown-scheme', parse_uri('unknown://u@t.example.com:443', FEATURES, LOG));
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.9.9
PKG_RELEASE:=273
PKG_RELEASE:=274
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -93,15 +93,11 @@ function gen_outbound(flag, node, tag, proxy_table)
end
local remarks = node.remarks
local proxy_tag = nil
local fragment = nil
local record_fragment = nil
local run_socks_instance = true
local proxy_tag, fragment, record_fragment
if proxy_table ~= nil and type(proxy_table) == "table" then
proxy_tag = proxy_table.tag or nil
fragment = (proxy_table.fragment and node.protocol ~= "naive" and not node.hysteria2_realms) and true or nil
record_fragment = (proxy_table.record_fragment and node.protocol ~= "naive" and not node.hysteria2_realms) and true or nil
run_socks_instance = proxy_table.run_socks_instance
end
if node.type ~= "sing-box" then
@@ -110,7 +106,12 @@ function gen_outbound(flag, node, tag, proxy_table)
proxy_tag = "socks <- " .. node_id
else
local new_port
if run_socks_instance then
local run_socks_instance = true
if NO_RUN then
TMP_PORT = TMP_PORT and TMP_PORT + 1 or 3001
new_port = TMP_PORT
run_socks_instance = nil
else
local relay_port = (proxy_tag and node.port) and tostring(node.port) or ""
if relay_port == "" then
local cache = api.get_socks_port_by_cache(node_id)
@@ -140,9 +141,6 @@ function gen_outbound(flag, node, tag, proxy_table)
api.set_socks_port_to_cache(node_id, new_port)
end
end
else
TMP_PORT = TMP_PORT and TMP_PORT + 1 or 3001 -- 导出配置时作为演示
new_port = TMP_PORT
end
if new_port then
node = {
@@ -1163,10 +1161,11 @@ function gen_config(var)
local dns_cache = var["dns_cache"]
local dns_socks_address = var["dns_socks_address"]
local dns_socks_port = var["dns_socks_port"]
local no_run = var["no_run"]
local use_proxy_list = var["use_proxy_list"]
local use_gfw_list = var["use_gfw_list"]
local chn_list = var["chn_list"]
local run_in_global = var["run_in_global"]
NO_RUN = var["no_run"]
local dns_domain_rules = {}
local dns = nil
@@ -1360,7 +1359,9 @@ function gen_config(var)
ut_nodes = _node.urltest_node
end
-- api.log(" - 加载 Sing-Box URLTest 节点【" .. (_node.remarks or "") .. "】,子节点数量:" .. #(ut_nodes or {}))
if not NO_RUN and run_in_global then
api.log(" - 加载 Sing-Box URLTest 节点【" .. (_node.remarks or "") .. "】,子节点数量:" .. #(ut_nodes or {}))
end
local valid_nodes = {}
for i = 1, #(ut_nodes or {}) do
@@ -1375,7 +1376,7 @@ function gen_config(var)
end
end
if is_new_ut_node then
local outboundTag = gen_outbound_get_tag(flag, ut_node_id, ut_node_tag, { fragment = singbox_settings.fragment == "1" or nil, record_fragment = singbox_settings.record_fragment == "1" or nil, run_socks_instance = not no_run })
local outboundTag = gen_outbound_get_tag(flag, ut_node_id, ut_node_tag, { fragment = singbox_settings.fragment == "1" or nil, record_fragment = singbox_settings.record_fragment == "1" or nil })
if outboundTag then
valid_nodes[#valid_nodes + 1] = outboundTag
end
@@ -1478,7 +1479,6 @@ function gen_config(var)
to_node.port = new_port
to_outbound = gen_outbound(node[".name"], to_node, tag, {
tag = tag,
run_socks_instance = not no_run
})
else
to_outbound = gen_outbound(node[".name"], to_node)
@@ -1571,7 +1571,6 @@ function gen_config(var)
local proxy_table = {
fragment = singbox_settings.fragment == "1",
record_fragment = singbox_settings.record_fragment == "1",
run_socks_instance = not no_run,
}
local preproxy_node_id = node[rule_name .. "_proxy_tag"]
if preproxy_node_id == _node_id then preproxy_node_id = nil end
@@ -1855,7 +1854,6 @@ function gen_config(var)
COMMON.default_outbound_tag = gen_outbound_get_tag(flag, node or node_id, nil, {
fragment = singbox_settings.fragment == "1" or nil,
record_fragment = singbox_settings.record_fragment == "1" or nil,
run_socks_instance = not no_run
})
end
@@ -2316,7 +2314,7 @@ function gen_config(var)
routing_mark = 255,
})
for index, value in ipairs(config.outbounds) do
if not value["_flag_proxy_tag"] and not value.detour and value["_id"] and value.server and (value.server_port or value.server_ports) and not no_run then
if not value["_flag_proxy_tag"] and not value.detour and value["_id"] and value.server and (value.server_port or value.server_ports) and not NO_RUN then
sys.call(string.format("echo '%s' >> %s", value["_id"], api.TMP_PATH .. "/direct_node_list"))
end
if not value.detour and not value.bind_interface and value.server then
@@ -2464,7 +2462,7 @@ if arg[1] then
var = jsonc.parse(arg[2])
end
print(func(var))
if (next(GEO_VAR.SITE_TAGS) or next(GEO_VAR.IP_TAGS)) and not no_run then
if (next(GEO_VAR.SITE_TAGS) or next(GEO_VAR.IP_TAGS)) and not NO_RUN then
convert_geofile()
end
end
+15 -18
View File
@@ -43,16 +43,11 @@ function gen_outbound(flag, node, tag, proxy_table)
end
local remarks = node.remarks
local proxy_tag = nil
local dialer_proxy_tag = nil
local fragment = nil
local noise = nil
local run_socks_instance = true
local proxy_tag, dialer_proxy_tag, fragment, noise
if proxy_table ~= nil and type(proxy_table) == "table" then
proxy_tag = proxy_table.tag or nil
fragment = (proxy_table.fragment and not node.hysteria2_realms) and true or nil
noise = (proxy_table.noise and not node.hysteria2_realms) and true or nil
run_socks_instance = proxy_table.run_socks_instance
end
if node.type ~= "Xray" then
@@ -61,7 +56,12 @@ function gen_outbound(flag, node, tag, proxy_table)
node.transport = "raw"
else
local new_port
if run_socks_instance then
local run_socks_instance = true
if NO_RUN then
TMP_PORT = TMP_PORT and TMP_PORT + 1 or 3001
new_port = TMP_PORT
run_socks_instance = nil
else
local relay_port = (proxy_tag and node.port) and tostring(node.port) or ""
if relay_port == "" then
local cache = api.get_socks_port_by_cache(node_id)
@@ -91,9 +91,6 @@ function gen_outbound(flag, node, tag, proxy_table)
api.set_socks_port_to_cache(node_id, new_port)
end
end
else
TMP_PORT = TMP_PORT and TMP_PORT + 1 or 3001 -- 导出配置时作为演示
new_port = TMP_PORT
end
if new_port then
node = {}
@@ -913,10 +910,11 @@ function gen_config(var)
local dns_socks_address = var["dns_socks_address"]
local dns_socks_port = var["dns_socks_port"]
local loglevel = var["loglevel"] or "warning"
local no_run = var["no_run"]
local use_proxy_list = var["use_proxy_list"]
local use_gfw_list = var["use_gfw_list"]
local chn_list = var["chn_list"]
local run_in_global = var["run_in_global"]
NO_RUN = var["no_run"]
local dns_domain_rules = {}
local dns = nil
@@ -1091,7 +1089,9 @@ function gen_config(var)
blc_nodes = _node.balancing_node
end
-- api.log(" - 加载 Xray 负载均衡 节点【" .. (_node.remarks or "") .. "】,子节点数量:" .. #(blc_nodes or {}))
if not NO_RUN and run_in_global then
api.log(" - 加载 Xray 负载均衡 节点【" .. (_node.remarks or "") .. "】,子节点数量:" .. #(blc_nodes or {}))
end
local valid_nodes = {}
for i = 1, #(blc_nodes or {}) do
@@ -1106,7 +1106,7 @@ function gen_config(var)
end
end
if is_new_blc_node then
local outboundTag = gen_outbound_get_tag(flag, blc_node_id, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run })
local outboundTag = gen_outbound_get_tag(flag, blc_node_id, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil })
if outboundTag then
valid_nodes[#valid_nodes + 1] = outboundTag
end
@@ -1135,7 +1135,7 @@ function gen_config(var)
local fallback_node = get_node_by_id(fallback_node_id)
if fallback_node then
if fallback_node.protocol ~= "_balancing" then
local outboundTag = gen_outbound_get_tag(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run })
local outboundTag = gen_outbound_get_tag(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil })
if outboundTag then
fallback_node_tag = outboundTag
end
@@ -1267,7 +1267,6 @@ function gen_config(var)
})
to_outbound = gen_outbound(node[".name"], to_node, to_node[".name"], {
tag = to_node[".name"],
run_socks_instance = not no_run
})
else
to_outbound = gen_outbound(node[".name"], to_node)
@@ -1375,7 +1374,6 @@ function gen_config(var)
local proxy_table = {
fragment = xray_settings.fragment == "1",
noise = xray_settings.noise == "1",
run_socks_instance = not no_run,
}
local preproxy_node_id = node[rule_name .. "_proxy_tag"]
if preproxy_node_id == _node_id then preproxy_node_id = nil end
@@ -1577,7 +1575,6 @@ function gen_config(var)
COMMON.default_outbound_tag = gen_outbound_get_tag(flag, node or node_id, nil, {
fragment = xray_settings.fragment == "1" or nil,
noise = xray_settings.noise == "1" or nil,
run_socks_instance = not no_run
})
if COMMON.default_outbound_tag then
routing = {
@@ -2073,7 +2070,7 @@ function gen_config(var)
for index, value in ipairs(config.outbounds) do
local pt = value.protocol
local exclude = { blackhole=1, dns=1, freedom=1, loopback=1 }
if not value["_flag_proxy_tag"] and value["_id"] and pt and not exclude[pt] and not no_run then
if not value["_flag_proxy_tag"] and value["_id"] and pt and not exclude[pt] and not NO_RUN then
sys.call(string.format("echo '%s' >> %s", value["_id"], api.TMP_PATH .. "/direct_node_list"))
end
for k, v in pairs(config.outbounds[index]) do
@@ -87,7 +87,7 @@ run_ipt2socks() {
run_singbox() {
local flag type node redir_port tcp_proxy_way socks_address socks_port socks_username socks_password http_address http_port http_username http_password
local dns_listen_port direct_dns_query_strategy direct_dns_port direct_dns_udp_server direct_dns_tcp_server remote_dns_protocol remote_dns_udp_server remote_dns_tcp_server remote_dns_doh remote_dns_client_ip remote_fakedns remote_dns_query_strategy remote_rewrite_ttl dns_cache dns_socks_address dns_socks_port
local loglevel log_file config_file server_host server_port no_run use_proxy_list use_gfw_list chn_list
local loglevel log_file config_file server_host server_port no_run use_proxy_list use_gfw_list chn_list run_in_global
eval_set_val "$@"
[ -z "$type" ] && {
type=$(echo $(config_n_get $node type) | tr 'A-Z' 'a-z')
@@ -182,13 +182,17 @@ run_singbox() {
[ "$remote_fakedns" = "1" ] && json_add_string "remote_dns_fake" "1"
[ -n "$remote_rewrite_ttl" ] && json_add_string "remote_rewrite_ttl" "${remote_rewrite_ttl}"
[ -n "$no_run" ] && json_add_string "no_run" "1"
[ -n "$run_in_global" ] && json_add_string "run_in_global" "1"
local _json_arg="$(json_dump)"
lua $UTIL_SINGBOX gen_config "${_json_arg}" > $config_file
[ -n "$no_run" ] && return
local test_log_file=$log_file
local status=0
[ "$test_log_file" = "/dev/null" ] && test_log_file="${TMP_PATH}/${config_file##*/}_test.log"
$SINGBOX_BIN check -c "$config_file" > $test_log_file 2>&1; local status=$?
[ -n "$run_in_global" ] && {
$SINGBOX_BIN check -c "$config_file" > $test_log_file 2>&1; status=$?
}
if [ "${status}" = 0 ]; then
ln_run "$SINGBOX_BIN" "sing-box" "${log_file}" run -c "$config_file"
else
@@ -201,7 +205,7 @@ run_singbox() {
run_xray() {
local flag type node redir_port tcp_proxy_way socks_address socks_port socks_username socks_password http_address http_port http_username http_password
local dns_listen_port direct_dns_query_strategy direct_dns_port direct_dns_udp_server direct_dns_tcp_server remote_dns_protocol remote_dns_udp_server remote_dns_tcp_server remote_dns_doh remote_dns_client_ip remote_fakedns remote_dns_query_strategy dns_cache dns_socks_address dns_socks_port
local loglevel log_file config_file server_host server_port no_run use_proxy_list use_gfw_list chn_list
local loglevel log_file config_file server_host server_port no_run use_proxy_list use_gfw_list chn_list run_in_global
eval_set_val "$@"
[ -z "$type" ] && {
type=$(echo $(config_n_get $node type) | tr 'A-Z' 'a-z')
@@ -278,13 +282,17 @@ run_xray() {
json_add_string "loglevel" "$loglevel"
[ -n "$no_run" ] && json_add_string "no_run" "1"
[ -n "$run_in_global" ] && json_add_string "run_in_global" "1"
local _json_arg="$(json_dump)"
lua $UTIL_XRAY gen_config "${_json_arg}" > $config_file
[ -n "$no_run" ] && return
local test_log_file=$log_file
local status=0
[ "$test_log_file" = "/dev/null" ] && test_log_file="${TMP_PATH}/${config_file##*/}_test.log"
$XRAY_BIN run -test -c "$config_file" > $test_log_file; local status=$?
[ -n "$run_in_global" ] && {
$XRAY_BIN run -test -c "$config_file" > $test_log_file; status=$?
}
if [ "${status}" = 0 ]; then
ln_run "$XRAY_BIN" "xray" "${log_file}" run -c "$config_file"
else
@@ -599,7 +607,7 @@ start_global() {
;;
sing-box)
local _flag="global"
local _args=""
local _args="run_in_global=1"
[ "$on_node_socks" = "1" ] && {
node_socks_flag=1
_args="${_args} socks_address=${node_socks_bind} socks_port=${GLOBAL_SOCKS_port}"
@@ -677,7 +685,7 @@ start_global() {
;;
xray)
local _flag="global"
local _args=""
local _args="run_in_global=1"
[ "$on_node_socks" = "1" ] && {
node_socks_flag=1
_args="${_args} socks_address=${node_socks_bind} socks_port=${GLOBAL_SOCKS_port}"
+2 -2
View File
@@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise
PKG_VERSION:=2026.9.8
PKG_RELEASE:=14
PKG_VERSION:=2026.9.9
PKG_RELEASE:=15
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=sing-box
PKG_VERSION:=1.14.1
PKG_RELEASE:=33
PKG_RELEASE:=34
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/SagerNet/sing-box/tar.gz/v$(PKG_VERSION)?
@@ -1,403 +0,0 @@
diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go
index 352b289..ef07e6d 100644
--- a/experimental/clashapi/provider.go
+++ b/experimental/clashapi/provider.go
@@ -3,43 +3,65 @@ package clashapi
import (
"context"
"net/http"
+ "os/exec"
+ "regexp"
+
+ "github.com/sagernet/sing-box/option"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
-func proxyProviderRouter() http.Handler {
+func proxyProviderRouter(server *Server) http.Handler {
r := chi.NewRouter()
- r.Get("/", getProviders)
+ r.Get("/", getProviders(server))
r.Route("/{name}", func(r chi.Router) {
- r.Use(parseProviderName, findProviderByName)
- r.Get("/", getProvider)
+ r.Use(parseProviderName, findProviderByName(server))
+ r.Get("/", getProvider(server))
r.Put("/", updateProvider)
r.Get("/healthcheck", healthCheckProvider)
})
return r
}
-func getProviders(w http.ResponseWriter, r *http.Request) {
- render.JSON(w, r, render.M{
- "providers": render.M{},
- })
+func getProviders(server *Server) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ providers := render.M{}
+ for _, provider := range server.proxyProviders {
+ if provider.Name == "" {
+ continue
+ }
+ providers[provider.Name] = proxyProviderInfo(server, provider)
+ }
+ render.JSON(w, r, render.M{
+ "providers": providers,
+ })
+ }
}
-func getProvider(w http.ResponseWriter, r *http.Request) {
- /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider)
- render.JSON(w, r, provider)*/
- render.NoContent(w, r)
+func getProvider(server *Server) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ provider := r.Context().Value(CtxKeyProvider).(option.ClashProxyProviderOptions)
+ render.JSON(w, r, proxyProviderInfo(server, provider))
+ }
}
func updateProvider(w http.ResponseWriter, r *http.Request) {
- /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider)
- if err := provider.Update(); err != nil {
+ provider := r.Context().Value(CtxKeyProvider).(option.ClashProxyProviderOptions)
+ if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(provider.UpdateID) {
+ render.Status(r, http.StatusServiceUnavailable)
+ render.JSON(w, r, newError("Provider update is unavailable"))
+ return
+ }
+
+ command := exec.Command("/bin/sh", "/etc/homeproxy/scripts/update_provider.sh", provider.UpdateID)
+ if err := command.Start(); err != nil {
render.Status(r, http.StatusServiceUnavailable)
render.JSON(w, r, newError(err.Error()))
return
- }*/
+ }
+ _ = command.Process.Release()
render.NoContent(w, r)
}
@@ -57,18 +79,58 @@ func parseProviderName(next http.Handler) http.Handler {
})
}
-func findProviderByName(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- /*name := r.Context().Value(CtxKeyProviderName).(string)
- providers := tunnel.ProxyProviders()
- provider, exist := providers[name]
- if !exist {*/
- render.Status(r, http.StatusNotFound)
- render.JSON(w, r, ErrNotFound)
- //return
- //}
+func findProviderByName(server *Server) func(next http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ name := r.Context().Value(CtxKeyProviderName).(string)
+ for _, provider := range server.proxyProviders {
+ if provider.Name == name {
+ ctx := context.WithValue(r.Context(), CtxKeyProvider, provider)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ return
+ }
+ }
+ render.Status(r, http.StatusNotFound)
+ render.JSON(w, r, ErrNotFound)
+ })
+ }
+}
- // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider)
- // next.ServeHTTP(w, r.WithContext(ctx))
- })
+func proxyProviderInfo(server *Server, provider option.ClashProxyProviderOptions) render.M {
+ providerType := provider.Type
+ if providerType == "" {
+ providerType = "Proxy"
+ }
+ vehicleType := provider.VehicleType
+ if vehicleType == "" {
+ vehicleType = "HTTP"
+ }
+
+ proxies := make([]any, 0, len(provider.Proxies))
+ for _, proxyName := range provider.Proxies {
+ proxy, exists := lookupProxyByName(server, proxyName)
+ if !exists {
+ continue
+ }
+ proxies = append(proxies, proxyInfo(server, proxy))
+ }
+
+ info := render.M{
+ "name": provider.Name,
+ "type": providerType,
+ "vehicleType": vehicleType,
+ "proxies": proxies,
+ }
+ if provider.UpdatedAt != "" {
+ info["updatedAt"] = provider.UpdatedAt
+ }
+ if provider.SubscriptionInfo != nil {
+ info["subscriptionInfo"] = render.M{
+ "Upload": provider.SubscriptionInfo.Upload,
+ "Download": provider.SubscriptionInfo.Download,
+ "Total": provider.SubscriptionInfo.Total,
+ "Expire": provider.SubscriptionInfo.Expire,
+ }
+ }
+ return info
}
diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go
index ef88ff3..1f7d14d 100644
--- a/experimental/clashapi/proxies.go
+++ b/experimental/clashapi/proxies.go
@@ -21,6 +21,50 @@ import (
"github.com/go-chi/render"
)
+func proxyDisplayName(tag string) string {
+ switch tag {
+ case "GLOBAL":
+ return "GLOBAL"
+ case "main-out":
+ return "主代理"
+ case "main-udp-out":
+ return "UDP 主代理"
+ case "direct-out":
+ return "直连"
+ case "block-out":
+ return "拦截"
+ default:
+ return tag
+ }
+}
+
+func lookupProxyByName(server *Server, name string) (adapter.Outbound, bool) {
+ proxy, exist := server.outbound.Outbound(name)
+ if exist {
+ return proxy, true
+ }
+
+ for _, detour := range server.outbound.Outbounds() {
+ if proxyDisplayName(detour.Tag()) == name {
+ return detour, true
+ }
+ }
+
+ for _, endpoint := range server.endpoint.Endpoints() {
+ if proxyDisplayName(endpoint.Tag()) == name {
+ return endpoint, true
+ }
+ }
+
+ return nil, false
+}
+
+func proxyDisplayNames(tags []string) []string {
+ return common.Map(tags, func(tag string) string {
+ return proxyDisplayName(tag)
+ })
+}
+
func proxyRouter(server *Server, router adapter.Router) http.Handler {
r := chi.NewRouter()
r.Get("/", getProxies(server))
@@ -29,7 +73,7 @@ func proxyRouter(server *Server, router adapter.Router) http.Handler {
r.Use(parseProxyName, findProxyByName(server))
r.Get("/", getProxy(server))
r.Get("/delay", getProxyDelay(server))
- r.Put("/", updateProxy)
+ r.Put("/", updateProxy(server))
})
return r
}
@@ -46,7 +90,7 @@ func findProxyByName(server *Server) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := r.Context().Value(CtxKeyProxyName).(string)
- proxy, exist := server.outbound.Outbound(name)
+ proxy, exist := lookupProxyByName(server, name)
if !exist {
render.Status(r, http.StatusNotFound)
render.JSON(w, r, ErrNotFound)
@@ -68,7 +112,7 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
clashType = C.ProxyDisplayName(detour.Type())
}
info.Put("type", clashType)
- info.Put("name", detour.Tag())
+ info.Put("name", proxyDisplayName(detour.Tag()))
info.Put("udp", common.Contains(detour.Network(), N.NetworkUDP))
delayHistory := server.urlTestHistory.LoadURLTestHistory(adapter.OutboundTag(detour))
if delayHistory != nil {
@@ -77,8 +121,8 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
info.Put("history", []*adapter.URLTestHistory{})
}
if group, isGroup := detour.(adapter.OutboundGroup); isGroup {
- info.Put("now", group.Now())
- info.Put("all", group.All())
+ info.Put("now", proxyDisplayName(group.Now()))
+ info.Put("all", proxyDisplayNames(group.All()))
}
return &info
}
@@ -102,10 +146,10 @@ func getProxies(server *Server) func(w http.ResponseWriter, r *http.Request) {
case C.TypeDirect, C.TypeBlock, C.TypeDNS:
continue
}
- allProxies = append(allProxies, detour.Tag())
+ allProxies = append(allProxies, proxyDisplayName(detour.Tag()))
}
- defaultTag := server.outbound.Default().Tag()
+ defaultTag := proxyDisplayName(server.outbound.Default().Tag())
sort.SliceStable(allProxies, func(i, j int) bool {
return allProxies[i] == defaultTag
@@ -114,7 +158,7 @@ func getProxies(server *Server) func(w http.ResponseWriter, r *http.Request) {
// fix clash dashboard
proxyMap.Put("GLOBAL", map[string]any{
"type": "Fallback",
- "name": "GLOBAL",
+ "name": proxyDisplayName("GLOBAL"),
"udp": true,
"history": []*adapter.URLTestHistory{},
"all": allProxies,
@@ -126,7 +170,7 @@ func getProxies(server *Server) func(w http.ResponseWriter, r *http.Request) {
if detour.Tag() == "" {
tag = F.ToString(i)
} else {
- tag = detour.Tag()
+ tag = proxyDisplayName(detour.Tag())
}
proxyMap.Put(tag, proxyInfo(server, detour))
}
@@ -159,29 +203,36 @@ type UpdateProxyRequest struct {
Name string `json:"name"`
}
-func updateProxy(w http.ResponseWriter, r *http.Request) {
- req := UpdateProxyRequest{}
- if err := render.DecodeJSON(r.Body, &req); err != nil {
- render.Status(r, http.StatusBadRequest)
- render.JSON(w, r, ErrBadRequest)
- return
- }
+func updateProxy(server *Server) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ req := UpdateProxyRequest{}
+ if err := render.DecodeJSON(r.Body, &req); err != nil {
+ render.Status(r, http.StatusBadRequest)
+ render.JSON(w, r, ErrBadRequest)
+ return
+ }
- proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound)
- selector, ok := proxy.(*group.Selector)
- if !ok {
- render.Status(r, http.StatusBadRequest)
- render.JSON(w, r, newError("Must be a Selector"))
- return
- }
+ proxy := r.Context().Value(CtxKeyProxy).(adapter.Outbound)
+ selector, ok := proxy.(*group.Selector)
+ if !ok {
+ render.Status(r, http.StatusBadRequest)
+ render.JSON(w, r, newError("Must be a Selector"))
+ return
+ }
- if !selector.SelectOutbound(req.Name) {
- render.Status(r, http.StatusBadRequest)
- render.JSON(w, r, newError("Selector update error: not found"))
- return
- }
+ selectedName := req.Name
+ if targetProxy, exist := lookupProxyByName(server, req.Name); exist {
+ selectedName = targetProxy.Tag()
+ }
+
+ if !selector.SelectOutbound(selectedName) {
+ render.Status(r, http.StatusBadRequest)
+ render.JSON(w, r, newError("Selector update error: not found"))
+ return
+ }
- render.NoContent(w, r)
+ render.NoContent(w, r)
+ }
}
func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request) {
diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go
index b9222ad5440..d4fd9dd3db1 100644
--- a/experimental/clashapi/server.go
+++ b/experimental/clashapi/server.go
@@ -57,6 +57,7 @@ type Server struct {
externalUI string
externalUIDownloadURL string
externalUIDownloadDetour string
+ proxyProviders []option.ClashProxyProviderOptions
}
func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.LifecycleService, error) {
@@ -92,6 +93,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op
externalController: options.ExternalController != "",
externalUIDownloadURL: options.ExternalUIDownloadURL,
externalUIDownloadDetour: options.ExternalUIDownloadDetour,
+ proxyProviders: options.ProxyProviders,
}
//goland:noinspection GoDeprecation
//nolint:staticcheck
@@ -120,7 +122,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op
r.Mount("/proxies", proxyRouter(s, s.router))
r.Mount("/rules", ruleRouter(s.router))
r.Mount("/connections", connectionRouter(s.ctx, s.network, trafficManager))
- r.Mount("/providers/proxies", proxyProviderRouter())
+ r.Mount("/providers/proxies", proxyProviderRouter(s))
r.Mount("/providers/rules", ruleProviderRouter())
r.Mount("/script", scriptRouter())
r.Mount("/profile", profileRouter())
diff --git a/option/experimental.go b/option/experimental.go
index bf144ad4452..89f303a7b26 100644
--- a/option/experimental.go
+++ b/option/experimental.go
@@ -34,6 +34,7 @@ type ClashAPIOptions struct {
ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty" reference:"outbound"`
Secret string `json:"secret,omitempty"`
DefaultMode string `json:"default_mode,omitempty"`
+ ProxyProviders []ClashProxyProviderOptions `json:"proxy_providers,omitempty"`
AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"`
AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"`
@@ -49,6 +50,23 @@ type ClashAPIOptions struct {
StoreFakeIP bool `json:"store_fakeip,omitempty" schema:"omit"`
}
+type ClashProxyProviderOptions struct {
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"`
+ VehicleType string `json:"vehicle_type,omitempty"`
+ Proxies []string `json:"proxies,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ UpdateID string `json:"update_id,omitempty"`
+ SubscriptionInfo *ClashProxyProviderSubscriptionInfo `json:"subscription_info,omitempty"`
+}
+
+type ClashProxyProviderSubscriptionInfo struct {
+ Upload uint64 `json:"upload,omitempty"`
+ Download uint64 `json:"download,omitempty"`
+ Total uint64 `json:"total,omitempty"`
+ Expire int64 `json:"expire,omitempty"`
+}
+
type V2RayAPIOptions struct {
Listen string `json:"listen,omitempty"`
Stats *V2RayStatsServiceOptions `json:"stats,omitempty"`