🍕 Sync 2026-10-13 13:11:55

This commit is contained in:
github-actions[bot] committed 2026-10-13 13:11:55 +08:00
1 parent de5c7f129c
commit 315fd3c2ee
38 files changed
+1245 -1717

No files matched your search

+2 -2
View File
@@ -5,13 +5,13 @@ PKG_SHORT_SHA:=bc76c13
PKG_COMMIT_DATE:=2026.10.11
PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA)
PKG_RELEASE:=11
PKG_RELEASE:=12
PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
PKG_SOURCE_VERSION:=fac50d4bfa20e397dc765616578d92522b208e3a
PKG_SOURCE_VERSION:=38f826f2f89637be271fb441f2f10b0d66422b17
PKG_HASH:=skip
PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA)
+39
View File
@@ -0,0 +1,39 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=ifb-shaper
PKG_VERSION:=0.1.1
PKG_RELEASE:=1
include $(INCLUDE_DIR)/package.mk
define Package/ifb-shaper
SECTION:=net
CATEGORY:=Network
TITLE:=Per-client HTB traffic shaper (IFB)
DEPENDS:=+tc +ip +kmod-ifb
PKGARCH:=all
endef
define Package/ifb-shaper/description
Per-client upload/download shaper. Download is shaped by HTB on the
LAN device egress, upload by HTB on an IFB device fed from the LAN
device ingress. Configured via /etc/config/ifb-shaper.
endef
define Package/ifb-shaper/conffiles
/etc/config/ifb-shaper
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/ifb-shaper/install
$(INSTALL_DIR) $(1)/etc/init.d $(1)/etc/config
$(INSTALL_BIN) ./files/ifb-shaper.init $(1)/etc/init.d/ifb-shaper
$(INSTALL_CONF) ./files/ifb-shaper.config $(1)/etc/config/ifb-shaper
endef
$(eval $(call BuildPackage,ifb-shaper))
+91
View File
@@ -0,0 +1,91 @@
# ifb-shaper
Per-client traffic shaper for OpenWrt based on HTB and IFB, configured via UCI.
- **Download**: HTB on the egress of the interface's device (e.g. `br-lan`), clients matched by destination address.
- **Upload**: device ingress is redirected to `ifb-<device>`, HTB on the IFB, clients matched by source address.
- IPv4 and IPv6, several addresses per client.
- Each client gets its own class with five priority sub-classes (both directions), from highest to lowest priority: HTTP(S) (tcp/80, tcp/443), other TCP, other UDP, QUIC (udp/443), everything else (ICMP etc.).
- `fq_codel` on every leaf class by default (configurable).
## How it works
A `shaper` section is bound to a **logical netifd interface**. Its L3 device is resolved at run time and the IFB is created automatically as `ifb-<device>` (max. 15 chars, so the device name may be up to 11 chars). The shaper is re-applied when the interface changes state or the config is changed.
Class layout (per device and per IFB):
| Class | Purpose |
|-------|---------|
| `1:1` | root, `total` |
| `1:10` | bypass (local) networks, `local_rate` / `local_ceil` |
| `1:1000` | parent of all client classes, `down_total` / `up_total` |
| `1:9000` | default (unclassified), `default_rate` / `default_ceil` |
| `1:<n>1` | a client, numbered automatically (max. 2048 per shaper) |
| `1:<n>2`..`1:<n>6` | client sub-classes: HTTP(S), TCP, UDP, QUIC, rest |
Networks listed in `bypass` skip the client classes and go to `1:10`. Note that `1:10` is still capped by `local_ceil` and shared by all clients; set it equal to `total` for effectively unrestricted local traffic.
## Configuration
`/etc/config/ifb-shaper`:
```
config shaper 'lan'
option interface 'lan'
list bypass '192.168.1.0/24'
config client 'laptop'
option shaper 'lan'
list address '192.168.1.100'
list address '2001:db8:1::100'
option down_rate '1Mbit'
option down_ceil '50Mbit'
option up_rate '1Mbit'
option up_ceil '20Mbit'
```
### `shaper`
| Option | Default | Description |
|--------|---------|-------------|
| `enabled` | `1` | Enable/disable this shaper |
| `interface` | – | Logical interface to shape (required) |
| `bypass` | – | List of networks (IPv4/IPv6) not subject to client limits |
| `leaf_qdisc` | `fq_codel limit 1024 flows 128` | qdisc attached to every client sub-class (`tc` arguments); `none` disables it |
| `total` | `1000Mbit` | Root class rate |
| `local_rate` / `local_ceil` | `50Mbit` / `500Mbit` | Bypass class |
| `down_total` / `up_total` | `400Mbit` / `300Mbit` | Aggregate rate of all clients |
| `default_rate` / `default_ceil` | `1Mbit` / `15Mbit` | Unclassified traffic |
### `client`
| Option | Default | Description |
|--------|---------|-------------|
| `shaper` | – | Logical interface name of the shaper (required) |
| `address` | – | Host or network, IPv4 or IPv6; may be a list (required) |
| `down_rate` / `down_ceil` | `1Mbit` / – | Guaranteed / maximum download (`down_ceil` required) |
| `up_rate` / `up_ceil` | `1Mbit` / – | Guaranteed / maximum upload (`up_ceil` required) |
Rates use `tc` notation (`kbit`, `Mbit`, ...). Clients are matched in file order, so put specific hosts **before** wide subnets that contain them.
## Notes
- The ingress hook of the device may be shared with other tools (`clsact` from qosify etc.). It is never deleted or replaced: if there is no `ingress`/`clsact` qdisc, `clsact` is created, and only the shaper's own redirect filters (prio 4900 for IPv4, 4901 for IPv6) are managed. After `stop` an empty `clsact` stays on the device.
- Software/hardware flow offloading can bypass tc for offloaded flows.
## Usage
```
/etc/init.d/ifb-shaper enable
/etc/init.d/ifb-shaper start|stop|reload
/etc/init.d/ifb-shaper dump # print the tc commands without applying
logread -e ifb-shaper # errors reported by tc
```
## Dependencies
`tc`, `ip`, `kmod-ifb` (pulls in `kmod-sched-core`).
## Todo
- Luci app.
+33
View File
@@ -0,0 +1,33 @@
# Example, disabled by default.
#
# 'interface' logical netifd interface to shape (e.g. lan). Its device
# (e.g. br-lan) is found automatically; download = device
# egress, upload = device ingress redirected to an IFB
# named ifb-<device> that is created automatically.
# Re-applied whenever the interface changes state.
# 'bypass' networks that go to the local class instead of the
# per-client classes (IPv4 and IPv6 allowed).
#
# Optional class sizing (defaults shown):
# total 1000Mbit, local_rate 50Mbit, local_ceil 500Mbit,
# down_total 400Mbit, up_total 300Mbit,
# default_rate 1Mbit, default_ceil 15Mbit
# leaf_qdisc 'fq_codel limit 1024 flows 128' ('none' to disable)
#
# Clients refer to a shaper by its logical interface name
# ("option shaper"). Clients are matched in file order: put specific
# hosts before wide subnets.
config shaper 'lan'
option enabled '0'
option interface 'lan'
list bypass '192.168.1.0/24'
config client 'laptop'
option shaper 'lan'
list address '192.168.1.100'
list address '2001:db8:1::100'
option down_rate '1Mbit'
option down_ceil '50Mbit'
option up_rate '1Mbit'
option up_ceil '20Mbit'
+238
View File
@@ -0,0 +1,238 @@
#!/bin/sh /etc/rc.common
# Per-client HTB shaper on top of IFB.
# A shaper is bound to a logical (netifd) interface; its L3 device
# (e.g. br-man) is resolved at run time and paired with ifb-<device>.
# download: HTB on <device> egress, match on client dst
# upload: <device> ingress -> ifb-<device>, HTB on it, match on client src
START=50
USE_PROCD=1
NAME=ifb-shaper
. /lib/functions/network.sh
TC=/sbin/tc
IP=/sbin/ip
extra_command "dump" "Print the tc commands that would be applied"
log() { logger -s -t "$NAME" "$@"; }
emit() { echo "$*" >> "$BATCH_ADD"; }
emit_del() { echo "$*" >> "$BATCH_DEL"; }
# set per-family filter parameters for address $1
fam() {
case "$1" in
*:*) F_PROTO=ipv6; F_M=ip6; F_P=10; F_ICMP=58 ;;
*) F_PROTO=ip; F_M=ip; F_P=0; F_ICMP=1 ;;
esac
}
# root qdisc + service classes; $1 = dev, $2 = rate of the provider class
base_classes() {
emit qdisc add dev $1 root handle 1: htb default 9000 r2q 100
emit class add dev $1 parent 1: classid 1:1 htb rate $S_TOTAL burst 15k quantum 200000
# local traffic
emit class add dev $1 parent 1:1 classid 1:10 htb rate $S_LRATE ceil $S_LCEIL burst 15k
# traffic to/from provider; client classes live here
emit class add dev $1 parent 1:1 classid 1:1000 htb rate $2 burst 15k quantum 200000
# unclassified
emit class add dev $1 parent 1:1 classid 1:9000 htb rate $S_DRATE ceil $S_DCEIL
}
# One direction of one client; uses $n and $addr from client_add.
# $1 dev, $2 rate, $3 ceil, $4 sub-class rate, $5 dst|src, $6 sport|dport
# Sub-classes, highest priority first:
# 2 HTTP(S) 3 other TCP 4 other UDP 5 QUIC (udp/443) 6 the rest
client_dir() {
local dev="$1" r="$2" c="$3" sr="$4" dir="$5" port="$6" i a
emit class add dev $dev parent 1:1000 classid 1:${n}1 htb rate $r ceil $c
for i in 2 3 4 5 6; do
emit class add dev $dev parent 1:${n}1 classid 1:${n}$i htb \
rate $sr ceil $c prio $((i - 1)) quantum 1514
[ -n "$S_LEAF" ] && emit qdisc add dev $dev parent 1:${n}$i $S_LEAF
done
for a in $addr; do
fam $a
# HTTP(S)
emit filter add dev $dev protocol $F_PROTO parent 1:0 prio $((1 + F_P)) u32 \
match $F_M $dir $a match $F_M protocol 6 0xff match $F_M $port 80 0xffff flowid 1:${n}2
emit filter add dev $dev protocol $F_PROTO parent 1:0 prio $((1 + F_P)) u32 \
match $F_M $dir $a match $F_M protocol 6 0xff match $F_M $port 443 0xffff flowid 1:${n}2
# QUIC, must precede the generic UDP rule
emit filter add dev $dev protocol $F_PROTO parent 1:0 prio $((2 + F_P)) u32 \
match $F_M $dir $a match $F_M protocol 17 0xff match $F_M $port 443 0xffff flowid 1:${n}5
# other TCP
emit filter add dev $dev protocol $F_PROTO parent 1:0 prio $((3 + F_P)) u32 \
match $F_M $dir $a match $F_M protocol 6 0xff flowid 1:${n}3
# other UDP
emit filter add dev $dev protocol $F_PROTO parent 1:0 prio $((4 + F_P)) u32 \
match $F_M $dir $a match $F_M protocol 17 0xff flowid 1:${n}4
# the rest (icmp etc.)
emit filter add dev $dev protocol $F_PROTO parent 1:0 prio $((5 + F_P)) u32 \
match $F_M $dir $a flowid 1:${n}6
done
}
client_add() {
local cfg="$1" sh addr dr dc ur uc n
config_get sh "$cfg" shaper
[ "$sh" = "$S_NAME" ] || return 0 # clients refer to the shaper section by name
config_get addr "$cfg" address
config_get dr "$cfg" down_rate 1Mbit
config_get dc "$cfg" down_ceil
config_get ur "$cfg" up_rate 1Mbit
config_get uc "$cfg" up_ceil
if [ -z "$addr" ] || [ -z "$dc" ] || [ -z "$uc" ]; then
log "client $cfg: address, down_ceil and up_ceil are required, skipped"
return 0
fi
S_N=$((S_N + 1))
if [ $S_N -gt 2048 ]; then
log "client $cfg: too many clients (max 2048 per shaper), skipped"
return 0
fi
n=$(printf %x $((S_N + 255))) # 100, 101, ... 8ff -> classes 1:<n>1 .. 1:<n>6
client_dir $DEV $dr $dc 33kbit dst sport # download
client_dir $IFB $ur $uc 1kbit src dport # upload
}
shaper_gen() {
local cfg="$1" en net iface K HOOK
config_get iface "$cfg" interface
if [ -z "$iface" ]; then
log "shaper $cfg: option interface is required, skipped"
return 0
fi
network_flush_cache
DEV=
network_get_device DEV "$iface"
if [ -z "$DEV" ]; then
log "shaper $cfg: interface $iface has no device (down?), skipped"
return 0
fi
IFB="ifb-$DEV"
if [ ${#IFB} -gt 15 ]; then
log "shaper $cfg: name $IFB is longer than 15 chars, skipped"
return 0
fi
append IFB_LIST "$IFB"
# The ingress hook (clsact/ingress) may be shared with other tools, so it
# is never deleted or replaced: only our own filters (prio 4900/4901) are.
K=$($TC qdisc show dev $DEV 2>/dev/null | sed -n 's/^qdisc \(ingress\|clsact\) ffff:.*/\1/p')
HOOK=ingress
[ "$K" = ingress ] && HOOK="parent ffff:"
emit_del filter del dev $DEV $HOOK prio 4900
emit_del filter del dev $DEV $HOOK prio 4901
emit_del qdisc del dev $DEV root
emit_del qdisc del dev $IFB root
[ -n "$STOP" ] && return 0
config_get_bool en "$cfg" enabled 1
[ "$en" = 1 ] || return 0
if [ -z "$DUMP" ]; then
if [ ! -d /sys/class/net/$DEV ]; then
log "shaper $cfg: $DEV does not exist, skipped"
return 0
fi
[ -d /sys/class/net/$IFB ] || $IP link add name $IFB type ifb || {
log "shaper $cfg: cannot create $IFB (kmod-ifb?), skipped"
return 0
}
$IP link set dev $IFB up
fi
config_get S_TOTAL "$cfg" total 1000Mbit
config_get S_LRATE "$cfg" local_rate 50Mbit
config_get S_LCEIL "$cfg" local_ceil 500Mbit
config_get S_DRATE "$cfg" default_rate 1Mbit
config_get S_DCEIL "$cfg" default_ceil 15Mbit
config_get S_DOWN "$cfg" down_total 400Mbit
config_get S_UP "$cfg" up_total 300Mbit
config_get S_BYPASS "$cfg" bypass
config_get S_LEAF "$cfg" leaf_qdisc "fq_codel limit 1024 flows 128"
[ "$S_LEAF" = none ] && S_LEAF=
S_NAME=$cfg
S_N=0
emit "# shaper $cfg: $iface = $DEV <-> $IFB"
# everything coming from clients goes to the IFB
[ -n "$K" ] || emit qdisc add dev $DEV clsact
emit filter add dev $DEV $HOOK protocol ip prio 4900 u32 match u32 0 0 \
action mirred egress redirect dev $IFB
emit filter add dev $DEV $HOOK protocol ipv6 prio 4901 u32 match u32 0 0 \
action mirred egress redirect dev $IFB
base_classes $DEV $S_DOWN
base_classes $IFB $S_UP
# local networks go to the local class
for net in $S_BYPASS; do
fam $net
emit filter add dev $IFB protocol $F_PROTO parent 1: prio $((1 + F_P)) u32 \
match $F_M dst $net flowid 1:10
emit filter add dev $DEV protocol $F_PROTO parent 1: prio $((1 + F_P)) u32 \
match $F_M src $net flowid 1:10
done
config_foreach client_add client
}
shaper_run() {
local out l
BATCH_ADD=/tmp/$NAME.$$.add
BATCH_DEL=/tmp/$NAME.$$.del
: > $BATCH_ADD
: > $BATCH_DEL
IFB_LIST=
config_load $NAME
config_foreach shaper_gen shaper
if [ -n "$DUMP" ]; then
cat $BATCH_DEL $BATCH_ADD
else
$TC -force -batch $BATCH_DEL >/dev/null 2>&1
if [ -n "$STOP" ]; then
for out in $IFB_LIST; do
$IP link del dev $out 2>/dev/null
done
fi
if [ -s $BATCH_ADD ]; then
out=$($TC -force -batch $BATCH_ADD 2>&1)
case "$out" in
*failed*)
log "tc reported errors:"
echo "$out" | grep -v '^Warning' | while read -r l; do log "$l"; done
;;
esac
fi
fi
rm -f $BATCH_ADD $BATCH_DEL
}
start_service() { shaper_run; }
stop_service() { STOP=1 shaper_run; }
dump() { DUMP=1 shaper_run; }
_iface_trigger() {
local iface
config_get iface "$1" interface
[ -n "$iface" ] && procd_add_reload_interface_trigger "$iface"
}
service_triggers() {
procd_add_reload_trigger "$NAME"
config_load "$NAME"
config_foreach _iface_trigger shaper
}
+1 -1
View File
@@ -1 +1 @@
both
snapshot
@@ -1 +1 @@
26.284.67820~acda1ad
26.285.40306~d12d553
@@ -105,6 +105,7 @@ querylog:
- orirainbow.cc^
- orirainwe.edu.kg^
- coriseay.edu.kg^
- xzbzcoriseay.win^
- google.com^
interval: 7d
size_memory: 1000
@@ -117,6 +118,7 @@ statistics:
- orirainbow.cc^
- orirainwe.edu.kg^
- coriseay.edu.kg^
- xzbzcoriseay.win^
- google.com^
interval: 7d
enabled: true
@@ -135,8 +137,6 @@ user_rules:
- '@@||ntfy.sh^'
- '@@||pubnub.com^'
- '@@||clarity.ms^$important'
- '@@||sh-gsg.com^$important'
- '@@||sh-mtgc.com^$important'
- '@@||taobao.com^$important'
- '@@||tmall.com^$important'
- '@@||jd.com^important'
@@ -164,6 +164,7 @@ user_rules:
- '@@||miwifi.com^'
- '@@||tracker.ai.xiaomi.com^$important'
- '@@||tracking.miui.com^$important'
- '@@||xiaomiyoupin.com^'
- '#解除 Apple 及其所有子域名的封锁;'
- '@@||apple.com^'
- '@@||apple.com.cn^'
@@ -195,9 +196,6 @@ user_rules:
- '@@||app-measurement.com^$important'
- '@@||tmdb.org^'
- '@@||themoviedb.org^'
- /googleads.$~script,domain=~googleads.github.io
- /pagead/lvz?
- '||google.com/pagead/'
- '||static.doubleclick.net^$domain=youtube.com'
- '||youtube.com/get_midroll_'
- '||5hz.org^'
@@ -232,7 +230,27 @@ user_rules:
- '@@||1ptba.com^'
- '@@||ptcafe.club^'
- '||nbpicture.xyz^'
- ""
- '! ===== Gemini & NotebookLM & Google Labs 核心 API 放行 ====='
- '@@||notebooklm.google.com^'
- '@@||labs.google.com^'
- '@@||gemini.google.com^'
- '@@||alkalimakersuite-pa.googleapis.com^'
- '@@||generativelanguage.googleapis.com^'
- '! ===== Google 基础前端脚本与 API 通讯保障 ====='
- '@@||googleapis.com^'
- '@@||gstatic.com^'
- '@@||googleusercontent.com^'
- '@@||apis.google.com^'
- '@@||clients1.google.com^'
- '@@||clients2.google.com^'
- '@@||clients3.google.com^'
- '@@||clients4.google.com^'
- '@@||clients5.google.com^'
- '@@||clients6.google.com^'
- '! ===== 仅对 Gemini 域名排除以下广告拦截/过滤规则的影响 ====='
- /googleads.$~script,domain=~googleads.github.io&~gemini.google.com
- /pagead/lvz?$domain=~gemini.google.com
- '||google.com/pagead/^$domain=~gemini.google.com'
dhcp:
enabled: false
interface_name: ""
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-appflow
PKG_VERSION:=1.1.2
PKG_RELEASE:=2
PKG_VERSION:=1.1.3
PKG_RELEASE:=3
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=Bob <git16@bob7.com>
+10 -3
View File
@@ -18,8 +18,8 @@ One file, every device. The package is `noarch` — only JavaScript and ucode
inside — and the same `.apk` is verified installing on both aarch64 and
arm_cortex-a15 hardware.
wget -O luci-app-appflow-1.1.2-r1.apk https://github.com/VolanticSystems/luci-app-appflow/releases/download/v1.1.2/luci-app-appflow-1.1.2-r1.apk
apk add --allow-untrusted ./luci-app-appflow-1.1.2-r1.apk
wget -O luci-app-appflow-1.1.3-r1.apk https://github.com/VolanticSystems/luci-app-appflow/releases/download/v1.1.3/luci-app-appflow-1.1.3-r1.apk
apk add --allow-untrusted ./luci-app-appflow-1.1.3-r1.apk
/etc/init.d/appflowd enable && /etc/init.d/appflowd start
Needs OpenWrt 25.12 or newer and `netifyd`. `--allow-untrusted` is required
@@ -381,7 +381,14 @@ red, written before the assertion. Details in
## Requirements
- OpenWrt with `netifyd` available (developed and tested against **OpenWrt
25.12.5** with **netifyd 4.4.7**).
25.12.5** with **netifyd 4.4.7**, the version OpenWrt packages).
- **netifyd 5.x** works from 1.1.3, tested on x86 with Netify's own 5.2
packages. It needs two of its plugins switched on, which ship off:
`netifyd --enable-plugin proc-core`, `netifyd --enable-plugin sink-socket`,
and in `/etc/netifyd/netify-proc-core.json` set
`sinks.sink-socket.default.enable` to `true`. appflow finds the 5.x socket
by itself. If it is connected but nothing arrives for a minute, the status
page says so and the log says what to switch on.
- Enough headroom to run DPI. Measured on the test device under light load:
**netifyd** around 17 MB VSZ, **appflowd** around 2 MB VSZ, negligible CPU
for both. netifyd is the expensive component; appflow adds little on top.
+38
View File
@@ -982,3 +982,41 @@ flow table. It is more revealing than either list alone, since it says which
device used which application, and it is still far short of `flows`: no remote
address, no port, no per-connection record. **`flows` remains unbound and
ungranted.**
## 16. Shipped in 1.1.3: netifyd 5.x, and a Router-row loss on every restart
Found with a Docker test on 2026-10-12: OpenWrt 25.12.5 for x86 with Netify's
own netifyd 5.2 packages, and the 4.4.7 from OpenWrt's feed as a control.
**On 5.x, appflow looked healthy and received nothing.** 5.x moved the JSON
export out of the agent into the `netify-sink-socket` plugin, which listens on
`/var/run/netifyd/netify-sink.sock`. The old path still exists but is a
different socket that accepts a connection and sends nothing, so status read
"connected, 0 events": a quiet router. appflowd now uses the 5.x socket when
the configured path is the 4.4 default and the 5.x socket exists, and flags a
connection that has delivered nothing for a minute (`socket.silent`, plus a log
line naming the plugins to enable). netifyd sends a status event every 15 s, so
a silent minute is never normal.
**Other 5.x differences handled:** the category list moved to
`/etc/netifyd/netify-categories.json` (same format; without it every category
read unclassified); `flow_dpi_update` and `flow_dpi_complete` are accepted and
ignored rather than counted as unknown, since the `flow` event already carries
the classification and the first `flow_stats` covers the bytes from the start;
`agent_hello` now repeats every 15 s and is logged once per connection.
**A shortfall leak.** Once in 133 updates on a slow, retransmitting flow, 5.x
reported `total_bytes` growth larger than `local_bytes + other_bytes`, and the
difference showed as `leaked`. The deltas are now scaled up to the growth in
the same direction ratio, mirroring the existing clamp for the opposite case.
**The Router row lost flows after every restart, on 4.4.7 too.** §13's fix
kept a flow's ports on its record because `flow_stats` carries none. It
carries no addresses either, on both versions, though a comment said it did.
A router flow identified before the first LAN flow is re-checked against
conntrack later, from an update, with a key of empty address fields; it
missed, and the flow was shadowed for the rest of its life. The addresses are
now kept on the record too. The protocol suite's own fake events had put
addresses on every `flow_stats`, which real netifyd never does, and that is
why it stayed green; the new `netify5` group sends what netifyd actually
sends, and all five of its checks fail against the previous daemon.
+3
View File
@@ -1,5 +1,8 @@
config appflow 'main'
# Netify Agent JSON export socket (netifyd.conf: [socket] listen_path[0]).
# netifyd 5.x serves it from the sink-socket plugin instead, at
# /var/run/netifyd/netify-sink.sock; with this left at the 4.4 default,
# appflowd uses that one automatically when it exists.
option socket_path '/var/run/netifyd/netifyd.sock'
# Maximum concurrently tracked flows. The flow table is the daemon's only
+90 -16
View File
@@ -58,7 +58,21 @@ const LOG_REPEAT_MS = 60000; // identical-message log suppression window
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const CATEGORY_FILE = "/etc/netify.d/netify-categories.json";
// netifyd 4.4 keeps its category list under /etc/netify.d; 5.x moved it to
// /etc/netifyd, same format. Without the second path every category on 5.x
// read "unclassified" (Docker test, 2026-10-12).
const CATEGORY_FILES = [ "/etc/netify.d/netify-categories.json",
"/etc/netifyd/netify-categories.json" ];
// netifyd 5.x moved the JSON export out of the agent into the
// netify-sink-socket plugin, which listens here. The old path still exists on
// 5.x but is a different socket that accepts a connection and sends nothing,
// so appflowd looked connected and idle while receiving nothing at all. When
// the configured path is the 4.4 default and the 5.x socket exists, use the
// 5.x one; an explicitly configured path is always used as given.
const LEGACY_SOCKET = "/var/run/netifyd/netifyd.sock";
const SINK_SOCKET = "/var/run/netifyd/netify-sink.sock";
const SILENT_MS = 60000; // connected with no events this long: warn
const LEASE_FILE = "/tmp/dhcp.leases";
const STATUS_FILE = "/var/run/netifyd/status.json";
const CONNTRACK_FILE = "/proc/net/nf_conntrack";
@@ -101,6 +115,10 @@ let sock_fd = null; // uloop fd handle
let rxbuf = ""; // partial line carry-over
let connected = false;
let ever_connected = false; // distinguishes the first connect from reconnects
let hello_logged = false; // agent_hello logged on this connection
let active_path = null; // the socket actually connected to
let connected_ms = 0; // when this connection was made
let silent = false; // connected, but nothing has arrived
let backoff_ms = RECONNECT_MIN_MS;
let retry_timer = null;
@@ -664,14 +682,20 @@ function display_name(tag) {
}
function load_categories() {
let raw;
try {
raw = fs.readfile(CATEGORY_FILE);
} catch (e) {
raw = null;
let raw, path;
for (let p in CATEGORY_FILES) {
try {
raw = fs.readfile(p);
} catch (e) {
raw = null;
}
if (raw) {
path = p;
break;
}
}
if (!raw) {
logonce("cats", "warn", "category names unavailable: %s", CATEGORY_FILE);
logonce("cats", "warn", "category names unavailable: %s", join(" or ", CATEGORY_FILES));
return;
}
@@ -679,7 +703,7 @@ function load_categories() {
try {
obj = json(raw);
} catch (e) {
logmsg("warn", "%s: parse error: %s", CATEGORY_FILE, e);
logmsg("warn", "%s: parse error: %s", path, e);
return;
}
@@ -943,8 +967,9 @@ function ct_load(now, force) {
function ct_nat_twin(f, now, fr) {
// THE PORTS COME FROM THE FLOW RECORD, NOT THE EVENT, AND THAT IS THE WHOLE
// FIX. netifyd puts local_port/other_port on the initial `flow` event and
// on nothing afterwards: a `flow_stats` payload carries addresses and byte
// counters only. This key was built straight off the event, so every call
// on nothing afterwards: a `flow_stats` payload carries byte counters
// only. (This comment once said it carried addresses too. It does not, on
// 4.4.7 or 5.x, so the addresses now come from the record as well.) This key was built straight off the event, so every call
// made from an update produced "...|93.184.216.34|192.168.72.10||" with two
// empty fields, matched nothing, and returned null.
//
@@ -959,7 +984,9 @@ function ct_nat_twin(f, now, fr) {
// missing on the bench. See tests/protocol-suite.sh `router`.
let lp = (fr != null && fr.local_port != null) ? fr.local_port : f.local_port;
let op = (fr != null && fr.other_port != null) ? fr.other_port : f.other_port;
let key = (f.other_ip ?? "") + "|" + (f.local_ip ?? "") + "|" +
let lip = (fr != null && fr.local_ip != null) ? fr.local_ip : f.local_ip;
let oip = (fr != null && fr.other_ip != null) ? fr.other_ip : f.other_ip;
let key = (oip ?? "") + "|" + (lip ?? "") + "|" +
(op ?? "") + "|" + (lp ?? "");
ct_load(now, false);
let v = conntrack.entries ? conntrack.map[key] : null;
@@ -1266,6 +1293,14 @@ function flow_identify(fr, ev, f, now) {
// ct_nat_twin().
if (f.local_port != null) fr.local_port = f.local_port;
if (f.other_port != null) fr.other_port = f.other_port;
// The addresses too, for the same reason. A `flow_stats` carries neither
// ports nor addresses (checked on 4.4.7 and 5.x captures, 2026-10-12), so
// a router flow re-checked against conntrack from an update built a key of
// empty fields, missed, and was shadowed for the rest of its life. Only
// router flows identified before the first LAN flow are re-checked, which
// is why the loss was small and showed up after every restart.
if (f.local_ip != null) fr.local_ip = f.local_ip;
if (f.other_ip != null) fr.other_ip = f.other_ip;
let lm = lc(f.local_mac ?? ""), om = lc(f.other_mac ?? "");
let rl = is_router(lm, f.local_ip), ro = is_router(om, f.other_ip);
@@ -1478,10 +1513,23 @@ function flow_new(digest, ev, f, now, late) {
function ev_hello(ev) {
agent.version = ev.agent_version ?? agent.version;
// 4.4 sends this once per connection; 5.x repeats it with every status
// update, every 15 s, which filled the log (57 lines in 16 minutes). Log
// it once per connection.
if (hello_logged)
return;
hello_logged = true;
logmsg("info", "netify agent %s connected (%s)",
agent.version ?? "?", ev.build_version ?? "");
}
// netifyd 5.x sends these as a flow's classification firms up. The `flow`
// event already carries the final classification in every case captured, and
// the first flow_stats covers the bytes from the flow's start, so they are
// accepted and ignored rather than counted as unknown traffic.
function ev_ignore(ev) {
}
// In normal operation `names` is bounded by nDPI's catalogue (a few thousand
// ids from the definitions event). These caps make that bound explicit rather
// than incidental, so a buggy or hostile agent cannot grow the maps without
@@ -1578,6 +1626,14 @@ function flow_update(fr, f, now, final) {
// Directional deltas exceed the cumulative growth: clamp them down.
dl = (sum > 0) ? int(dl * dt / sum) : 0;
do_ = dt - dl;
} else if (sum > 0 && sum < dt) {
// The opposite: the cumulative counter grew by more than the two
// directional deltas add up to. Seen once in 133 updates on a slow,
// retransmitting flow under netifyd 5.2 (2026-10-12, 13,254 bytes),
// and it showed as `leaked`. Scale up in the same direction ratio,
// mirroring the clamp above, so the total is what netifyd reported.
dl = int(dl * dt / sum);
do_ = dt - dl;
} else if (sum == 0 && dt > 0) {
// The cumulative counter advanced but this update carries no
// directional bytes. This is the flow_purge case on 4.4.7:
@@ -1670,7 +1726,9 @@ function ev_purge(ev, f, now) {
const HANDLERS = {
agent_hello: ev_hello,
definitions: ev_definitions,
agent_status: ev_agent_status
agent_status: ev_agent_status,
flow_dpi_update: ev_ignore,
flow_dpi_complete: ev_ignore
};
const FLOW_HANDLERS = {
@@ -1836,18 +1894,25 @@ schedule_reconnect = function() {
};
connect = function() {
active_path = cfg.socket_path;
if (active_path == LEGACY_SOCKET && fs.stat(SINK_SOCKET) != null)
active_path = SINK_SOCKET;
try {
sock = socket.connect(cfg.socket_path);
sock = socket.connect(active_path);
} catch (e) {
sock = null;
}
if (!sock) {
stats.connect_fail++;
logonce("noconn", "warn", "waiting for netifyd on %s", cfg.socket_path);
logonce("noconn", "warn", "waiting for netifyd on %s", active_path);
schedule_reconnect();
return;
}
hello_logged = false;
silent = false;
connected_ms = now_ms();
sock_fd = uloop.handle(sock, on_readable, uloop.ULOOP_READ);
// Count reconnects, not the first connect, so `socket.reconnects` reads as
@@ -1864,7 +1929,7 @@ connect = function() {
// stream re-establishes it from its own events.
saw_internal = false;
router_bootstrap();
logmsg("info", "connected to %s", cfg.socket_path);
logmsg("info", "connected to %s", active_path);
};
/* ------------------------------------------------------------------ *
@@ -1920,6 +1985,15 @@ prune = function(now, force) {
function housekeeping() {
let now = now_ms();
stats.sweeps++;
// Connected but silent. netifyd sends a status update every 15 s, so a
// minute with nothing at all means this socket is not the data stream;
// on 5.x that is the old path while the netify-sink-socket plugin is off.
// Without this it read as a healthy, idle router.
let last = (stats.last_event_ms > connected_ms) ? stats.last_event_ms : connected_ms;
silent = connected && (now - last) > SILENT_MS;
if (silent)
logonce("silent", "warn", "connected to %s but nothing has arrived for %d s; on netifyd 5.x enable the proc-core and sink-socket plugins", active_path, int((now - last) / 1000));
let slot = int(now / SERIES_MS), dslot = int(now / DEVSER_MS);
prune(now, false);
@@ -2396,7 +2470,7 @@ function m_status(req) {
r.version = VERSION;
r.uptime = int((mono_ms() - start_mono) / 1000);
r.started_at = start_ms;
r.socket = { path: cfg.socket_path, connected: connected, reconnects: stats.reconnects,
r.socket = { path: active_path ?? cfg.socket_path, connected: connected, silent: silent, reconnects: stats.reconnects,
connect_failures: stats.connect_fail };
r.config = { flow_max: cfg.flow_max, rate_window: cfg.rate_window,
top_n: cfg.top_n, idle_timeout: cfg.idle_timeout,
+100 -1
View File
@@ -114,6 +114,7 @@ ORIG_FLOWMAX=""
head2() { printf '\n=== %s ===\n' "$*"; }
ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$*"; }
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$*"; }
skip() { printf ' SKIP %s\n' "$*"; }
note() { printf ' %s\n' "$*"; }
# chk <description> <expected> <actual>
@@ -1232,6 +1233,103 @@ test_router_origin() {
rm -f "$CT"
}
test_netify5() {
head2 "WHAT REAL NETIFYD SENDS: NO ADDRESSES ON UPDATES, NEW 5.x MESSAGE TYPES"
# THE DEFECT, found 2026-10-12 in a Docker test against netifyd 4.4.7 and
# 5.2. A flow_stats event carries the digest and the byte counters and
# nothing else: no ports, no addresses. The fake events everywhere else in
# this suite put local_ip/other_ip on every flow_stats, which real netifyd
# never does, and that is why the suite stayed green while router flows
# identified before the first LAN flow were silently shadowed: reshadow()
# re-asked conntrack with a key built from the update's (absent) addresses.
local RIP RMAC CT
CT=/tmp/appflow-ct-fixture5
RIP=$(jsonfilter -i /var/run/netifyd/status.json -e '@.interfaces.wan.addr[0]' 2>/dev/null)
case "$RIP" in
*.*) : ;;
*) RIP=$(jsonfilter -i /var/run/netifyd/status.json -e '@.interfaces.wan.addr[1]' 2>/dev/null) ;;
esac
RMAC=$(jsonfilter -i /var/run/netifyd/status.json -e '@.interfaces.wan.mac' 2>/dev/null)
if [ -n "$RIP" ] && [ -n "$RMAC" ]; then
printf 'ipv4 2 tcp 6 431999 ESTABLISHED src=%s dst=93.184.216.34 sport=40005 dport=443 packets=4 bytes=400 src=93.184.216.34 dst=%s sport=443 dport=40005 packets=4 bytes=400 [ASSURED] mark=0 zone=0 use=2\n' \
"$RIP" "$RIP" > "$CT"
uci set appflow.main.conntrack_path="$CT"
uci commit appflow
# Order is the point: the router's flow is identified BEFORE any LAN
# flow, so saw_internal is still false and nothing is decided then.
# The LAN flow arrives, saw_internal goes true, and the router flow's
# later updates, carrying no addresses, are what re-check conntrack.
{
hello
printf '{"type":"flow","internal":false,"interface":"wan","flow":{"digest":"N5R00001","local_ip":"%s","local_mac":"%s","other_ip":"93.184.216.34","other_mac":"02:00:00:00:00:ff","local_port":40005,"other_port":443,"ip_protocol":6,"detected_application":0,"detected_application_name":"","detected_protocol":91,"detected_protocol_name":"TLS","category":{"application":10,"protocol":20},"local_bytes":0,"other_bytes":0,"total_bytes":0}}\n' "$RIP" "$RMAC"
flow_ev N5L00001 1001 lan-client 400 600 1000 true 02:00:00:00:00:aa
printf '{"type":"flow_stats","internal":false,"interface":"wan","flow":{"digest":"N5R00001","local_bytes":700,"other_bytes":1300,"total_bytes":2000}}\n'
printf '{"type":"flow_stats","internal":false,"interface":"wan","flow":{"digest":"N5R00001","local_bytes":700,"other_bytes":1300,"total_bytes":4000}}\n'
} > "$EV"
feed_and_settle
# SABOTAGE: delete the two `fr.local_ip`/`fr.other_ip` assignments in
# flow_identify(), or make ct_nat_twin() key off f.local_ip again. The
# router row then reads MISSING, which is what 1.1.2 did.
chk "a router flow seen before any LAN flow still counts" "4000" "$(dev_bytes router)"
uci delete appflow.main.conntrack_path 2>/dev/null
uci commit appflow
rm -f "$CT"
else
skip "no WAN address/mac in netifyd status; cannot forge a router flow"
fi
# THE SHORTFALL, seen once in 133 updates on netifyd 5.2: total_bytes grew
# by more than local_bytes + other_bytes. The difference showed as leaked.
#
# first update local 400 other 600 total 1000
# second update local 100 other 100 total 1500 (grew 500, deltas 200)
#
# Expected: the device gets 1500, the cumulative total, and nothing leaks.
# SABOTAGE: delete the `sum > 0 && sum < dt` branch in flow_update(). The
# device then reads 1200 and leaked reads 300.
{
hello
flow_ev N5S00001 1001 lan-client 400 600 1000 true 02:00:00:00:00:bb
printf '{"type":"flow_stats","internal":true,"interface":"br-lan","flow":{"digest":"N5S00001","local_bytes":100,"other_bytes":100,"total_bytes":1500}}\n'
} > "$EV"
feed_and_settle
chk "a shortfall update still credits the full growth" "1500" "$(dev_bytes 02:00:00:00:00:bb)"
chk "and nothing is reported as leaked" "0" "$(field bytes leaked)"
# 5.x sends flow_dpi_update / flow_dpi_complete as classification firms
# up. They carry nothing appflow needs; they must not count as unknown.
# SABOTAGE: drop them from HANDLERS. unknown then reads 2.
{
hello
flow_ev N5D00001 1001 lan-client 400 600 1000 true 02:00:00:00:00:cc
printf '{"type":"flow_dpi_update","flow":{"digest":"N5D00001"}}\n'
printf '{"type":"flow_dpi_complete","flow":{"digest":"N5D00001"}}\n'
} > "$EV"
feed_and_settle
chk "5.x classification events are not counted as unknown" "0" "$(field events unknown)"
# Connected but silent: on 5.x the old socket path accepts a connection
# and sends nothing, and that read as a healthy idle router. A minute with
# no events at all must raise the flag. One hello, then nothing; the
# housekeeping sweep runs every 15 s, so wait past 60 + 15.
# SABOTAGE: never set `silent` in housekeeping(). The flag then reads false.
#
# The fake hangs up after HOLD_S, appflowd reconnects and the fake replays
# the hello, which is not silence. The first run of this check failed that
# way, against a daemon that was right. Hold the line past the wait here.
local HOLD_S=150
hello > "$EV"
feed_and_settle
note "waiting 80 s for the silence to register"
sleep 80
chk "a connection that sends nothing is flagged silent" "true" \
"$(st | jsonfilter -e '@.socket.silent' 2>/dev/null)"
}
test_ai_breakout() {
head2 "AI BREAKOUT (hostname matching, and the things it must NOT match)"
@@ -1860,12 +1958,13 @@ case "$GROUP" in
acl) test_acl ;;
ai) test_ai_breakout ;;
router) test_router_origin ;;
netify5) test_netify5 ;;
hostmap) test_hostmap ;;
drilldown) test_drilldown ;;
all) test_conservation; test_protocol; test_bounds
test_attribution; test_hostile; test_lifecycle; test_acl
test_ai_breakout; test_hostmap; test_drilldown
test_router_origin ;;
test_router_origin; test_netify5 ;;
*) printf "unknown group '%s'\n" "$GROUP"; exit 2 ;;
esac
+3 -3
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.317
PKG_RELEASE:=26
PKG_VERSION:=0.1.320
PKG_RELEASE:=27
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
@@ -104,7 +104,7 @@ define Package/luci-app-open-box/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
rm -rf /tmp/luci-*cache* 2>/dev/null || true
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/rpcd reload >/dev/null 2>&1 || true
/etc/init.d/openbox-panel restart >/dev/null 2>&1 || true
}
exit 0
@@ -1,6 +0,0 @@
config openbox 'main'
option enabled '1'
option port '3036'
option channel 'mirror'
option mirror_prefix 'https://ghfast.top'
option auto_restart '1'
-92
View File
@@ -1,92 +0,0 @@
#!/bin/sh /etc/rc.common
# Copyright (C) 2026 OpenWrt.org
# Open-Box Core Service (sing-box transparent proxy)
START=96
STOP=09
USE_PROCD=1
INSTALL_ROOT="/opt/open-box"
openbox_cleanup() {
if command -v uci >/dev/null 2>&1; then
local changed=0
for rule in openbox_dns openbox_tun_forward openbox_tun_input openbox_v6block; do
if [ -n "$(uci -q get firewall.$rule 2>/dev/null)" ]; then
uci -q delete firewall.$rule
changed=1
fi
done
for _ob_rule in $(uci -q show firewall 2>/dev/null | sed -n 's/^firewall\.\(openbox_srv_[A-Za-z0-9_]*\)=rule$/\1/p'); do
uci -q delete "firewall.$_ob_rule" || true
changed=1
done
if [ "$changed" = "1" ] && [ -n "$(uci -q changes firewall)" ]; then
uci -q commit firewall
/etc/init.d/firewall reload >/dev/null 2>&1 || true
fi
fi
if command -v nft >/dev/null 2>&1; then
nft delete table inet openbox 2>/dev/null || true
fi
}
start_service() {
if [ -f "$INSTALL_ROOT/openwrt/initd/openbox" ] && [ ! -L "$INSTALL_ROOT/openwrt/initd/openbox" ]; then
"$INSTALL_ROOT/openwrt/initd/openbox" start
return $?
fi
if [ ! -d "$INSTALL_ROOT" ]; then
echo "[openbox] 核心组件尚未安装在 $INSTALL_ROOT,请先安装或下载核心组件。" >&2
return 1
fi
local deploy_cli="$INSTALL_ROOT/panel/server/cli/deploy.mjs"
local node_bin="$INSTALL_ROOT/node/bin/node"
local core_bin="$INSTALL_ROOT/bin/sing-box"
if [ -x "$node_bin" ] && [ -f "$deploy_cli" ]; then
OPENBOX_ROOT="$INSTALL_ROOT" ZASHBOARD_DB_PATH="$INSTALL_ROOT/data/openbox.sqlite" \
OPENSSL_CONF=/dev/null LD_LIBRARY_PATH="$INSTALL_ROOT/node/lib" \
"$node_bin" "$deploy_cli" >/dev/null 2>&1
return $?
elif [ -x "$core_bin" ] && [ -f "$INSTALL_ROOT/etc/config.json" ]; then
procd_open_instance openbox
procd_set_param command "$core_bin" run -c "$INSTALL_ROOT/etc/config.json" -D "$INSTALL_ROOT"
procd_set_param respawn
procd_set_param limits core="unlimited"
procd_close_instance
else
echo "[openbox] 核心服务未就绪,请在面板完成订阅或分流配置后启动。" >&2
return 1
fi
}
stop_service() {
if [ -f "$INSTALL_ROOT/openwrt/initd/openbox" ] && [ ! -L "$INSTALL_ROOT/openwrt/initd/openbox" ]; then
"$INSTALL_ROOT/openwrt/initd/openbox" stop
return $?
fi
if [ -x "$INSTALL_ROOT/bin/sing-box" ]; then
killall -9 sing-box 2>/dev/null || true
fi
openbox_cleanup
}
restart() {
stop
sleep 1
start
}
status() {
if [ -x "$INSTALL_ROOT/bin/sing-box" ] && pidof sing-box >/dev/null; then
echo "openbox is running (pid $(pidof sing-box))"
return 0
fi
echo "openbox is not running"
return 1
}
@@ -1,94 +0,0 @@
#!/bin/sh /etc/rc.common
# Copyright (C) 2026 OpenWrt.org
# Open-Box Panel Web Service
START=95
STOP=10
USE_PROCD=1
INSTALL_ROOT="/opt/open-box"
openbox_get_port() {
local p=""
[ -f "$INSTALL_ROOT/data/panel-port" ] && p=$(cat "$INSTALL_ROOT/data/panel-port" 2>/dev/null | tr -dc '0-9')
if [ -z "$p" ]; then
config_load openbox
config_get p main port 3036
fi
[ -n "$p" ] || p=3036
echo "$p"
}
ensure_firewall_rule() {
local port="$1"
command -v uci >/dev/null 2>&1 || return 0
local cur_port=$(uci -q get firewall.openbox_panel.dest_port 2>/dev/null)
if [ "$cur_port" != "$port" ]; then
uci -q delete firewall.openbox_panel 2>/dev/null || true
uci -q set firewall.openbox_panel=rule
uci -q set firewall.openbox_panel.name='Open-Box Panel'
uci -q set firewall.openbox_panel.src='lan'
uci -q set firewall.openbox_panel.proto='tcp'
uci -q set firewall.openbox_panel.dest_port="$port"
uci -q set firewall.openbox_panel.target='ACCEPT'
uci -q commit firewall
/etc/init.d/firewall reload >/dev/null 2>&1 || true
fi
}
start_service() {
if [ -f "$INSTALL_ROOT/openwrt/initd/openbox-panel" ] && [ ! -L "$INSTALL_ROOT/openwrt/initd/openbox-panel" ]; then
"$INSTALL_ROOT/openwrt/initd/openbox-panel" start
return $?
fi
if [ ! -d "$INSTALL_ROOT" ] || [ ! -x "$INSTALL_ROOT/node/bin/node" ]; then
echo "[openbox-panel] 核心未安装在 $INSTALL_ROOT,请先执行 open-box install 或在 LuCI 页面下载安装核心。" >&2
return 1
fi
local node_bin="$INSTALL_ROOT/node/bin/node"
local panel_app="$INSTALL_ROOT/panel/server/index.mjs"
[ -f "$panel_app" ] || panel_app="$INSTALL_ROOT/panel/server/index.js"
local port=$(openbox_get_port)
mkdir -p "$INSTALL_ROOT/data" 2>/dev/null || true
echo "$port" > "$INSTALL_ROOT/data/panel-port"
ensure_firewall_rule "$port"
if [ -f "$INSTALL_ROOT/openwrt/bin/open-box" ] && [ ! -e /usr/bin/open-box ]; then
ln -sf "$INSTALL_ROOT/openwrt/bin/open-box" /usr/bin/open-box 2>/dev/null || true
fi
procd_open_instance openbox-panel
procd_set_param command "$node_bin" "$panel_app"
procd_set_param env OPENBOX_ROOT="$INSTALL_ROOT" OPENSSL_CONF=/dev/null LD_LIBRARY_PATH="$INSTALL_ROOT/node/lib" PORT="$port"
procd_set_param respawn 3600 5 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
if [ -f "$INSTALL_ROOT/openwrt/initd/openbox-panel" ] && [ ! -L "$INSTALL_ROOT/openwrt/initd/openbox-panel" ]; then
"$INSTALL_ROOT/openwrt/initd/openbox-panel" stop
return $?
fi
return 0
}
status() {
if pgrep -f "$INSTALL_ROOT/panel/server" >/dev/null 2>&1; then
local pid=$(pgrep -f "$INSTALL_ROOT/panel/server" | head -n 1)
local port=$(openbox_get_port)
echo "openbox-panel is running (pid $pid, port $port)"
return 0
fi
echo "openbox-panel is not running"
return 1
}
service_triggers() {
procd_add_reload_trigger "openbox"
}
@@ -1,26 +0,0 @@
#!/bin/sh
# Set default firewall rule for openbox-panel
uci -q get firewall.openbox_panel >/dev/null || {
uci -q set firewall.openbox_panel=rule
uci -q set firewall.openbox_panel.name='Open-Box Panel'
uci -q set firewall.openbox_panel.src='lan'
uci -q set firewall.openbox_panel.proto='tcp'
uci -q set firewall.openbox_panel.dest_port='3036'
uci -q set firewall.openbox_panel.target='ACCEPT'
uci -q commit firewall
}
# Create open-box CLI link if available
[ -e /usr/bin/open-box ] || {
if [ -f /usr/share/openbox/open-box ]; then
ln -sf /usr/share/openbox/open-box /usr/bin/open-box
elif [ -f /opt/open-box/openwrt/bin/open-box ]; then
ln -sf /opt/open-box/openwrt/bin/open-box /usr/bin/open-box
fi
}
# Clean LuCI cache
rm -rf /tmp/luci-*cache* 2>/dev/null || true
exit 0
-208
View File
@@ -1,208 +0,0 @@
#!/bin/sh
# Open-Box 命令行管理工具
INSTALL_ROOT="/opt/open-box"
OPENBOX_PORT_FILE="$INSTALL_ROOT/data/panel-port"
OPENBOX_PORT_FALLBACK=2026
OPENBOX_PORT_DEFAULT=3036
OPENBOX_RESERVED_PORTS="9095 7853 7854 7891 53 22 80 443"
if [ -f "$INSTALL_ROOT/openwrt/bin/open-box" ] && [ "$0" != "$INSTALL_ROOT/openwrt/bin/open-box" ] && [ ! -L "$INSTALL_ROOT/openwrt/bin/open-box" ]; then
exec "$INSTALL_ROOT/openwrt/bin/open-box" "$@"
fi
openbox_port_problem() {
_p="$1"
case "$_p" in
''|*[!0-9]*) echo "端口要填 1024-65535 的数字"; return 0 ;;
esac
if [ "$_p" -lt 1024 ] || [ "$_p" -gt 65535 ]; then
echo "端口要在 1024-65535 之间(1024 以下是系统保留端口)"
return 0
fi
for _r in $OPENBOX_RESERVED_PORTS; do
if [ "$_p" = "$_r" ]; then
echo "$_p 是 Open-Box 自己或系统服务在用的端口(内核 API / DNS / SSH / HTTP 等),换一个"
return 0
fi
done
_busy=""
if command -v ss >/dev/null 2>&1; then
_busy=$(ss -ltn 2>/dev/null | awk 'NR>1 {print $4}' | sed 's/.*[:.]//' | grep -x "$_p" | head -n 1)
elif command -v netstat >/dev/null 2>&1; then
_busy=$(netstat -ltn 2>/dev/null | awk '{print $4}' | sed 's/.*[:.]//' | grep -x "$_p" | head -n 1)
fi
if [ -n "$_busy" ]; then
echo "$_p 已经被别的程序占用(已在监听)"
return 0
fi
return 1
}
openbox_current_port() {
_cp=$(cat "$OPENBOX_PORT_FILE" 2>/dev/null | tr -dc '0-9')
[ -n "$_cp" ] || _cp="$OPENBOX_PORT_DEFAULT"
printf '%s\n' "$_cp"
}
openbox_get_lan_ip() {
LAN_IP=""
command -v uci >/dev/null 2>&1 && LAN_IP=$(uci -q get network.lan.ipaddr 2>/dev/null | tr " " "\n" | head -n 1 | cut -d/ -f1)
if [ -z "$LAN_IP" ] && command -v ip >/dev/null 2>&1; then
LAN_IP=$(ip -4 -o addr show br-lan 2>/dev/null | awk '{ print $4 }' | cut -d/ -f1 | head -n 1)
fi
[ -n "$LAN_IP" ] || LAN_IP="192.168.1.1"
printf '%s\n' "$LAN_IP"
}
openbox_get_password() {
if [ -f "$INSTALL_ROOT/data/openbox.sqlite" ] && [ -x "$INSTALL_ROOT/node/bin/node" ]; then
_pw=$(OPENSSL_CONF=/dev/null LD_LIBRARY_PATH="$INSTALL_ROOT/node/lib" "$INSTALL_ROOT/node/bin/node" -e '
try {
const sqlite3 = require("sqlite3");
const db = new sqlite3.Database("'$INSTALL_ROOT'/data/openbox.sqlite", sqlite3.OPEN_READONLY, (err) => {
if (err) process.exit(1);
db.get("SELECT value FROM settings WHERE key = ?", ["password"], (e, row) => {
if (row && row.value) console.log(row.value);
db.close();
});
});
} catch (_) {}
' 2>/dev/null)
if [ -n "$_pw" ]; then
printf '%s\n' "$_pw"
return 0
fi
fi
if [ -f "$INSTALL_ROOT/data/password" ]; then
cat "$INSTALL_ROOT/data/password" 2>/dev/null
return 0
fi
echo "尚未设置(首次打开面板设置管理密码)"
}
openbox_get_version() {
_ver=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$INSTALL_ROOT/meta.json" 2>/dev/null | head -n 1)
[ -n "$_ver" ] || _ver="未安装"
printf '%s\n' "$_ver"
}
cmd="${1:-}"
case "$cmd" in
password)
openbox_get_password
exit 0
;;
port)
if [ $# -ge 2 ]; then
new_port="$2"
_reason=$(openbox_port_problem "$new_port") && {
echo "面板端口不能用:$_reason" >&2
exit 1
}
mkdir -p "$INSTALL_ROOT/data"
printf '%s\n' "$new_port" > "$OPENBOX_PORT_FILE"
if command -v uci >/dev/null 2>&1; then
uci -q set openbox.main.port="$new_port"
uci -q set firewall.openbox_panel.dest_port="$new_port"
uci -q commit openbox 2>/dev/null || true
uci -q commit firewall 2>/dev/null || true
/etc/init.d/firewall reload >/dev/null 2>&1 || true
fi
echo "面板端口已修改为: $new_port"
/etc/init.d/openbox-panel restart >/dev/null 2>&1 || true
exit 0
else
openbox_current_port
exit 0
fi
;;
check)
if [ -f "$INSTALL_ROOT/update.sh" ]; then
sh "$INSTALL_ROOT/update.sh" --probe direct
elif [ -f /usr/share/openbox/update.sh ]; then
sh /usr/share/openbox/update.sh --probe direct
else
echo "未找到更新脚本"
fi
exit 0
;;
update)
shift
if [ -f "$INSTALL_ROOT/update.sh" ]; then
sh "$INSTALL_ROOT/update.sh" "$@"
elif [ -f /usr/share/openbox/update.sh ]; then
sh /usr/share/openbox/update.sh "$@"
else
echo "未找到更新脚本" >&2
exit 1
fi
exit 0
;;
restart)
/etc/init.d/openbox restart
/etc/init.d/openbox-panel restart
echo "已重启 Open-Box 服务与面板"
exit 0
;;
uninstall)
shift
if [ -f "$INSTALL_ROOT/uninstall.sh" ]; then
sh "$INSTALL_ROOT/uninstall.sh" "$@"
elif [ -f /usr/share/openbox/uninstall.sh ]; then
sh /usr/share/openbox/uninstall.sh "$@"
else
/etc/init.d/openbox stop
/etc/init.d/openbox-panel stop
rm -rf "$INSTALL_ROOT"
echo "已停止并清理"
fi
exit 0
;;
install)
shift
if [ -f /usr/share/openbox/install.sh ]; then
sh /usr/share/openbox/install.sh "$@"
else
curl -fsSL https://raw.githubusercontent.com/liandu2024/Open-Box/main/scripts/install.sh | sh -s -- "$@"
fi
exit 0
;;
*)
VERSION=$(openbox_get_version)
echo ""
echo "Open-Box $VERSION"
echo " 1) 当前密码"
echo " 2) 重新启动"
echo " 3) 检查升级"
echo " 4) 卸载"
echo " 5) 退出"
printf '请选择 [1-5]: '
read -r choice
case "$choice" in
1)
port=$(openbox_current_port)
lan_ip=$(openbox_get_lan_ip)
pw=$(openbox_get_password)
echo ""
echo " 面板地址: http://$lan_ip:$port"
echo " 当前密码: $pw"
echo ""
;;
2)
/etc/init.d/openbox restart
/etc/init.d/openbox-panel restart
echo "服务已重启。"
;;
3)
sh "$0" update
;;
4)
sh "$0" uninstall
;;
5|*)
exit 0
;;
esac
;;
esac
@@ -1,12 +0,0 @@
{
"admin/services/openbox": {
"title": "Open-Box",
"action": {
"type": "view",
"path": "openbox/main"
},
"depends": {
"acl": [ "luci-app-openbox" ]
}
}
}
@@ -1,136 +0,0 @@
#!/bin/sh
# Open-Box 一键安装脚本
set -eu
umask 022
REPO="liandu2024/Open-Box"
INSTALL_ROOT="/opt/open-box"
CHANNEL="mirror"
MIRROR_PREFIX="https://ghfast.top"
PANEL_PORT="3036"
info() { echo "[open-box] $*"; }
warn() { echo "[open-box] 警告:$*" >&2; }
die() { echo "[open-box] 错误:$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--mirror)
CHANNEL="mirror"
shift
if [ $# -ge 1 ]; then
case "$1" in
--*) ;;
*) MIRROR_PREFIX="$1"; shift ;;
esac
fi
;;
--direct)
CHANNEL="direct"
MIRROR_PREFIX=""
shift
;;
--port)
shift
[ $# -ge 1 ] || die "--port 后面要跟端口号"
PANEL_PORT="$1"
shift
;;
*) shift ;;
esac
done
detect_arch() {
RAW_ARCH=$(uname -m 2>/dev/null || true)
case "$RAW_ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
*) die "不支持的 CPU 架构:${RAW_ARCH:-未知}。Open-Box 目前仅支持 x86_64 与 aarch64(arm64)。" ;;
esac
}
detect_arch
if [ -f "/etc/config/openbox" ]; then
p=$(uci -q get openbox.main.port 2>/dev/null || true)
[ -n "$p" ] && PANEL_PORT="$p"
fi
TMP_DL=$(mktemp -d /tmp/openbox-inst.XXXXXX 2>/dev/null || echo "/tmp/openbox-inst.$$")
mkdir -p "$TMP_DL"
trap 'rm -rf "$TMP_DL"' EXIT INT TERM
ASSET="open-box-linux-${ARCH}.tar.gz"
ASSET_URL="https://github.com/$REPO/releases/latest/download/$ASSET"
SHA_URL="$ASSET_URL.sha256"
build_url() {
url="$1"
if [ "$CHANNEL" = "mirror" ] && [ -n "$MIRROR_PREFIX" ]; then
case "$MIRROR_PREFIX" in
http://*|https://*) printf '%s/%s
' "${MIRROR_PREFIX%/}" "$url" ;;
*) printf 'https://%s/%s
' "${MIRROR_PREFIX%/}" "$url" ;;
esac
else
printf '%s
' "$url"
fi
}
info "下载校验和与发布包: $ASSET ..."
curl -fsSL -o "$TMP_DL/$ASSET.sha256" "$(build_url "$SHA_URL")" 2>/dev/null || wget -q -O "$TMP_DL/$ASSET.sha256" "$(build_url "$SHA_URL")" || die "下载校验文件失败"
curl -fsSL -o "$TMP_DL/$ASSET" "$(build_url "$ASSET_URL")" 2>/dev/null || wget -q -O "$TMP_DL/$ASSET" "$(build_url "$ASSET_URL")" || die "下载发布包失败"
info "校验 SHA256 ..."
( cd "$TMP_DL" && sha256sum -c "$ASSET.sha256" >/dev/null 2>&1 ) || die "安装包校验失败 (SHA256 不匹配)"
info "解包到 $INSTALL_ROOT ..."
mkdir -p "$INSTALL_ROOT"
tar -xzf "$TMP_DL/$ASSET" -C "$INSTALL_ROOT" || die "解包失败"
chown -R 0:0 "$INSTALL_ROOT" 2>/dev/null || true
mkdir -p "$INSTALL_ROOT/data"
printf '%s
' "$PANEL_PORT" > "$INSTALL_ROOT/data/panel-port"
if [ "$CHANNEL" = "mirror" ]; then
printf 'mirror
%s
' "$MIRROR_PREFIX" > "$INSTALL_ROOT/data/channel"
else
printf 'direct
' > "$INSTALL_ROOT/data/channel"
fi
cp -f "$INSTALL_ROOT/openwrt/initd/openbox" /etc/init.d/openbox 2>/dev/null || true
cp -f "$INSTALL_ROOT/openwrt/initd/openbox-panel" /etc/init.d/openbox-panel 2>/dev/null || true
chmod +x /etc/init.d/openbox /etc/init.d/openbox-panel 2>/dev/null || true
if [ -f "$INSTALL_ROOT/openwrt/bin/open-box" ]; then
chmod +x "$INSTALL_ROOT/openwrt/bin/open-box" 2>/dev/null || true
ln -sf "$INSTALL_ROOT/openwrt/bin/open-box" /usr/bin/open-box 2>/dev/null || true
fi
if [ -d "$INSTALL_ROOT/openwrt/luci/htdocs/luci-static/resources/view/openbox" ]; then
mkdir -p /www/luci-static/resources/view/openbox
cp -f "$INSTALL_ROOT/openwrt/luci/htdocs/luci-static/resources/view/openbox/"*.js /www/luci-static/resources/view/openbox/ 2>/dev/null || true
fi
if [ -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/luci/menu.d/luci-app-openbox.json" ]; then
mkdir -p /usr/share/luci/menu.d
cp -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/luci/menu.d/luci-app-openbox.json" /usr/share/luci/menu.d/ 2>/dev/null || true
fi
if [ -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/rpcd/acl.d/luci-app-openbox.json" ]; then
mkdir -p /usr/share/rpcd/acl.d
cp -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/rpcd/acl.d/luci-app-openbox.json" /usr/share/rpcd/acl.d/ 2>/dev/null || true
fi
rm -rf /tmp/luci-*cache* 2>/dev/null || true
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/openbox-panel enable >/dev/null 2>&1 || true
/etc/init.d/openbox-panel restart >/dev/null 2>&1 || true
info "Open-Box 核心组件安装完成!"
@@ -1,85 +0,0 @@
#!/bin/sh
# Open-Box 卸载清理脚本
set -eu
INSTALL_ROOT="/opt/open-box"
STATUS_PATH="/tmp/openbox-uninstall.status"
PURGE=0
DETACH=0
while [ $# -gt 0 ]; do
case "$1" in
--purge) PURGE=1; shift ;;
--detach) DETACH=1; shift ;;
*) shift ;;
esac
done
if [ "$DETACH" = "1" ]; then
_args=""
[ "$PURGE" = "1" ] && _args="--purge"
nohup sh "$0" $_args >/dev/null 2>&1 &
echo "卸载已在后台启动"
exit 0
fi
{
echo "pid=$$"
echo "stage=stopping"
} > "$STATUS_PATH" 2>/dev/null || true
/etc/init.d/openbox stop >/dev/null 2>&1 || true
/etc/init.d/openbox-panel stop >/dev/null 2>&1 || true
/etc/init.d/openbox disable >/dev/null 2>&1 || true
/etc/init.d/openbox-panel disable >/dev/null 2>&1 || true
{
echo "pid=$$"
echo "stage=firewall"
} > "$STATUS_PATH" 2>/dev/null || true
if command -v uci >/dev/null 2>&1; then
uci -q delete firewall.openbox_panel || true
uci -q delete firewall.openbox_dns || true
uci -q delete firewall.openbox_tun_forward || true
uci -q delete firewall.openbox_tun_input || true
uci -q delete firewall.openbox_v6block || true
for _ob_rule in $(uci -q show firewall 2>/dev/null | sed -n 's/^firewall\.\(openbox_srv_[A-Za-z0-9_]*\)=rule$/\1/p'); do
uci -q delete "firewall.$_ob_rule" || true
done
if [ -n "$(uci -q changes firewall)" ]; then
uci -q commit firewall
/etc/init.d/firewall reload >/dev/null 2>&1 || true
fi
fi
{
echo "pid=$$"
echo "stage=files"
} > "$STATUS_PATH" 2>/dev/null || true
rm -f /etc/init.d/openbox /etc/init.d/openbox-panel
rm -f /www/luci-static/resources/view/openbox/main.js /www/luci-static/resources/view/openbox/status.js
rmdir /www/luci-static/resources/view/openbox 2>/dev/null || true
rm -f /usr/share/luci/menu.d/luci-app-openbox.json
rm -f /usr/share/rpcd/acl.d/luci-app-openbox.json
[ -L /usr/bin/open-box ] && rm -f /usr/bin/open-box
rm -rf /tmp/luci-*cache* 2>/dev/null || true
if [ "$PURGE" = "1" ]; then
rm -rf "$INSTALL_ROOT"
else
for entry in "$INSTALL_ROOT"/*; do
[ -e "$entry" ] || continue
base=$(basename -- "$entry")
[ "$base" = "data" ] && continue
rm -rf "$entry"
done
fi
{
echo "pid=$$"
echo "stage=done"
} > "$STATUS_PATH" 2>/dev/null || true
echo "Open-Box 卸载完成"
@@ -1,204 +0,0 @@
#!/bin/sh
# Open-Box 更新/回退脚本
set -eu
umask 022
REPO="liandu2024/Open-Box"
INSTALL_ROOT="/opt/open-box"
STATUS_PATH="/tmp/openbox-update.status"
CANCEL_FLAG="/tmp/openbox-update.cancel"
UPDATE_LOG="/tmp/openbox-update.log"
STATUS_PID="$$"
DETACH=0
ROLLBACK_MODE=0
CHANNEL="direct"
MIRROR_PREFIX=""
PROBE_CHANNEL=""
CANCEL_MODE=0
info() { echo "[open-box] $*"; }
warn() { echo "[open-box] 警告:$*" >&2; }
die() {
echo "[open-box] 错误:$*" >&2
write_status failed "" "" "$*"
exit 1
}
write_status() {
[ -n "$STATUS_PID" ] || return 0
_ws_tmp="$STATUS_PATH.$$.tmp"
{
echo "pid=$STATUS_PID"
echo "stage=$1"
echo "bytes=${2:-}"
echo "total=${3:-}"
echo "message=${4:-}"
} > "$_ws_tmp" 2>/dev/null && mv -f "$_ws_tmp" "$STATUS_PATH" 2>/dev/null || true
}
check_cancel() {
if [ -e "$CANCEL_FLAG" ]; then
write_status cancelled "" "" "已取消更新"
exit 0
fi
}
while [ $# -gt 0 ]; do
case "$1" in
--detach) DETACH=1; shift ;;
--rollback) ROLLBACK_MODE=1; shift ;;
--cancel) CANCEL_MODE=1; shift ;;
--direct) CHANNEL="direct"; MIRROR_PREFIX=""; shift ;;
--mirror)
CHANNEL="mirror"
shift
if [ $# -ge 1 ]; then
case "$1" in
--*) ;;
*) MIRROR_PREFIX="$1"; shift ;;
esac
fi
;;
--probe)
shift
PROBE_CHANNEL="$1"
shift
;;
*) shift ;;
esac
done
if [ "$CANCEL_MODE" = "1" ]; then
touch "$CANCEL_FLAG" 2>/dev/null || true
echo "requested"
exit 0
fi
if [ -n "$PROBE_CHANNEL" ]; then
start_time=$(date +%s%N 2>/dev/null || date +%s)
target="https://github.com/$REPO/releases/latest/download/open-box-linux-x64.tar.gz.sha256"
if [ "$PROBE_CHANNEL" != "direct" ]; then
case "$PROBE_CHANNEL" in
http://*|https://*) target="${PROBE_CHANNEL%/}/$target" ;;
*) target="https://${PROBE_CHANNEL%/}/$target" ;;
esac
fi
if curl -fsSL --connect-timeout 8 --max-time 15 -o /dev/null "$target" 2>/dev/null || wget -q --timeout=15 -O /dev/null "$target" 2>/dev/null; then
end_time=$(date +%s%N 2>/dev/null || date +%s)
ms=$(( (end_time - start_time) / 1000000 2>/dev/null || 100 ))
[ "$ms" -le 0 ] && ms=80
echo "ok $ms ms"
else
echo "fail 连接超时或无法访问"
fi
exit 0
fi
if [ "$DETACH" = "1" ]; then
: > "$UPDATE_LOG" 2>/dev/null || true
write_status starting "" "" ""
rm -f "$CANCEL_FLAG" 2>/dev/null || true
_args=""
[ "$ROLLBACK_MODE" = "1" ] && _args="$_args --rollback"
if [ "$CHANNEL" = "mirror" ]; then
_args="$_args --mirror $MIRROR_PREFIX"
else
_args="$_args --direct"
fi
nohup sh "$0" $_args >"$UPDATE_LOG" 2>&1 </dev/null &
echo "升级已在后台启动,进度见 $STATUS_PATH"
exit 0
fi
write_status probing "" "" ""
check_cancel
RAW_ARCH=$(uname -m 2>/dev/null || true)
case "$RAW_ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
*) die "不支持的 CPU 架构: $RAW_ARCH" ;;
esac
ASSET="open-box-linux-${ARCH}.tar.gz"
ASSET_URL="https://github.com/$REPO/releases/latest/download/$ASSET"
SHA_URL="$ASSET_URL.sha256"
build_url() {
url="$1"
if [ "$CHANNEL" = "mirror" ] && [ -n "$MIRROR_PREFIX" ]; then
case "$MIRROR_PREFIX" in
http://*|https://*) printf '%s/%s
' "${MIRROR_PREFIX%/}" "$url" ;;
*) printf 'https://%s/%s
' "${MIRROR_PREFIX%/}" "$url" ;;
esac
else
printf '%s
' "$url"
fi
}
TMP_DL=$(mktemp -d /tmp/openbox-upd.XXXXXX 2>/dev/null || echo "/tmp/openbox-upd.$$")
mkdir -p "$TMP_DL"
trap 'rm -rf "$TMP_DL"' EXIT INT TERM
write_status downloading 0 100000000 "开始下载"
check_cancel
curl -fsSL -o "$TMP_DL/$ASSET.sha256" "$(build_url "$SHA_URL")" 2>/dev/null || wget -q -O "$TMP_DL/$ASSET.sha256" "$(build_url "$SHA_URL")" || die "下载校验和失败"
check_cancel
curl -fsSL -o "$TMP_DL/$ASSET" "$(build_url "$ASSET_URL")" 2>/dev/null || wget -q -O "$TMP_DL/$ASSET" "$(build_url "$ASSET_URL")" || die "下载升级包失败"
check_cancel
write_status verifying "" "" ""
( cd "$TMP_DL" && sha256sum -c "$ASSET.sha256" >/dev/null 2>&1 ) || die "校验失败 (SHA256 不匹配)"
write_status extracting "" "" ""
check_cancel
STAGE_DIR="$INSTALL_ROOT/.update-stage.$$"
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"
tar -xzf "$TMP_DL/$ASSET" -C "$STAGE_DIR" || die "解包失败"
write_status committing "" "" ""
/etc/init.d/openbox-panel stop >/dev/null 2>&1 || true
/etc/init.d/openbox stop >/dev/null 2>&1 || true
for comp in node panel bin openwrt; do
if [ -e "$STAGE_DIR/$comp" ]; then
rm -rf "$INSTALL_ROOT/$comp"
mv "$STAGE_DIR/$comp" "$INSTALL_ROOT/$comp"
fi
done
[ -e "$STAGE_DIR/meta.json" ] && mv "$STAGE_DIR/meta.json" "$INSTALL_ROOT/meta.json"
rm -rf "$STAGE_DIR"
cp -f "$INSTALL_ROOT/openwrt/initd/openbox" /etc/init.d/openbox 2>/dev/null || true
cp -f "$INSTALL_ROOT/openwrt/initd/openbox-panel" /etc/init.d/openbox-panel 2>/dev/null || true
chmod +x /etc/init.d/openbox /etc/init.d/openbox-panel 2>/dev/null || true
if [ -f "$INSTALL_ROOT/openwrt/bin/open-box" ]; then
ln -sf "$INSTALL_ROOT/openwrt/bin/open-box" /usr/bin/open-box 2>/dev/null || true
fi
if [ -d "$INSTALL_ROOT/openwrt/luci/htdocs/luci-static/resources/view/openbox" ]; then
cp -f "$INSTALL_ROOT/openwrt/luci/htdocs/luci-static/resources/view/openbox/"*.js /www/luci-static/resources/view/openbox/ 2>/dev/null || true
fi
if [ -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/luci/menu.d/luci-app-openbox.json" ]; then
cp -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/luci/menu.d/luci-app-openbox.json" /usr/share/luci/menu.d/ 2>/dev/null || true
fi
if [ -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/rpcd/acl.d/luci-app-openbox.json" ]; then
cp -f "$INSTALL_ROOT/openwrt/luci/root/usr/share/rpcd/acl.d/luci-app-openbox.json" /usr/share/rpcd/acl.d/ 2>/dev/null || true
fi
rm -rf /tmp/luci-*cache* 2>/dev/null || true
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/openbox-panel start >/dev/null 2>&1 || true
/etc/init.d/openbox start >/dev/null 2>&1 || true
write_status done "" "" "升级完成"
info "升级完成!"
@@ -1,34 +0,0 @@
{
"luci-app-openbox": {
"description": "Grant UCI and system execution access for luci-app-openbox",
"read": {
"cgi-io": [ "backup" ],
"file": {
"/opt/open-box/meta.json": [ "read" ],
"/opt/open-box/data/panel-port": [ "read" ],
"/tmp/openbox-update.status": [ "read" ],
"/tmp/openbox-update.log": [ "read" ],
"/tmp/openbox-uninstall.status": [ "read" ],
"/tmp/openbox-uninstall.log": [ "read" ]
},
"uci": [ "network", "firewall", "openbox" ],
"ubus": {
"service": [ "list" ]
}
},
"write": {
"file": {
"/etc/init.d/openbox": [ "exec" ],
"/etc/init.d/openbox-panel": [ "exec" ],
"/usr/bin/open-box": [ "exec" ],
"/opt/open-box/openwrt/bin/open-box": [ "exec" ],
"/opt/open-box/update.sh": [ "exec" ],
"/opt/open-box/uninstall.sh": [ "exec" ],
"/usr/share/openbox/install.sh": [ "exec" ],
"/usr/share/openbox/update.sh": [ "exec" ],
"/usr/share/openbox/uninstall.sh": [ "exec" ]
},
"uci": [ "firewall", "openbox" ]
}
}
}
@@ -1,456 +0,0 @@
'use strict';
'require view';
'require fs';
'require ui';
'require uci';
'require rpc';
'require poll';
var callServiceList = rpc.declare({
object: 'service',
method: 'list',
params: [ 'name' ],
expect: { '': {} }
});
return view.extend({
pollInterval: 3,
load: function() {
return Promise.all([
fs.read('/opt/open-box/meta.json').catch(function() { return null; }),
fs.read('/opt/open-box/data/panel-port').catch(function() { return null; }),
fs.exec_direct('/usr/bin/open-box', [ 'password' ]).catch(function() { return ''; }),
callServiceList('openbox'),
callServiceList('openbox-panel'),
uci.load('network').catch(function() { return null; }),
uci.load('openbox').catch(function() { return null; })
]);
},
render: function(data) {
var metaRaw = data[0];
var portRaw = data[1];
var password = (data[2] || '').trim();
var openboxSvc = data[3] || {};
var panelSvc = data[4] || {};
var meta = null;
if (metaRaw) {
try { meta = JSON.parse(metaRaw); } catch(e) {}
}
var port = (portRaw || '').replace(/[^0-9]/g, '');
if (!port) {
port = (uci.get('openbox', 'main', 'port') || '3036');
}
var lanIp = uci.get('network', 'lan', 'ipaddr');
if (Array.isArray(lanIp)) lanIp = lanIp[0];
if (lanIp && lanIp.indexOf('/') !== -1) lanIp = lanIp.split('/')[0];
if (!lanIp) lanIp = window.location.hostname || '192.168.1.1';
var panelRunning = (panelSvc['openbox-panel'] && panelSvc['openbox-panel']['instances'] && Object.keys(panelSvc['openbox-panel']['instances']).length > 0);
var coreRunning = (openboxSvc['openbox'] && openboxSvc['openbox']['instances'] && Object.keys(openboxSvc['openbox']['instances']).length > 0);
var panelUrl = 'http://' + lanIp + ':' + port;
var viewRoot = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, [ _('Open-Box') + ' ' + (meta && meta.version ? 'v' + meta.version : '') ]),
E('div', { 'class': 'cbi-map-descr' }, [
_('Open-Box 是路由器上的一体化透明代理方案,内置 sing-box 内核、Node.js 运行时与 Web 管理面板。本页面为 LuCI 兜底管理页,可用于启停服务、修改端口、找回密码或执行维护操作。')
])
]);
// 1. 面板直达与密码卡片
var accessCard = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, [ _('Open-Box 管理面板') ]),
E('div', { 'class': 'cbi-section-descr' }, [
_('日常代理节点选择、分流规则配置、订阅管理及流量统计请在独立 Web 面板中进行。')
]),
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left', 'style': 'width:30%' }, [ E('strong', {}, _('面板访问地址')) ]),
E('div', { 'class': 'td' }, [
E('a', { 'href': panelUrl, 'target': '_blank', 'class': 'cbi-button cbi-button-action' }, [
_('打开管理面板') + ' (' + panelUrl + ')'
])
])
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left' }, [ E('strong', {}, _('面板当前密码')) ]),
E('div', { 'class': 'td' }, [
E('span', { 'style': 'font-family: monospace; font-size: 1.1em; color: #2c3e50; font-weight: bold;' }, [
password || _('尚未设置(首次打开面板设置管理密码)')
]),
password ? E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 1em;',
'click': function() {
navigator.clipboard.writeText(password).then(function() {
ui.addNotification(null, E('p', {}, _('密码已复制到剪贴板。')), 'info');
});
}
}, [ _('复制密码') ]) : E('span')
])
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left' }, [ E('strong', {}, _('面板监听端口')) ]),
E('div', { 'class': 'td' }, [
E('span', { 'style': 'font-family: monospace; margin-right: 1em;' }, [ port ]),
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, 'handlePortChange', port)
}, [ _('修改端口') ])
])
])
])
]);
viewRoot.appendChild(accessCard);
// 2. 服务运行状态卡片
var statusCard = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, [ _('服务运行状态') ]),
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left', 'style': 'width:30%' }, [ E('strong', {}, _('面板服务 (openbox-panel)')) ]),
E('div', { 'class': 'td' }, [
E('span', { 'class': 'badge ' + (panelRunning ? 'badge-success' : 'badge-danger') }, [
panelRunning ? _('运行中 (Running)') : _('已停止 (Stopped)')
]),
E('div', { 'class': 'btn-group', 'style': 'display: inline-block; margin-left: 1.5em;' }, [
E('button', {
'class': 'cbi-button ' + (panelRunning ? 'cbi-button-reset' : 'cbi-button-action'),
'click': ui.createHandlerFn(this, 'handleServiceAction', 'openbox-panel', panelRunning ? 'stop' : 'start')
}, [ panelRunning ? _('停止面板') : _('启动面板') ]),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 0.5em;',
'click': ui.createHandlerFn(this, 'handleServiceAction', 'openbox-panel', 'restart')
}, [ _('重启面板') ])
])
])
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left' }, [ E('strong', {}, _('内核代理服务 (openbox)')) ]),
E('div', { 'class': 'td' }, [
E('span', { 'class': 'badge ' + (coreRunning ? 'badge-success' : 'badge-danger') }, [
coreRunning ? _('运行中 (Running)') : _('已停止 (Stopped)')
]),
E('div', { 'class': 'btn-group', 'style': 'display: inline-block; margin-left: 1.5em;' }, [
E('button', {
'class': 'cbi-button ' + (coreRunning ? 'cbi-button-reset' : 'cbi-button-action'),
'click': ui.createHandlerFn(this, 'handleServiceAction', 'openbox', coreRunning ? 'stop' : 'start')
}, [ coreRunning ? _('紧急停止内核 (恢复直连)') : _('启动内核') ]),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 0.5em;',
'click': ui.createHandlerFn(this, 'handleServiceAction', 'openbox', 'restart')
}, [ _('按配置重载/重启') ])
])
])
])
])
]);
viewRoot.appendChild(statusCard);
// 3. 核心组件与更新维护卡片
var hasCore = (meta !== null);
var maintenanceCard = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, [ _('组件与更新维护') ]),
E('div', { 'class': 'cbi-section-descr' }, [
hasCore ? _('当前已安装 Open-Box 核心组件(安装目录:/opt/open-box)。') :
_('检测到核心组件尚未安装或 /opt/open-box 不完整。您可以直接使用下方一键下载安装功能完成初始化。')
]),
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left', 'style': 'width:30%' }, [ E('strong', {}, _('核心组件状态')) ]),
E('div', { 'class': 'td' }, [
hasCore ? E('span', { 'class': 'badge badge-success' }, [
_('已就绪') + ' (' + (meta.arch || 'unknown') + ', ' + (meta.version || 'v' + meta.version) + ')'
]) : E('span', { 'class': 'badge badge-danger' }, [ _('尚未就绪') ]),
!hasCore ? E('button', {
'class': 'cbi-button cbi-button-action',
'style': 'margin-left: 1em;',
'click': ui.createHandlerFn(this, 'handleInstallCore')
}, [ _('一键安装核心组件') ]) : E('span')
])
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left' }, [ E('strong', {}, _('更新加速镜像')) ]),
E('div', { 'class': 'td' }, [
E('select', { 'id': 'openbox_mirror_select', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'direct' }, _('GitHub 直连 (官方)')),
E('option', { 'value': 'https://ghfast.top', 'selected': 'selected' }, 'ghfast.top (' + _('内置推荐') + ')'),
E('option', { 'value': 'https://gh-proxy.com' }, 'gh-proxy.com'),
E('option', { 'value': 'https://gh.llkk.cc' }, 'gh.llkk.cc')
]),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 1em;',
'click': ui.createHandlerFn(this, 'handleTestLatency')
}, [ _('测速/检测可用性') ]),
E('span', { 'id': 'openbox_probe_result', 'style': 'margin-left: 1em; font-weight: bold;' }, '')
])
]),
E('div', { 'class': 'tr' }, [
E('div', { 'class': 'td left' }, [ E('strong', {}, _('系统升级与维护')) ]),
E('div', { 'class': 'td' }, [
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, 'handleUpdate', false)
}, [ _('检查并升级到最新版') ]),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 0.5em;',
'click': ui.createHandlerFn(this, 'handleUpdate', true)
}, [ _('回退到上个版本 (Rollback)') ]),
E('button', {
'class': 'cbi-button cbi-button-reset',
'style': 'margin-left: 1.5em;',
'click': ui.createHandlerFn(this, 'handleUninstall')
}, [ _('卸载 Open-Box') ])
])
])
])
]);
viewRoot.appendChild(maintenanceCard);
return viewRoot;
},
handleServiceAction: function(service, action) {
ui.showModal(_('正在处理'), [
E('p', { 'class': 'spinning' }, _('正在执行操作,请稍候...'))
]);
return fs.exec('/etc/init.d/' + service, [ action ]).then(function() {
window.location.reload();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('操作失败: ') + e.message), 'error');
ui.hideModal();
});
},
handlePortChange: function(currentPort) {
var input = E('input', {
'type': 'number',
'class': 'cbi-input-text',
'min': 1024,
'max': 65535,
'value': currentPort
});
ui.showModal(_('修改面板端口'), [
E('p', {}, _('请输入 1024 至 65535 之间的可用端口号(修改后将自动重启面板并放行防火墙):')),
input,
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'click': ui.hideModal
}, [ _('取消') ]),
E('button', {
'class': 'cbi-button cbi-button-apply',
'style': 'margin-left: 0.5em;',
'click': function() {
var newPort = input.value.trim();
if (!newPort || isNaN(newPort) || newPort < 1024 || newPort > 65535) {
ui.addNotification(null, E('p', {}, _('请输入合法的端口号 (1024-65535)')), 'error');
return;
}
ui.showModal(_('保存中'), [ E('p', { 'class': 'spinning' }, _('正在应用新端口并重启面板...')) ]);
fs.exec_direct('/usr/bin/open-box', [ 'port', newPort ]).then(function() {
window.location.reload();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('修改失败: ') + e.message), 'error');
ui.hideModal();
});
}
}, [ _('保存并重启') ])
])
]);
},
handleTestLatency: function() {
var select = document.getElementById('openbox_mirror_select');
var mirror = select ? select.value : 'direct';
var resultSpan = document.getElementById('openbox_probe_result');
if (resultSpan) {
resultSpan.textContent = _('正在检测...');
resultSpan.style.color = '#3498db';
}
var args = [ '--probe', mirror ];
var execPath = '/opt/open-box/update.sh';
fs.stat(execPath).catch(function() {
return fs.stat('/usr/share/openbox/update.sh').then(function() {
execPath = '/usr/share/openbox/update.sh';
});
}).then(function() {
return fs.exec_direct(execPath, args);
}).then(function(out) {
if (resultSpan) {
var text = (out || '').trim();
if (text.startsWith('ok')) {
resultSpan.textContent = '✓ ' + text.replace('ok', _('可用') + ': ');
resultSpan.style.color = '#27ae60';
} else {
resultSpan.textContent = '✗ ' + text.replace('fail', _('失败') + ': ');
resultSpan.style.color = '#e74c3c';
}
}
}).catch(function(err) {
if (resultSpan) {
resultSpan.textContent = '✗ ' + (err.message || _('检测出错'));
resultSpan.style.color = '#e74c3c';
}
});
},
handleInstallCore: function() {
var select = document.getElementById('openbox_mirror_select');
var mirror = select ? select.value : 'https://ghfast.top';
var args = [];
if (mirror !== 'direct') {
args = [ '--mirror', mirror ];
}
ui.showModal(_('安装核心组件'), [
E('p', { 'class': 'spinning' }, _('正在下载并部署 Open-Box 完整核心组件(约需 1-3 分钟,视网络速度而定)...'))
]);
var script = '/usr/share/openbox/install.sh';
fs.exec(script, args).then(function() {
ui.addNotification(null, E('p', {}, _('核心组件安装成功!')), 'info');
window.location.reload();
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('安装失败: ') + e.message), 'error');
ui.hideModal();
});
},
handleUpdate: function(isRollback) {
var select = document.getElementById('openbox_mirror_select');
var mirror = select ? select.value : 'direct';
var args = [ '--detach' ];
if (isRollback) args.push('--rollback');
if (mirror === 'direct') {
args.push('--direct');
} else {
args.push('--mirror');
args.push(mirror);
}
var execPath = '/opt/open-box/update.sh';
fs.stat(execPath).catch(function() {
execPath = '/usr/share/openbox/update.sh';
}).then(function() {
return fs.exec_direct(execPath, args);
}).then(function() {
var progressBar = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, [ E('div', { 'style': 'width: 0%' }) ]);
var statusText = E('p', {}, _('任务已派发,正在准备下载...'));
var logPre = E('pre', { 'style': 'max-height: 200px; overflow-y: scroll; font-size: 11px; background: #f8f9fa; padding: 8px;' }, '');
ui.showModal(isRollback ? _('正在执行版本回退') : _('正在升级 Open-Box'), [
statusText,
progressBar,
E('p', { 'style': 'margin-top: 1em;' }, [ E('strong', {}, _('实时日志:')) ]),
logPre,
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
E('button', {
'class': 'cbi-button cbi-button-reset',
'click': function() {
fs.exec_direct(execPath, [ '--cancel' ]);
ui.hideModal();
}
}, [ _('取消更新') ])
])
]);
var timer = window.setInterval(function() {
Promise.all([
fs.read('/tmp/openbox-update.status').catch(function() { return ''; }),
fs.read('/tmp/openbox-update.log').catch(function() { return ''; })
]).then(function(res) {
var st = res[0] || '';
var lg = res[1] || '';
logPre.textContent = lg;
logPre.scrollTop = logPre.scrollHeight;
var stage = (st.match(/stage=([^\n]+)/) || [])[1];
var bytes = parseInt((st.match(/bytes=([0-9]+)/) || [])[1] || 0);
var total = parseInt((st.match(/total=([0-9]+)/) || [])[1] || 0);
var msg = (st.match(/message=([^\n]*)/) || [])[1] || '';
if (stage === 'downloading') {
if (total > 0) {
var pct = Math.floor((bytes / total) * 100);
progressBar.firstElementChild.style.width = pct + '%';
progressBar.setAttribute('title', pct + '%');
statusText.textContent = _('正在下载发布包: ') + (bytes / 1048576).toFixed(1) + 'MB / ' + (total / 1048576).toFixed(1) + 'MB (' + pct + '%)';
} else {
statusText.textContent = _('正在下载发布包: ') + (bytes / 1048576).toFixed(1) + 'MB';
}
} else if (stage === 'extracting') {
progressBar.firstElementChild.style.width = '80%';
statusText.textContent = _('正在解包并校验文件完整性...');
} else if (stage === 'committing') {
progressBar.firstElementChild.style.width = '90%';
statusText.textContent = _('正在替换旧组件并重启服务...');
} else if (stage === 'done') {
window.clearInterval(timer);
progressBar.firstElementChild.style.width = '100%';
statusText.textContent = _('操作完成!') + (msg ? ' (' + msg + ')' : '');
window.setTimeout(function() { window.location.reload(); }, 2000);
} else if (stage === 'failed' || stage === 'cancelled') {
window.clearInterval(timer);
statusText.textContent = (stage === 'failed' ? _('操作失败: ') : _('操作已取消: ')) + msg;
}
});
}, 1000);
}).catch(function(e) {
ui.addNotification(null, E('p', {}, _('启动升级失败: ') + e.message), 'error');
});
},
handleUninstall: function() {
var checkPurge = E('input', { 'type': 'checkbox' });
ui.showModal(_('卸载 Open-Box'), [
E('p', {}, _('确定要从系统中卸载 Open-Box 吗?')),
E('label', { 'class': 'cbi-checkbox', 'style': 'display: block; margin: 1em 0;' }, [
checkPurge,
' ' + _('同时删除所有用户数据(/opt/open-box/data 下的节点订阅、规则集与数据库)')
]),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'click': ui.hideModal
}, [ _('取消') ]),
E('button', {
'class': 'cbi-button cbi-button-reset',
'style': 'margin-left: 0.5em;',
'click': function() {
var purge = checkPurge.checked;
var args = [ '--detach' ];
if (purge) args.push('--purge');
var execPath = '/opt/open-box/uninstall.sh';
fs.stat(execPath).catch(function() {
execPath = '/usr/share/openbox/uninstall.sh';
}).then(function() {
return fs.exec_direct(execPath, args);
}).then(function() {
ui.showModal(_('卸载中'), [
E('p', { 'class': 'spinning' }, _('正在停止服务并清理残留文件...'))
]);
window.setTimeout(function() {
window.location.href = window.location.pathname.replace(/\/admin\/services\/openbox.*/, '/admin/status/overview');
}, 4000);
}).catch(function(err) {
ui.addNotification(null, E('p', {}, _('卸载失败: ') + err.message), 'error');
ui.hideModal();
});
}
}, [ _('确认卸载') ])
])
]);
}
});
+1 -1
View File
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openvpn-plus
PKG_VERSION:=1.0.0
PKG_RELEASE:=2
PKG_RELEASE:=3
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Manfred Jaider <info@masmbit.com>
@@ -9,14 +9,9 @@
# luci-app-openvpn-plus : helper tools and background workers
# /usr/libexec/luci-app-openvpn-plus
# Check if the user gave an argument
if [ -z "$1" ]; then
echo "Usage: $0 {getmac|lanip|getnetstat|bestcrypto|securepath|readfile|writefile|makedir|removefile|removedir|statfile|ovpnver|ovpnstart|ovpnrestart|ovpnstop|ovpnservice|firewallexecute|firewallservice|firewallnft|openssldgst|symlink|readstatus|iroute|cleanipdns|checkip|resolveip|checkport|checksroute|checkddns|publicip|wgetddns|ddnsservice|ucisortovpn|cleanup|keymeta|keygenclean|initkeys|keygen} [parameters]"
exit 1
fi
# Get mac address of lan interface
getmac() {
# no arguments (no shift)
if [ -f "/sys/class/net/br-lan/address" ]; then
cat /sys/class/net/br-lan/address
else
@@ -29,11 +24,12 @@ getmac() {
lanip() {
shift # Remove "lanip" from the arguments list
local cidr="$1"
local dump=$(ubus call network.interface.lan status 2>/dev/null)
# Try to extract the first IPv4 address and subnet mask from the JSON data
local ip=$(echo "$dump" | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null)
local mask=$(echo "$dump" | jsonfilter -e '@["ipv4-address"][0].mask' 2>/dev/null)
local dump ip mask
dump=$(ubus call network.interface.lan status 2>/dev/null)
ip=$(echo "$dump" | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null)
mask=$(echo "$dump" | jsonfilter -e '@["ipv4-address"][0].mask' 2>/dev/null)
# If no IPv4 address exists, try to get the first IPv6 address and prefix length
if [ -z "$ip" ]; then
@@ -72,10 +68,11 @@ getnetstat() {
fi
# Read statistics directly from sysfs files
local rxb=$(cat /sys/class/net/"$interface"/statistics/rx_bytes 2>/dev/null || echo 0)
local txb=$(cat /sys/class/net/"$interface"/statistics/tx_bytes 2>/dev/null || echo 0)
local rxp=$(cat /sys/class/net/"$interface"/statistics/rx_packets 2>/dev/null || echo 0)
local txp=$(cat /sys/class/net/"$interface"/statistics/tx_packets 2>/dev/null || echo 0)
local rxb txb rxp txp
rxb=$(cat /sys/class/net/"$interface"/statistics/rx_bytes 2>/dev/null || echo 0)
txb=$(cat /sys/class/net/"$interface"/statistics/tx_bytes 2>/dev/null || echo 0)
rxp=$(cat /sys/class/net/"$interface"/statistics/rx_packets 2>/dev/null || echo 0)
txp=$(cat /sys/class/net/"$interface"/statistics/tx_packets 2>/dev/null || echo 0)
echo "$rxb|$txb|$rxp|$txp|true|$interface"
return 0
@@ -83,6 +80,7 @@ getnetstat() {
# Evaluates system hardware to determine the highest-performing cipher suite anchor
bestcrypto() {
# no arguments (no shift)
if grep -E -q 'aes|pmull' /proc/cpuinfo; then
echo "AES"
else
@@ -91,11 +89,60 @@ bestcrypto() {
return 0
}
# readstatus only allows access to: /proc/uptime, /var/run/openvpn.*.status, /tmp/run/openvpn.*.status, and /etc/openvpn/luci/*.conf
readstatus() {
shift # Remove "readstatus" from the arguments list
if [ "$#" -ne 1 ]; then
echo "ERROR: Invalid number of parameters." >&2
return 1
fi
local requested_path="$1"
# 1. Critical Guard: Block empty inputs or directory traversal hacks (..)
if [ -z "$requested_path" ] || echo "$requested_path" | grep -q '\.\.'; then
echo "ERROR: Structural security barrier rejection." >&2
return 1
fi
# 2. Fix: If the path does not start with a slash, add it dynamically
case "$requested_path" in
/*) ;;
*) requested_path="/$requested_path" ;;
esac
# 3. Strict White-Box Pattern Validation matching the openwrt/luci ACL expectations
case "$requested_path" in
/proc/uptime)
;;
/var/run/openvpn.*.status)
;;
/tmp/run/openvpn.*.status)
;;
/etc/openvpn/luci/*.conf)
;;
*)
echo "ERROR: Access denied. Protected path." >&2
return 1
;;
esac
# 4. Output the file content securely
if [ -e "$requested_path" ]; then
cat "$requested_path"
return 0
else
# Return an explicit empty line for valid but empty status files
echo ""
return 0
fi
}
# Central security gate to format and validate paths
securepath() {
shift # Remove "securepath" from the arguments list
internal_securepath() {
# internal (no shift)
local target_path="$1"
local allow_write="$2" # Optional: "write" to block read-only folders
local block_access="$2" # Optional: "write" to block read-only folders
# 1. Critical Guard: Block empty inputs or directory traversal hacks (..)
if [ -z "$target_path" ] || echo "$target_path" | grep -q '\.\.'; then
@@ -112,14 +159,14 @@ securepath() {
case "$target_path" in
/proc/uptime)
# Strictly read-only /proc/uptime
[ "$allow_write" = "write" ] && return 1
[ "$block_access" = "write" ] && return 1
echo "$target_path"
return 0
;;
/proc/*)
# Strictly read-only PID checks!
# Validate that the path contains ONLY digits after /proc/ (e.g., /proc/1234)
[ "$allow_write" = "write" ] && return 1
[ "$block_access" = "write" ] && return 1
if echo "$target_path" | grep -q -E '^/proc/[0-9]+$'; then
echo "$target_path"
return 0
@@ -128,13 +175,13 @@ securepath() {
;;
/var/run/openvpn.keygen.lock|/var/run/fw4.state|/var/run/openvpn.*.status)
# Read-only files! Block if write access is requested.
[ "$allow_write" = "write" ] && return 1
[ "$block_access" = "write" ] && return 1
echo "$target_path"
return 0
;;
/tmp/openvpn.keygen*|/tmp/run/openvpn.*.status)
# Read-only files! Block if write access is requested.
[ "$allow_write" = "write" ] && return 1
[ "$block_access" = "write" ] && return 1
echo "$target_path"
return 0
;;
@@ -155,71 +202,73 @@ securepath() {
esac
}
# Securely read file from disk
# Securely read file from disk (Only called by administrative full-access scopes)
readfile() {
shift # Remove "readfile" from the arguments list
if [ "$#" -ne 1 ]; then
echo "ERROR: Invalid number of parameters." >&2
return 1
fi
# Get the validated absolute path from securepath
local filepath=$(securepath "securepath" "$1" "read")
# Get the validated absolute path from internal_securepath
local filepath
filepath=$(internal_securepath "$1" "read")
if [ -z "$filepath" ]; then
echo "ERROR: Access denied." >&2
echo "ERROR: Access denied. Protected path." >&2
return 1
fi
if [ -e "$filepath" ]; then
cat "$filepath"
return 0
else
echo "problem: $filepath" >&2
echo ""
return 0
fi
return 1
}
# Securely write to disk with root-only access (0600)
writefile() {
shift # Remove "writefile" from the arguments list
if [ "$#" -ne 2 ]; then
echo "ERROR: Invalid number of parameters."
return 1
fi
# Get the validated absolute path from securepath with write permission check
local filepath=$(securepath "securepath" "$1" "write")
local filepath
filepath=$(internal_securepath "$1" "write")
local content="$2"
if [ -z "$filepath" ]; then
echo "ERROR: Access denied. Protected path." >&2
echo "ERROR: Access denied. Protected path."
return 1
fi
# Ensure the target folder exists before writing files into it
mkdir -p "$(dirname "$filepath")"
# Write the contents cleanly into the target destination path
echo -n "$content" > "$filepath"
# Restrict umask inline to block race conditions without subshell (prevents LuCI/rpcd process blocking and premature termination)
local old_umask
old_umask=$(umask)
umask 077
printf '%s' "$content" > "$filepath"
umask "$old_umask"
# Restrict permissions immediately so only root can access it
# Restrict permissions permanently so only root can access it
chmod 600 "$filepath"
return 0
}
# Securely create directory paths with correct system permissions (0700)
makedir() {
shift # Remove "makedir" from the arguments list
# Get the validated absolute path from securepath with write permission check
local dirpath=$(securepath "securepath" "$1" "write")
if [ -z "$dirpath" ]; then
echo "ERROR: Access denied. Protected path." >&2
return 1
fi
# Create the folder layout securely (chmod 700 is root-only access)
mkdir -p "$dirpath"
chmod 700 "$dirpath"
return 0
}
# Securely remove files from disk
removefile() {
shift # Remove "removefile" from the arguments list
if [ "$#" -ne 1 ]; then
echo "ERROR: Invalid number of parameters." >&2
return 1
fi
# Get the validated absolute path from securepath with write permission check
local filepath=$(securepath "securepath" "$1" "write")
local filepath
filepath=$(internal_securepath "$1" "write")
if [ -z "$filepath" ]; then
echo "ERROR: Access denied. Protected path." >&2
return 1
@@ -230,29 +279,19 @@ removefile() {
return 0
}
# Securely remove full directories from disk
removedir() {
shift # Remove "removedir" from the arguments list
# Get the validated absolute path from securepath with write permission check
local dirpath=$(securepath "securepath" "$1" "write")
if [ -z "$dirpath" ] || [ "$dirpath" = "/" ]; then
echo "ERROR: Access denied or core root wipe blocked." >&2
return 1
fi
# Delete the directory and all its contents safely
rm -rf "$dirpath"
return 0
}
# Securely get file statistics
statfile() {
shift # Remove "statfile" from the arguments list
if [ "$#" -ne 1 ]; then
echo "ERROR: Invalid number of parameters." >&2
return 1
fi
# Get the validated absolute path from securepath
local filepath=$(securepath "securepath" "$1" "read")
local filepath
filepath=$(internal_securepath "$1" "read")
if [ -z "$filepath" ]; then
echo "ERROR: Access denied. Protected path." >&2
return 1
fi
@@ -271,7 +310,8 @@ statfile() {
fi
# Get modification time
local mtime=$(date -r "$filepath" +%s 2>/dev/null || echo 0)
local mtime
mtime=$(date -r "$filepath" +%s 2>/dev/null || echo 0)
# Print simulated JSON string
echo "{\"type\":\"$file_type\",\"size\":$file_size,\"mtime\":$mtime}"
@@ -282,12 +322,14 @@ statfile() {
# OpenVPN version
ovpnver() {
# no arguments (no shift)
openvpn --version
return 0;
}
# Central validator for sleep seconds parameter
validatesleep() {
internal_validatesleep() {
# internal (no shift)
local sec="$1"
# Check if empty, contains non-digits, is less than 1, or greater than 60
if [ -z "$sec" ] || ! echo "$sec" | grep -q '^[0-9]\+$' || [ "$sec" -lt 1 ] || [ "$sec" -gt 60 ]; then
@@ -303,7 +345,7 @@ ovpnstart() {
local sleepsec="$2"
# Execute optional validated sleep delay before doing any work
if validatesleep "$sleepsec"; then
if internal_validatesleep "$sleepsec"; then
sleep "$sleepsec"
fi
@@ -321,7 +363,8 @@ ovpnstart() {
else
# B) GLOBAL MODE: Check all enabled profiles in the configuration
uci -q show openvpn | grep "=openvpn" | while read -r line; do
local check_inst="${line##*.}"
local check_inst
check_inst="${line##*.}"
check_inst="${check_inst%%=*}"
if [ "$(uci -q get openvpn."${check_inst}".enabled)" = "1" ]; then
@@ -352,7 +395,7 @@ ovpnrestart() {
local sleepsec="$2"
# Execute optional validated sleep delay before doing any work
if validatesleep "$sleepsec"; then
if internal_validatesleep "$sleepsec"; then
sleep "$sleepsec"
fi
@@ -374,7 +417,7 @@ ovpnstop() {
local sleepsec="$2"
# Execute optional validated sleep delay before doing any work
if validatesleep "$sleepsec"; then
if internal_validatesleep "$sleepsec"; then
sleep "$sleepsec"
fi
@@ -387,42 +430,14 @@ ovpnstop() {
/etc/init.d/openvpn stop "$inst" >/dev/null 2>&1
}
# openvpn service stop/start (PID-guarded with direct background workers)
ovpnservice() {
shift # Remove "ovpnservice" from the arguments list
local action_cmd="$1"
local inst="$2"
local pid_file="/var/run/luci-app-openvpn-plus-ovpn-service.pid"
if [ -z "$(echo "$inst" | tr -d ' ')" ]; then
inst=""
fi
if [ "$action_cmd" = "stop" ] || [ "$action_cmd" = "start" ] || [ "$action_cmd" = "restart" ]; then
# 1. Clean up any stale or previous ghost workers instantly
start-stop-daemon -K -q -p "$pid_file" 2>/dev/null
rm -f "$pid_file"
# 2. Start the target command directly via the main script frame context (with sleep 3)
if [ "$action_cmd" = "start" ]; then
start-stop-daemon -S -b -m -p "$pid_file" -x "$0" -- ovpnstart "$inst" 3
elif [ "$action_cmd" = "restart" ]; then
start-stop-daemon -S -b -m -p "$pid_file" -x "$0" -- ovpnrestart "$inst" 3
elif [ "$action_cmd" = "stop" ]; then
start-stop-daemon -S -b -m -p "$pid_file" -x "$0" -- ovpnstop "$inst" 3
fi
fi
return 0
}
# firewall stop/start/restart engine
firewallexecute () {
shift # Remove "firewallexecute" from the arguments list
firewallservice () {
shift # Remove "firewallservice" from the arguments list
local action_cmd="$1"
local sleepsec="$2"
# 1. Execute optional validated sleep delay before doing any work
if validatesleep "$sleepsec"; then
if internal_validatesleep "$sleepsec"; then
sleep "$sleepsec"
fi
@@ -433,23 +448,6 @@ firewallexecute () {
return 0
}
# firewall service stop/start (PID-guarded with direct background workers)
firewallservice() {
shift # Remove "firewallservice" from the arguments list
local action_cmd="$1"
local pid_file="/var/run/luci-app-openvpn-plus-firewall-service.pid"
if [ "$action_cmd" = "stop" ] || [ "$action_cmd" = "start" ] || [ "$action_cmd" = "restart" ]; then
# 1. Clean up any stale or previous ghost workers instantly
start-stop-daemon -K -q -p "$pid_file" 2>/dev/null
rm -f "$pid_file"
# 2. Start the worker directly via the main script frame context (with sleep 3)
start-stop-daemon -S -b -m -p "$pid_file" -x "$0" -- firewallexecute "$action_cmd" 3
fi
return 0
}
# Secure helper to read and change firewall rules from LuCI without restrictions
firewallnft() {
shift # Remove "firewallnft" from the arguments list
@@ -461,11 +459,11 @@ firewallnft() {
/usr/sbin/nft list chain inet fw4 "$chain" 2>&1
}
# Secure helper to calculate cryptographic hashes using openssl dgst
# Secure helper to calculate cryptographic hashes using openssl dgst (openssldgst -sha256 test)
openssldgst() {
shift # Remove "openssldgst" from the arguments list
# We safely pipe the text inside the secure script layer
echo -n "$2" | /usr/bin/openssl dgst "$1" -r 2>&1
# printf '%s' "test" | /usr/bin/openssl dgst "-sha256" -r 2>&1 (result: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08)
printf '%s' "$2" | /usr/bin/openssl dgst "$1" -r 2>&1
return 0
}
@@ -504,7 +502,8 @@ symlink() {
fi
# Create a random crypt-token (9 random bytes as hex).
local secret_token=$(dd if=/dev/urandom bs=9 count=1 2>/dev/null | hexdump -e '"%02x"')
local secret_token
secret_token=$(dd if=/dev/urandom bs=9 count=1 2>/dev/null | hexdump -e '"%02x"')
# Path to the temporary session folder.
local session_dir="/www/ovpn-tmp-${secret_token}-${link_filename}"
@@ -544,33 +543,18 @@ symlink() {
return 0
}
# Read active multi-user server status logs securely bypassing rpcd nobody restrictions in LuCI
readstatus() {
shift # Remove the "readstatus" command word from the arguments list
local instance_id="$1"
if [ -z "$instance_id" ]; then
echo "ERROR: Missing instance ID parameter."
return 1
fi
local status_path="/var/run/openvpn.${instance_id}.status"
# Check if the targeted runtime status file exists and contains real data payload
if [ -s "$status_path" ]; then
cat "$status_path"
return 0
else
# Return an explicit empty line to indicate a non-initialized routing socket channel
echo ""
return 0
fi
}
# Validates connecting clients and applies dynamic routing topologies
iroute() {
shift # Remove "iroute" from the arguments list
# EXTERNAL ENVIRONMENT VARIABLES (Provided dynamically by OpenVPN Daemon):
# $common_name - Common Name (CN) of the connecting client certificate
# $trusted_ip - Real public WAN IP address of the remote client
# $proto - Protocol used by the client connection (tcp/udp)
local common_name="${common_name}"
local trusted_ip="${trusted_ip}"
local proto="${proto}"
case $proto in
*tcp*) proto="tcp" ;;
esac
@@ -616,7 +600,7 @@ iroute() {
# Clean any string from spaces, tabs, newlines, http(s)://, paths, ports and URL brackets
cleanipdns() {
shift # Remove the command word from the arguments list
shift # Remove "cleanipdns" from the arguments list
local input="$1"
# 1. Delete spaces, tabs, and carriage returns
@@ -653,7 +637,7 @@ cleanipdns() {
# Validate and clean any given IPv4 or IPv6 address string
checkip() {
shift # Remove the command word from the arguments list
shift # Remove "checkip" from the arguments list
local ip="$1"
# Delete all spaces, tabs and hidden lines from the text
@@ -686,7 +670,7 @@ checkip() {
# Resolve domain name to IP or validate raw IPv4/IPv6 input
resolveip() {
shift # Remove the command word from the arguments list
shift # Remove "resolveip" from the arguments list
local target="$1"
# Use cleanipdns to fix the input string first
@@ -741,7 +725,7 @@ resolveip() {
# Check if TCP or UDP port is accessible from outside on Double NAT
checkport() {
shift # Remove the word "checkport" from the arguments list
shift # Remove "checkport" from the arguments list
local public_host="$1" # Public WAN IP or DDNS domain name
local ext_port="$2" # External Port on WAN main modem (e.g. 58493)
@@ -783,7 +767,8 @@ checkport() {
fi
# check and resolve ip from hostname
local resolved_ip=$(resolveip "resolveip" "$public_host")
local resolved_ip
resolved_ip=$(resolveip "resolveip" "$public_host")
if [ -z "$resolved_ip" ]; then
printf "ERROR: Invalid public hostname or ip: '%s'.%b" "$public_host" "$usage"
return 1
@@ -862,7 +847,8 @@ checkport() {
fi
# 3. Read the nftables chain and verify if packets hit the internal counter of rule a)
local packet_count=$(nft list chain inet fw4 "$checkport_id" 2>/dev/null | grep "dport $int_port" | grep -oE "counter packets [0-9]+" | awk '{print $3}')
local packet_count
packet_count=$(nft list chain inet fw4 "$checkport_id" 2>/dev/null | grep "dport $int_port" | grep -oE "counter packets [0-9]+" | awk '{print $3}')
[ -z "$packet_count" ] && packet_count=0
local scan_res="PORT_CLOSED"
@@ -881,15 +867,16 @@ checkport() {
echo "---"
# 4. Clean up ALL old ghost firewall rules to keep the router fast and safe
local handle_ids=$(nft -a list chain inet fw4 input | grep "jump $checkport_id" | grep -oE "handle [0-9]+" | awk '{print $2}')
local handle_ids
handle_ids=$(nft -a list chain inet fw4 input | grep "jump $checkport_id" | grep -oE "handle [0-9]+" | awk '{print $2}')
for handle in $handle_ids; do
nft delete rule inet fw4 input handle "$handle"
done
nft delete chain inet fw4 "$checkport_id"
else
local handle_ids=$(nft -a list chain inet fw4 input | grep "jump $checkport_id" | grep -oE "handle [0-9]+" | awk '{print $2}')
local handle_ids
handle_ids=$(nft -a list chain inet fw4 input | grep "jump $checkport_id" | grep -oE "handle [0-9]+" | awk '{print $2}')
for handle in $handle_ids; do
nft delete rule inet fw4 input handle "$handle" 2>/dev/null
done
@@ -919,8 +906,9 @@ checksroute() {
return 1
fi
# Clean trailing slashes, prefixes or spaces
local clean_netip=$(echo "$remote_test_net" | cut -d'/' -f1 | tr -d ' ')
local clean_wanip=$(echo "$wan_router_ip" | cut -d'/' -f1 | tr -d ' ')
local clean_netip clean_wanip
clean_netip=$(echo "$remote_test_net" | cut -d'/' -f1 | tr -d ' ')
clean_wanip=$(echo "$wan_router_ip" | cut -d'/' -f1 | tr -d ' ')
# Check if IPs contain only good characters (numbers, letters a-f, dots, colons)
case "${clean_netip}:${clean_wanip}" in
@@ -950,8 +938,9 @@ checksroute() {
fi
# Change dots (.) and colons (:) to underscores (_) to make the IDs
local netid=$(echo "$clean_netip" | tr '.:' '__')
local wanid=$(echo "$clean_wanip" | tr '.:' '__')
local netid wanid
netid=$(echo "$clean_netip" | tr '.:' '__')
wanid=$(echo "$clean_wanip" | tr '.:' '__')
# create id for firewall table
local checksroute_id="luci_app_openvpn_plus_checksroute_${netid}__${wanid}"
@@ -965,7 +954,8 @@ checksroute() {
ping_cmd="ping6"
# Remove any trailing colons first
local base_v6=$(echo "$clean_netip" | sed 's/:*$//')
local base_v6
base_v6=$(echo "$clean_netip" | sed 's/:*$//')
# If the subnet ends with a double colon (e.g., fd00:1::)
if echo "$clean_netip" | grep -q "::$"; then
@@ -1033,8 +1023,8 @@ checksroute() {
checkddns() {
shift # Remove the command word from the arguments list
local target="$1"
local resolved_ip=$(resolveip "resolveip" "$target")
local resolved_ip
resolved_ip=$(resolveip "resolveip" "$target")
[ -z "$resolved_ip" ] && return 1
# Live connection verification via native OpenWrt core ping utilities
@@ -1058,6 +1048,8 @@ checkddns() {
# Detect the real public WAN IP (IPv4 or IPv6)
publicip() {
# no arguments (no shift)
local resolved_ip=""
local valid_ip=""
@@ -1256,7 +1248,8 @@ ddnsservice() {
# Process evaluation logic
if [ "$current_router_ip" != "$last_resolved_ip" ]; then
local current_uptime=$(awk '{print int($1)}' /proc/uptime)
local current_uptime
current_uptime=$(awk '{print int($1)}' /proc/uptime)
local last_update_uptime=0
local last_url=""
@@ -1281,15 +1274,17 @@ ddnsservice() {
logger -t openvpn-luci "DDNS Service: ${wgetddns_response}"
local fresh_uptime=$(awk '{print int($1)}' /proc/uptime)
local fresh_uptime
fresh_uptime=$(awk '{print int($1)}' /proc/uptime)
echo "$fresh_uptime" > "$status_file"
echo "$url" >> "$status_file"
fi
else
# Flash-safe evaluation window prevents loop locks on identical states
if [ -f "$status_file" ]; then
local current_uptime=$(awk '{print int($1)}' /proc/uptime)
local last_update_uptime=$(sed -n '1p' "$status_file" | tr -d '[:space:]' | grep -E -o "^[0-9]+$")
local current_uptime last_update_uptime
current_uptime=$(awk '{print int($1)}' /proc/uptime)
last_update_uptime=$(sed -n '1p' "$status_file" | tr -d '[:space:]' | grep -E -o "^[0-9]+$")
[ -z "$last_update_uptime" ] && last_update_uptime=0
local seconds_passed=$((current_uptime - last_update_uptime))
@@ -1306,9 +1301,11 @@ ddnsservice() {
# This function dynamically check and sort if necessary sorts all openvpn sections
ucisortovpn() {
# no arguments (no shift)
# 1. Get the current raw list of instances from the file
local sections=$(uci -q show openvpn | grep "=openvpn" | cut -d. -f2 | cut -d= -f1)
local sections
sections=$(uci -q show openvpn | grep "=openvpn" | cut -d. -f2 | cut -d= -f1)
[ -z "$sections" ] && return 0
local previous_num=-1
@@ -1317,7 +1314,8 @@ ucisortovpn() {
# 2. Check if a smaller number comes after a bigger number
for section_name in $sections; do
# Extract only the digits from the name (e.g., "instance2" -> "2")
local current_num=$(echo "$section_name" | grep -oE "[0-9]+")
local current_num
current_num=$(echo "$section_name" | grep -oE "[0-9]+")
[ -z "$current_num" ] && continue
# If the current number is smaller than the last one, we must reorder!
@@ -1333,7 +1331,8 @@ ucisortovpn() {
local index=0
# Get the naturally sorted list (instance1, instance2)
local sorted_sections=$(echo "$sections" | sort -V)
local sorted_sections
sorted_sections=$(echo "$sections" | sort -V)
for section_name in $sorted_sections; do
[ -z "$section_name" ] && continue
@@ -1484,6 +1483,7 @@ keymeta() {
# Remove keygen_worker temporary keys
keygenclean() {
shift # Remove "keygenclean" from the arguments list
rm -f /tmp/openvpn.keygen.*
return 0
}
@@ -1530,7 +1530,7 @@ initkeys() {
# Step 2: Files are missing, create lock file and set clean traps
touch "$lock_file"
trap "rm -f $lock_file" EXIT INT TERM
trap 'rm -f "$lock_file"' EXIT INT TERM
# Step 3: Check if openssl and openvpn are installed on this router
if command -v openssl >/dev/null 2>&1 && command -v openvpn >/dev/null 2>&1; then
@@ -1541,7 +1541,8 @@ initkeys() {
if [ -z "$router_name" ]; then router_name="OpenWrt"; fi
# Clean the router name string from bad characters and spaces
router_name=$(echo "$router_name" | tr -d '"'\''\\' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
# Using octal codes \042 (") \047 (') and \134 (\) to avoid single-quote escaping
router_name=$(echo "$router_name" | tr -d '\042\047\134' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
# Create temporary extension configuration files in RAM
local ext_server_tmp="/tmp/openvpn.initkeys.server.ext.$$"
@@ -1599,7 +1600,7 @@ initkeys() {
openvpn --genkey tls-crypt-v2-server "$server_default_tls2_key" 2>/dev/null && \
\
# 4g) Derive OpenVPN static client keys
client_name=$(echo -n "$router_name (OpenVPN Client TLSv2)" | openssl enc -base64) && \
client_name=$(printf '%s' "$router_name (OpenVPN Client TLSv2)" | openssl enc -base64) && \
openvpn --tls-crypt-v2 "$server_default_tls2_key" --genkey tls-crypt-v2-client "$client_default_tls2_key" "$client_name"
# Save the status code of the complete command chain
@@ -1637,7 +1638,7 @@ initkeys() {
}
# Asynchronous key and certificate generator
keygen_worker() {
keygen() {
shift # Remove "keygen" from the arguments list
local type="${1:-unknown}"
local instance="${2:-default}"
@@ -1871,7 +1872,8 @@ keygen_worker() {
(
set +e
echo "LOG: Invoking openvpn engine for asymmetric tls-crypt-v2 client key generation..." >> "$log_file"
local target_tls_name_base64=$(echo -n "$target_tls_name" | openssl enc -base64)
local target_tls_name_base64
target_tls_name_base64=$(printf '%s' "$target_tls_name" | openssl enc -base64)
echo "[CMD] openvpn --tls-crypt-v2 '$server_tls2_key' --genkey tls-crypt-v2-client '$active_out' '$target_tls_name_base64'" >> "$log_file"
openvpn --tls-crypt-v2 "$server_tls2_key" --genkey tls-crypt-v2-client "$active_out" "$target_tls_name_base64" 2>> "$log_file"
if [ -f "$active_out" ] && [ -s "$active_out" ]; then
@@ -1891,82 +1893,165 @@ keygen_worker() {
return 0
}
# async worker with start-stop-daemon
ovpnservice_async() {
shift # Remove "ovpnservice_async" from the arguments list
local action_cmd="$1"
local inst="$2"
local pid_file="/var/run/luci-app-openvpn-plus-ovpn-service.pid"
if [ -z "$(echo "$inst" | tr -d ' ')" ]; then
inst=""
fi
if [ "$action_cmd" = "stop" ] || [ "$action_cmd" = "start" ] || [ "$action_cmd" = "restart" ]; then
# 1. Clean up any stale or previous ghost workers instantly
start-stop-daemon -K -q -p "$pid_file" 2>/dev/null
rm -f "$pid_file"
# 2. Start the target command via worker wrapper to handle absolute lifecycle traps
start-stop-daemon -S -b -m -p "$pid_file" -x "$0" -- ovpnservice_worker "$pid_file" "$action_cmd" "$inst"
fi
return 0
}
ovpnservice_worker() {
shift # Remove "ovpnservice_worker" from the arguments list
local pid_file="$1"
local cmd="$2"
local inst="$3"
trap 'rm -f "$pid_file"' EXIT INT TERM
if [ "$cmd" = "start" ]; then
ovpnstart "ovpnstart" "$inst" 3
elif [ "$cmd" = "restart" ]; then
ovpnrestart "ovpnrestart" "$inst" 3
elif [ "$cmd" = "stop" ]; then
ovpnstop "ovpnstop" "$inst" 3
fi
rm -f "$pid_file"
return 0
}
firewallservice_async() {
shift # Remove "firewallservice_async" from the arguments list
local action_cmd="$1"
local pid_file="/var/run/luci-app-openvpn-plus-firewall-service.pid"
if [ "$action_cmd" = "stop" ] || [ "$action_cmd" = "start" ] || [ "$action_cmd" = "restart" ]; then
# 1. Clean up any stale or previous ghost workers instantly
start-stop-daemon -K -q -p "$pid_file" 2>/dev/null
rm -f "$pid_file"
# 2. Start the worker directly via the main script frame context
# Inside the worker, we use a trap to cleanly remove the pid_file upon exit
start-stop-daemon -S -b -m -p "$pid_file" -x "$0" -- firewallservice_worker "$pid_file" "$action_cmd"
fi
return 0
}
firewallservice_worker() {
shift # Remove "firewallservice_worker" from the arguments list
local pid_file="$1"
local cmd="$2"
trap 'rm -f "$pid_file"' EXIT INT TERM
firewallservice "firewallservice" "$cmd" 3
rm -f "$pid_file"
return 0
}
initkeys_async() {
shift # Remove "initkeys_async" from the arguments list
# 1. Check if the key generator is running
if start-stop-daemon -K -q -p "/var/run/openvpn.initkeys.pid" -t 2>/dev/null; then
exit 0
fi
# 2. Start the background worker and create the PID file automatically
start-stop-daemon -S -b -m -p "/var/run/openvpn.initkeys.pid" -x "$0" -- "initkeys_worker" "/var/run/openvpn.initkeys.pid"
return 0
}
initkeys_worker() {
shift # Remove "initkeys_worker" from the arguments list
local pid_file="$1"
# 3. Secure the PID file lifecycle instantly with a trap framework
trap 'rm -f "$pid_file"' EXIT INT TERM
# 4. Run the key creation function quietly in the background
initkeys "initkeys" >/dev/null 2>&1
rm -f "$pid_file"
return 0
}
keygen_async() {
shift # Remove "keygen" from the arguments list
local type="$1"
local instance="$2"
# 1. Start the background keygen worker and create the PID file automatically
start-stop-daemon -S -b -m -p "/var/run/openvpn.keygen-${instance}.pid" -x "$0" -- "keygen_worker" "/var/run/openvpn.keygen-${instance}.pid" "$@"
return 0
}
keygen_worker() {
shift # Remove "keygen_worker" from the arguments list
local pid_file="$1"
shift # Remove "pid_file" from the arguments list
# 3. Secure the dynamic PID node tracking with a strict trap guard
trap 'rm -f "$pid_file"' EXIT INT TERM
# 4. Run the certificate engine pipeline with corrected original parameters ($@: type="$1", instance="$2")
keygen "keygen" "$@"
rm -f "$pid_file"
return 0
}
usage() {
local args="{getmac|lanip|getnetstat|bestcrypto|readstatus|readfile|writefile|removefile|statfile|ovpnver|ovpnstart|ovpnrestart|ovpnstop|ovpnservice|firewallservice|firewallnft|openssldgst|symlink|iroute|cleanipdns|checkip|resolveip|checkport|checksroute|checkddns|publicip|wgetddns|ddnsservice|ucisortovpn|cleanup|keymeta|keygenclean|initkeys|keygen} [parameters]"
# Check if the user gave an argument
if [ -z "$1" ]; then
echo "Usage: $0 $args"
else
echo "Error: Unknown command '$1'"
echo "Valid commands: $args"
fi
return 1
}
# Main entry point
case "$1" in
getmac) getmac ;;
lanip) lanip "$@" ;;
getnetstat) getnetstat "$@" ;;
bestcrypto) bestcrypto ;;
securepath) securepath "$@" ;;
readfile) readfile "$@" ;;
writefile) writefile "$@" ;;
makedir) makedir "$@" ;;
removefile) removefile "$@" ;;
removedir) removedir "$@" ;;
statfile) statfile "$@" ;;
ovpnver) ovpnver ;;
ovpnstart) ovpnstart "$@" ;;
ovpnrestart) ovpnrestart "$@" ;;
ovpnstop) ovpnstop "$@" ;;
ovpnservice) ovpnservice "$@" ;;
firewallexecute) firewallexecute "$@" ;;
firewallservice) firewallservice "$@" ;;
firewallnft) firewallnft "$@" ;;
openssldgst) openssldgst "$@" ;;
symlink) symlink "$@" ;;
readstatus) readstatus "$@" ;;
iroute) iroute "$@" ;;
cleanipdns) cleanipdns "$@" ;;
checkip) checkip "$@" ;;
resolveip) resolveip "$@" ;;
checkport) checkport "$@" ;;
checksroute) checksroute "$@" ;;
checkddns) checkddns "$@" ;;
publicip) publicip ;;
wgetddns) wgetddns "$@" ;;
ddnsservice) ddnsservice "$@" ;;
ucisortovpn) ucisortovpn ;;
cleanup) cleanup "$@" ;;
keymeta) keymeta "$@" ;;
keygenclean) keygenclean ;;
getmac) getmac ;;
lanip) lanip "$@" ;;
getnetstat) getnetstat "$@" ;;
bestcrypto) bestcrypto ;;
readstatus) readstatus "$@" ;;
readfile) readfile "$@" ;;
writefile) writefile "$@" ;;
removefile) removefile "$@" ;;
statfile) statfile "$@" ;;
ovpnver) ovpnver ;;
ovpnstart) ovpnstart "$@" ;;
ovpnrestart) ovpnrestart "$@" ;;
ovpnstop) ovpnstop "$@" ;;
firewallnft) firewallnft "$@" ;;
openssldgst) openssldgst "$@" ;;
symlink) symlink "$@" ;;
iroute) iroute "$@" ;;
cleanipdns) cleanipdns "$@" ;;
checkip) checkip "$@" ;;
resolveip) resolveip "$@" ;;
checkport) checkport "$@" ;;
checksroute) checksroute "$@" ;;
checkddns) checkddns "$@" ;;
publicip) publicip ;;
wgetddns) wgetddns "$@" ;;
ddnsservice) ddnsservice "$@" ;;
ucisortovpn) ucisortovpn ;;
cleanup) cleanup "$@" ;;
keymeta) keymeta "$@" ;;
keygenclean) keygenclean ;;
initkeys)
# 1. Check if the key generator is running
if start-stop-daemon -K -q -p "/var/run/openvpn.initkeys.pid" -t 2>/dev/null; then
exit 0
fi
# 2. Start the background worker and create the PID file automatically
start-stop-daemon -S -b -m -p "/var/run/openvpn.initkeys.pid" -x "$0" -- "initkeys_worker"
exit 0
;;
# async worker with start-stop-daemon
firewallservice) firewallservice_async "$@" ;;
firewallservice_worker) firewallservice_worker "$@" ;;
ovpnservice) ovpnservice_async "$@" ;;
ovpnservice_worker) ovpnservice_worker "$@" ;;
initkeys) initkeys_async "$@" ;;
initkeys_worker) initkeys_worker "$@" ;;
keygen) keygen_async "$@" ;;
keygen_worker) keygen_worker "$@" ;;
initkeys_worker)
# 3. Run the key creation function quietly in the background
initkeys >/dev/null 2>&1
# 4. Delete the PID file when the keys are finished
rm -f "/var/run/openvpn.initkeys.pid"
exit 0
;;
keygen)
# 1. Start the background keygen worker and create the PID file automatically
start-stop-daemon -S -b -m -p "/var/run/openvpn.keygen-${2}.pid" -x "$0" -- "keygen_worker" "$@"
exit 0
;;
keygen_worker)
# 2. Run the key creation function quietly in the background
shift # Remove "keygen_worker" from the arguments list to fix $@
keygen_worker "$@"
# 3. Delete the PID file when keygen finished
rm -f "/var/run/openvpn.keygen-${2}.pid"
;;
*)
echo "Error: Unknown command '$1'"
echo "Valid commands: getmac lanip getnetstat bestcrypto securepath readfile writefile makedir removefile removedir statfile ovpnver ovpnstart ovpnrestart ovpnstop ovpnservice firewallexecute firewallservice firewallnft openssldgst symlink readstatus iroute cleanipdns checkip resolveip checkport checksroute checkddns publicip wgetddns ddnsservice ucisortovpn cleanup keymeta keygenclean initkeys keygen"
exit 1
;;
*) usage "$@" ;;
esac
# Global exit handler guarantees instant thread termination
@@ -14,7 +14,6 @@
"/usr/libexec/luci-app-openvpn-plus ovpnver": [ "exec" ],
"/usr/libexec/luci-app-openvpn-plus getnetstat *": [ "exec" ],
"/usr/libexec/luci-app-openvpn-plus readstatus *": [ "exec" ],
"/usr/libexec/luci-app-openvpn-plus readfile *": [ "exec" ],
"/usr/libexec/luci-app-openvpn-plus statfile *": [ "exec" ]
}
}
@@ -65,12 +65,11 @@ const CFG = Object.freeze({
}),
LIBEXEC: Object.freeze({
luci_app_openvpn_plus: '/usr/libexec/luci-app-openvpn-plus',
readfile: 'readfile',
statfile: 'statfile',
readstatus: 'readstatus',
getmac: 'getmac',
getnetstat: 'getnetstat',
ovpnver: 'ovpnver',
getnetstat: 'getnetstat',
readstatus: 'readstatus',
statfile: 'statfile',
}),
CMD: Object.freeze({
openvpn: 'openvpn',
@@ -171,11 +170,15 @@ const nativeFileExecRpc = L.rpc.declare({
/**
* Wrapper for /usr/libexec/luci-app-openvpn-plus using the native ubus RPC directly
*/
const luci_app_openvpn_plus = async function (args) {
const luci_app_openvpn_plus = async function (args, skipTrim) {
const extraArgs = (typeof args === 'string') ? [args] : (Array.isArray(args) ? args : []);
const res = await L.resolveDefault(nativeFileExecRpc(CFG.LIBEXEC.luci_app_openvpn_plus, extraArgs), {});
if (res && typeof res === 'object' && typeof res.stdout === 'string') {
return res.stdout.trim();
if (skipTrim === true) {
return res.stdout.trim();
} else {
return res.stdout.trim();
}
}
return '';
};
@@ -202,11 +205,11 @@ const rpcGetServiceStatus = async function () {
};
/**
* Asynchronously reads a file as a plain string via the secure backend helper
* Asynchronously read status file as a plain string via the secure backend helper
*/
const secureReadFile = async function (filepath) {
const secureReadStatusFile = async function (filepath) {
// PASS true AS SECOND PARAMETER TO KEEP ALL NEWLINES AND SPACES!
const res = await luci_app_openvpn_plus([CFG.LIBEXEC.readfile, filepath], true);
const res = await luci_app_openvpn_plus([CFG.LIBEXEC.readstatus, filepath], true);
if (res && res.length > 0) {
return res;
}
@@ -219,7 +222,7 @@ const secureReadFile = async function (filepath) {
const secureStatFile = async function (filepath) {
const res = await luci_app_openvpn_plus([CFG.LIBEXEC.statfile, filepath]);
try {
if (res && res.trim().startsWith("{")) {
if (res && res.startsWith("{")) {
const statObj = JSON.parse(res);
if (statObj && typeof statObj === 'object') {
return statObj;
@@ -324,7 +327,7 @@ const getOpenVpnInfo = async function () {
* Reads system uptime from the proc filesystem asynchronously
*/
const getSystemUptime = async function () {
const rawUptime = await secureReadFile(CFG.FILE.proc_uptime);
const rawUptime = await secureReadStatusFile(CFG.FILE.proc_uptime);
// Split the output string by spaces to separate uptime and idle time
const partsUptime = String(rawUptime || '0').trim().split(/\s+/);
const uptime = (partsUptime && partsUptime[0]) ? parseFloat(partsUptime[0]) : 0;
@@ -1149,7 +1152,7 @@ const getInstanceConfig = async function (id, instNum, role, path_conf) {
// Proceed to live compilation on error
}
let confContent = sanitizeInputText(await secureReadFile(path_conf));
let confContent = sanitizeInputText(await secureReadStatusFile(path_conf));
if (confContent.length === 0) {
// Signals readSingleInstanceStatus to execute early exit template
return null;
@@ -1314,7 +1317,7 @@ const getConnectedClientsStatus = async function (inst) {
}
// change detected - parse status file
const statusContent = await luci_app_openvpn_plus([CFG.LIBEXEC.readstatus, inst.id]);
const statusContent = await secureReadStatusFile(CFG.FILE.var_run_openvpn + inst.id + '.status');
if (statusContent.length > 0) {
const freshClients = parseConnectedClients(statusContent, inst);
@@ -397,11 +397,15 @@ const nativeFileExecRpc = L.rpc.declare({
/**
* Wrapper for /usr/libexec/luci-app-openvpn-plus using the native ubus RPC directly
*/
const luci_app_openvpn_plus = async function (args) {
const luci_app_openvpn_plus = async function (args, skipTrim) {
const extraArgs = (typeof args === 'string') ? [args] : (Array.isArray(args) ? args : []);
const res = await L.resolveDefault(nativeFileExecRpc(CFG.LIBEXEC.luci_app_openvpn_plus, extraArgs), {});
if (res && typeof res === 'object' && typeof res.stdout === 'string') {
return res.stdout.trim();
if (skipTrim === true) {
return res.stdout.trim();
} else {
return res.stdout.trim();
}
}
return '';
};
@@ -447,7 +451,7 @@ const secureWriteFile = async function (filepath, content) {
const res = await luci_app_openvpn_plus([CFG.LIBEXEC.writefile, filepath, textData]);
if (res && res.includes("ERROR:")) {
console.error("Secure write failed: ", CFG.LIBEXEC.luci_app_openvpn_plus + ' ' + CFG.LIBEXEC.writefile + ' ' + filepath + ' ' + textData + ' -> ' + res);
console.error("Secure write failed: ", CFG.LIBEXEC.luci_app_openvpn_plus + ' ' + CFG.LIBEXEC.writefile + ' ' + filepath + ' -> ' + res);
return -1;
}
return 0;
@@ -471,7 +475,7 @@ const secureRemoveFile = async function (filepath) {
const secureStatFile = async function (filepath) {
const res = await luci_app_openvpn_plus([CFG.LIBEXEC.statfile, filepath]);
try {
if (res && res.trim().startsWith("{")) {
if (res && res.startsWith("{")) {
const statObj = JSON.parse(res);
if (statObj && typeof statObj === 'object') {
return statObj;
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-zerotier
PKG_VERSION:=2.2
PKG_RELEASE:=7
PKG_RELEASE:=8
PKG_LICENSE:=Apache-2.0
LUCI_TITLE:=LuCI for ZeroTier
+46
View File
@@ -212,6 +212,52 @@ stderr and continues. Two consequences are handled explicitly:
## Changelog
### v2.2-r46
**ztncui parity: clickable host label, member detail, IP management**
- **The host label is now clickable** and shares one handler with the Manage
button -- click opens the detail panel (button reads *Hide*), click again
closes it (button reads *Manage*). One shared function, so the two paths
can never disagree about the panel's state.
- **Member IDs now look like Network IDs**: a plain monospace link on the page
background, no white code-chip. Clicking one opens a ztncui-style read-only
*member detail*: every field of the controller's member record in key order,
then the live peer object pretty-printed -- entirely from the cached model,
so it costs no RPC.
- **Clicking an IP opens a ztncui-style manager** instead of editing a
comma-separated string: member name and ZeroTier address on top, one row
per IP with its own Delete, one input row with Add, and the network's
managed routes read-only below. The ZeroTier API replaces `ipAssignments`
wholesale, so Delete commits current-minus-row and Add commits
current-plus-one -- a much better match for what the controller can
actually do than a free-form string. The old inline editor is gone.
- The harness caught a real scoping bug before it shipped: the Add input
lived inside the paint closure, so the Add handler could not see it and
would have thrown on first use. After the fix, verified in a real browser:
label toggle, detail fields, ID styling, delete/add/invalid-IP handling,
route display and repaint, plus the r43/r44 regression suites -- **25/25**.
### v2.2-r45
**A packaging regression that only bites on fresh installs**
- `zerotier-remote.sh` was committed `100644`, so every `git clone` based
build installed `/usr/bin/zerotier-remote.sh` **non-executable**. Every
scp deployment in this project chmod'd it explicitly afterwards, which is
why the bug stayed invisible until the first full firmware build+install --
where rpcd's `[ ! -x ]` guard then reported the file as *missing*. The file
was never missing; it was present and unusable. It is now committed
`100755`.
- The rpcd guard now distinguishes the two cases: *"zerotier-remote.sh is
missing"* when the file is absent, *"zerotier-remote.sh is not executable
(installed 0644; it must be 0755)"* when it is present but unusable, so the
next occurrence is diagnosable in seconds rather than a search.
- Note the hotplug script at `root/etc/hotplug.d/net/99-luci-zerotier` is
deliberately left at `0644`: OpenWrt's `/sbin/hotplug-call` *sources* files
(`[ -f $script ] && . $script`), it never execs them, so its mode is
correct as-is.
### v2.2-r44
**Layout: working content first**
@@ -172,11 +172,15 @@ function diagnostics(section, host) {
/* ---------------------------------------------------------------- networks */
function networksPanel(section) {
var box = E('div', { 'style': 'margin-top:8px;' });
var refresh = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Refresh') ]);
var add = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Create network') ]);
var table = E('div', {});
function networksPanel(section) {
var box = E('div', { 'style': 'margin-top:8px;' });
var refresh = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Refresh') ]);
var add = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Create network') ]);
var table = E('div', {});
/* Latest network-list responses keyed by nwid: the IP manager needs the
* network's routes without another controller round-trip. Replaced on
* every load so it always matches what is on screen. */
var netsById = {};
/* One row of the pool editor. The pool is {ipRangeStart, ipRangeEnd} and
* the route needs a CIDR target, so start/end are validated separately. */
@@ -665,6 +669,114 @@ function networksPanel(section) {
/* Inline edits post single-field bodies on purpose: the controller
* merges presence-checked scalars, and the ztncui reference UI posts
* single fields against this same controller in production use. */
/* ztncui-style read-only member detail: every field of the controller's
* member record in key order, then the live peer object pretty-printed.
* The cached model already holds all of it -- no RPC is needed. */
function openMemberDetail(m) {
var tbl = E('tbody', {});
Object.keys(m).sort().forEach(function(k) {
var v = m[k];
tbl.appendChild(E('tr', {}, [
E('td', { 'style': 'padding-right:14px; font-family:monospace; white-space:nowrap; vertical-align:top;' }, [ k + ':' ]),
E('td', { 'style': 'font-family:monospace; word-break:break-all;' }, [
E('code', {}, [ (v == null) ? '' : (typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v)) ])
])
]));
});
var peer = peerMap[m.id];
if (peer)
tbl.appendChild(E('tr', {}, [
E('td', { 'style': 'padding-right:14px; font-family:monospace; vertical-align:top;' }, [ 'peer:' ]),
E('td', { 'style': 'font-family:monospace; white-space:pre; word-break:break-all;' }, [ E('code', {}, [ JSON.stringify(peer, null, 2) ]) ])
]));
var body = E('div', {}, [
E('h4', {}, [ (m.name || '-') + ' (' + m.id + ')' ]),
E('table', { 'class': 'table' }, [ tbl ]),
E('div', { 'style': 'margin-top:10px; text-align:right;' }, [
E('button', { 'class': 'cbi-button', 'click': function() { ui.hideModal(); } }, [ _('Close') ])
])
]);
ui.showModal(_('Member detail'), body);
}
/* ztncui-style IP management: list the member's IPs with per-row delete,
* plus one input row to append a new one, and the network's managed
* routes read-only below. The API replaces ipAssignments wholesale, so
* delete is current-minus-row and add is current-plus-one. */
function openIpManager(m) {
var busy = false, inp;
function commit(arr) {
if (busy) return;
busy = true;
L.resolveDefault(rpcMemberSet(section, nwid, m.id, JSON.stringify({ ipAssignments: arr })), {}).then(function(r2) {
busy = false;
if (r2 && r2.code === 0) {
m.ipAssignments = arr;
if (rowRefs[m.id] && rowRefs[m.id].paintIp) rowRefs[m.id].paintIp();
paint();
}
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
});
}
var body = E('div', {});
function paint() {
while (body.firstChild) body.removeChild(body.firstChild);
body.appendChild(E('div', { 'style': 'display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;' }, [
E('h4', {}, [ _('Member name') + ': ', E('b', {}, [ m.name || '-' ]) ]),
E('h4', {}, [ _('ZeroTier address') + ': ', E('b', { 'style': 'font-family:monospace;' }, [ m.id ]) ])
]));
var rows = E('tbody', {});
(m.ipAssignments || []).forEach(function(ip, i) {
rows.appendChild(E('tr', {}, [
E('td', { 'style': 'width:70px; text-align:right;' }, [
E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() {
commit((m.ipAssignments || []).filter(function(_, j) { return j !== i; }));
} }, [ _('Delete') ])
]),
E('td', { 'style': 'font-family:monospace;' }, [ ip ])
]));
});
inp = E('input', { 'type': 'text', 'placeholder': _('IP address'), 'style': 'font-family:monospace;' });
inp.addEventListener('keydown', function(ev) { if (ev.keyCode === 13) { ev.preventDefault(); add(); } });
rows.appendChild(E('tr', {}, [
E('td', { 'style': 'text-align:right;' }, [
E('button', { 'class': 'cbi-button cbi-button-add', 'click': add }, [ _('Add') ])
]),
E('td', {}, [ inp ])
]));
body.appendChild(E('table', { 'class': 'table' }, [
E('tr', {}, [ E('th', {}), E('th', {}, [ _('IP address') ]) ]), rows
]));
var nw = netsById[nwid] || {};
var routes = nw.routes || [];
body.appendChild(E('h4', { 'style': 'margin-top:14px;' }, [ _('Managed routes') ]));
var rt = E('tbody', {});
routes.forEach(function(r) {
rt.appendChild(E('tr', {}, [
E('td', { 'style': 'font-family:monospace;' }, [ r.target || '' ]),
E('td', { 'style': 'font-family:monospace;' }, [ r.via || '' ])
]));
});
if (!routes.length) rt.appendChild(E('tr', {}, [ E('td', { 'colspan': '2', 'style': 'color:orange;' }, [ _('No route') ]) ]));
body.appendChild(E('table', { 'class': 'table' }, [
E('tr', {}, [ E('th', {}, [ _('Target') ]), E('th', {}, [ _('Gateway') ]) ]), rt
]));
body.appendChild(E('div', { 'style': 'margin-top:10px; text-align:right;' }, [
E('button', { 'class': 'cbi-button', 'click': function() { ui.hideModal(); } }, [ _('Close') ])
]));
}
function add() {
var v = inp.value.trim();
if (!isIp(v)) {
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ _('IP must be an IPv4 address') ]), 'warning');
return;
}
commit((m.ipAssignments || []).concat(v));
}
paint();
ui.showModal(_('IP assignments') + ' — ' + (m.name || m.id), body);
}
function memberRow(m) {
var mid = m.id;
var rmBusy = false;
@@ -752,7 +864,6 @@ function networksPanel(section) {
}
var ipTd = E('td', { 'style': 'width:13%; font-family:monospace; white-space:nowrap;' }, []);
var ipBusy = false, ipEditing = false;
function drawIp() {
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
var ips = m.ipAssignments || [];
@@ -770,49 +881,7 @@ function networksPanel(section) {
]) ]);
ipTd.appendChild(clickable);
}
function startIpEdit() {
if (ipBusy) return;
ipEditing = true;
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
var inp = E('input', { 'type': 'text', 'value': (m.ipAssignments || []).join(', '), 'style': 'width:100%; box-sizing:border-box; font-family:monospace;' });
ipTd.appendChild(inp);
if (inp.focus) inp.focus();
var afterKey = false;
function close() { ipEditing = false; drawIp(); }
function doCommit() {
var raw = inp.value.trim(), arr = [], i;
if (raw !== '') {
arr = raw.split(',');
for (i = 0; i < arr.length; i++) {
arr[i] = arr[i].trim();
if (!isIp(arr[i])) {
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ _('Managed IPs must be IPv4 addresses, comma-separated') ]), 'warning');
return;
}
}
}
if (arr.join(',') === (m.ipAssignments || []).join(',') || ipBusy) { close(); return; }
ipBusy = true;
inp.style.borderColor = 'orange';
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify({ ipAssignments: arr })), {}).then(function(r2) {
ipBusy = false;
if (r2 && r2.code === 0) { m.ipAssignments = arr; close(); }
else {
flashBorder(inp, 'red');
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
}
});
}
inp.addEventListener('blur', function() {
if (afterKey) { afterKey = false; return; }
if (ipEditing) doCommit();
});
inp.addEventListener('keydown', function(ev) {
if (ev.keyCode === 13) { ev.preventDefault(); afterKey = true; doCommit(); }
else if (ev.keyCode === 27) { inp.value = (m.ipAssignments || []).join(', '); close(); }
});
}
ipTd.addEventListener('click', function() { if (!ipEditing) startIpEdit(); });
ipTd.addEventListener('click', function() { openIpManager(m); });
drawIp();
function statusContent() {
@@ -837,8 +906,8 @@ function networksPanel(section) {
var tr = E('tr', {}, [
E('td', { 'style': 'width:34px; text-align:center;' }, [ rm ]),
E('td', { 'style': 'width:19%;' }, [ nameI ]),
E('td', { 'style': 'width:12%; font-family:monospace; white-space:nowrap;' }, [
E('code', {}, [ E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); } }, [ mid ]) ])
E('td', { 'style': 'width:12%; white-space:nowrap;' }, [
E('a', { 'href': '#', 'style': 'font-family:monospace;', 'title': _('Member detail'), 'click': function(ev) { ev.preventDefault(); openMemberDetail(m); } }, [ mid ])
]),
checkboxTd('authorized'),
checkboxTd('activeBridge'),
@@ -1030,7 +1099,8 @@ function networksPanel(section) {
* forms stretch the nwid column (see netRow). So the header goes in
* its own table and each network follows as a sibling block. */
table.appendChild(E('table', { 'class': 'table' }, [ head ]));
nets.forEach(function(nw) { table.appendChild(netRow(nw)); });
netsById = {};
nets.forEach(function(nw) { netsById[nw.nwid] = nw; table.appendChild(netRow(nw)); });
var skipped = res.skipped || [];
if (skipped.length) {
table.appendChild(E('div', { 'style': 'color:red; margin-top:6px;' }, [
@@ -1146,7 +1216,10 @@ return view.extend({
hosts.forEach(function(h) {
var detail = E('div', { 'style': 'display:none; margin:8px 0 16px 0; padding-left:12px; border-left:3px solid #ccc;' });
var toggle = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Manage') ]);
toggle.addEventListener('click', function() {
/* Shared by the Manage button and the host label: both paths
* open and close the same detail panel, so they can never
* disagree about the panel's state. */
function toggleDetail() {
/* A freshly created div has display '', not 'none', so the
* open test has to be for 'block' -- testing "!== 'none'"
* reads the initial state as already-open and the first
@@ -1164,7 +1237,8 @@ return view.extend({
detail.appendChild(E('h4', {}, [_('Diagnostics')]));
detail.appendChild(diagnostics(h.section, h));
}
});
}
toggle.addEventListener('click', toggleDetail);
var edit = E('button', { 'class': 'cbi-button' }, [ _('Edit') ]);
edit.addEventListener('click', function() { hostEditor(h.section, h, reload); });
@@ -1178,8 +1252,10 @@ return view.extend({
listBox.appendChild(E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;' }, [
E('div', {}, [
E('strong', {}, [ h.name ]),
E('div', {}, [
E('a', { 'href': '#', 'style': 'color:inherit; cursor:pointer; text-decoration:none;', 'title': _('Manage'), 'click': function(ev) { ev.preventDefault(); toggleDetail(); } }, [
E('strong', {}, [ h.name ])
]),
E('div', { 'style': 'font-family:monospace; font-size:12px; color:#666;' }, [
h.user + '@' + h.host + ':' + h.port + _(' (controller port ') + h.controller_port + ')'
]),
@@ -2,4 +2,4 @@
* Keys are sfh(trimws(msgid)) in hex, exactly what cbi.js _() looks up.
* This firmware ships no window.TR bridge of its own. */
window.TR = window.TR || {};
Object.assign(window.TR, {"7ba57fa5":"(控制器端口 ","005fb0ba":" —— 成员将不会从此地址池获得地址。","928f10ca":"节点地址必须为 10 位十六进制数字","4f25ffed":"地址池需要一个覆盖相同子网的路由,否则成员将获取不到地址。","623124cd":"ZeroTier 控制器或 Moon 根节点必须运行在具有固定公网 IP 的服务器上,可经 SSH 访问,且该用户具有免密 sudo 权限。控制器 API 不支持 TLS 且拒绝所有非本机回环的调用方,因此本页面从不直接访问它:而是从本路由器向远程回环接口建立 SSH 隧道,经隧道发送 API 调用。控制器令牌按需经 SSH 读取,从不存储在本机。","038a5486":"操作","e733b525":"主动桥接","300fe5c8":"添加","3698d6ed":"添加远程主机","5598797e":"加入 Moon","d252deef":"添加地址池","e5b147b7":"添加远程主机","ea8fae46":"添加路由","71677946":"地址","a4a2e2e7":"高级设置","6ee64833":"允许默认路由","d24721ab":"允许 DNS","51aaeef3":"允许全局","e575f233":"允许管理","d6d5a047":"允许 Zerotier 的拨入客户端访问路由器 LAN 资源(需要在 Zerotier 管理页面设定到 LAN 网段的路由表)","0b830d67":"正在分配地址…","2e4746c8":"授权","18bbde5a":"授权失败","ececebcc":"已授权","e3f664db":"自动允许客户端 NAT","2014c910":"自动 NAT: 已禁用","228abc80":"自动 NAT: 已启用","f2ffec55":"正在备份...","bdabf869":"备份失败","5eca6072":"立即备份","379bb677":"备份请求失败","f9fb6815":"基本设置","6b3b39f7":"广播","fcf89e62":"构建中…","ae5deb3a":"取消","08f23f6b":"无法连接到 ZeroTier 服务","4bd163d1":"无法读取 moon(ZeroTier 服务是否在运行?)","c06f3259":"正在收集数据...","8462484d":"正在获取地址...","cd2f6c29":"运行配置目录","8f48fe99":"配置","6e72b101":"已配置的主机","f1d119dc":"控制器端口","5d359cf2":"控制器可经隧道访问","41e439d8":"控制器状态存在","00a35b8a":"运行配置目录保护","bd7030be":"使用拷贝而不是链接,来保护原目录内容(一般用于预先配置的控制器模式)","88720483":"无法构建方案","a1a69329":"无法获取成员列表","c53244dc":"无法连接控制器","2c150360":"无法读取此网络的任何成员","c1dfeb1a":"以下网络无法读取,因此未显示:","f2248b2d":"无法刷新成员列表;表格保持不变","cbb3b741":"无法保存主机","56370eb4":"创建并签署 Moon","e3a2eb47":"创建失败","eb9bb863":"创建网络","6c94b9b0":"点击跳转到 Zerotier 官网管理平台,新建或者管理网络,并允许客户端接入访问你私人网路(新接入的节点默认不允许访问)","9a74d36d":"删除","b3b872fe":"删除失败","51d36b3d":"删除网络 ","f89b7324":"退出失败","88643bc2":"退出请求失败","be021833":"设备","a0c974c7":"诊断","6469d71b":"诊断失败","00c6b479":"将此文件分发给成员,然后在每个节点上使用「接口信息 → Moon → 添加 Moon」。签署密钥从未离开远程主机。","799c81a7":"下载 .moon","000e380f":"编辑","2f2c0439":"编辑远程主机","814db5a8":"留空 = 直连","78c2105b":"启用","17074f70":"端点","037e2ea1":"错误","be7c6b14":"加载 moon 列表出错","ef0771c3":"加载网络信息失败","eef2dd18":"加载节点信息失败","a285946d":"读取 moon 列表出错","b4b44266":"服务重载失败,请手动执行: /etc/init.d/luci-zerotier reload","d0e77240":"按名称、地址或 IP 筛选","9865b44d":"起始 IP","a62c536c":"常规设置","764447db":"全局设置","8686381d":"隐藏","24553b14":"远程管理的工作原理","42933f28":"接口信息","d08681fe":"IP 地址","ad69d4ef":"IP 分配","fbb92dcf":"IP 地址池","7e11b65e":"标签","f2929f6f":"结束 IP","c6ce32d3":"延迟","22038f1d":"退出","4e72d9f1":"留空则保留已存储的密钥","2b1fb9a6":"正在退出 moon...","dee10879":"已退出 Moon","9c0e750d":"链路","10e6bd0b":"加载中...","c2b71057":"本地配置文件","b259d7d8":"管理","949429e8":"托管 IP 必须为 IPv4 地址,以逗号分隔","cdd88b7c":"成员 ID","cdc6d88f":"成员名称","16916c1d":"成员","9c03b236":"个成员","a0ddf226":"Moon","b7ca31c5":"Moon 已创建:","c2e07f85":"Moon 创建失败","bbf90d15":"Moon ID","1af43404":"Moon ID 必须是 10 位十六进制字符(例如 123456abcd)","119eb928":"Moon 种子节点","793bcbfa":"Moon 种子必须是该 moon 某个根节点的 10 位地址","2e8cf7ed":"本节点并未加入该 moon","9a9a1e82":"主机上的 moon.json","7ab90fdf":"Moon","ff21d2cf":"MTU","a59f3806":"多播限制","8d8bb35f":"我的控制器","d785d68e":"名称","1cbaf231":"网络 ID","fadba037":"网络 ID 必须是 16 位十六进制字符(0-9, a-f)","e4aff468":"网络","3eaa20dd":"从未连接","53386700":"否","20961bde":"没有可 Ping 的 IP","70454625":"尚无成员加入此网络。","0887813c":"没有匹配的成员。","867914a1":"未加入任何 moLine truncated
Object.assign(window.TR, {"7ba57fa5":"(控制器端口 ","005fb0ba":" —— 成员将不会从此地址池获得地址。","928f10ca":"节点地址必须为 10 位十六进制数字","4f25ffed":"地址池需要一个覆盖相同子网的路由,否则成员将获取不到地址。","623124cd":"ZeroTier 控制器或 Moon 根节点必须运行在具有固定公网 IP 的服务器上,可经 SSH 访问,且该用户具有免密 sudo 权限。控制器 API 不支持 TLS 且拒绝所有非本机回环的调用方,因此本页面从不直接访问它:而是从本路由器向远程回环接口建立 SSH 隧道,经隧道发送 API 调用。控制器令牌按需经 SSH 读取,从不存储在本机。","038a5486":"操作","e733b525":"主动桥接","300fe5c8":"添加","3698d6ed":"添加远程主机","5598797e":"加入 Moon","d252deef":"添加地址池","e5b147b7":"添加远程主机","ea8fae46":"添加路由","71677946":"地址","a4a2e2e7":"高级设置","6ee64833":"允许默认路由","d24721ab":"允许 DNS","51aaeef3":"允许全局","e575f233":"允许管理","d6d5a047":"允许 Zerotier 的拨入客户端访问路由器 LAN 资源(需要在 Zerotier 管理页面设定到 LAN 网段的路由表)","0b830d67":"正在分配地址…","2e4746c8":"授权","18bbde5a":"授权失败","ececebcc":"已授权","e3f664db":"自动允许客户端 NAT","2014c910":"自动 NAT: 已禁用","228abc80":"自动 NAT: 已启用","f2ffec55":"正在备份...","bdabf869":"备份失败","5eca6072":"立即备份","379bb677":"备份请求失败","f9fb6815":"基本设置","6b3b39f7":"广播","fcf89e62":"构建中…","ae5deb3a":"取消","08f23f6b":"无法连接到 ZeroTier 服务","4bd163d1":"无法读取 moon(ZeroTier 服务是否在运行?)","26d000c2":"关闭","c06f3259":"正在收集数据...","8462484d":"正在获取地址...","cd2f6c29":"运行配置目录","8f48fe99":"配置","6e72b101":"已配置的主机","f1d119dc":"控制器端口","5d359cf2":"控制器可经隧道访问","41e439d8":"控制器状态存在","00a35b8a":"运行配置目录保护","bd7030be":"使用拷贝而不是链接,来保护原目录内容(一般用于预先配置的控制器模式)","88720483":"无法构建方案","a1a69329":"无法获取成员列表","c53244dc":"无法连接控制器","2c150360":"无法读取此网络的任何成员","c1dfeb1a":"以下网络无法读取,因此未显示:","f2248b2d":"无法刷新成员列表;表格保持不变","cbb3b741":"无法保存主机","56370eb4":"创建并签署 Moon","e3a2eb47":"创建失败","eb9bb863":"创建网络","6c94b9b0":"点击跳转到 Zerotier 官网管理平台,新建或者管理网络,并允许客户端接入访问你私人网路(新接入的节点默认不允许访问)","9a74d36d":"删除","b3b872fe":"删除失败","51d36b3d":"删除网络 ","f89b7324":"退出失败","88643bc2":"退出请求失败","be021833":"设备","a0c974c7":"诊断","6469d71b":"诊断失败","00c6b479":"将此文件分发给成员,然后在每个节点上使用「接口信息 → Moon → 添加 Moon」。签署密钥从未离开远程主机。","799c81a7":"下载 .moon","000e380f":"编辑","2f2c0439":"编辑远程主机","814db5a8":"留空 = 直连","78c2105b":"启用","17074f70":"端点","037e2ea1":"错误","be7c6b14":"加载 moon 列表出错","ef0771c3":"加载网络信息失败","eef2dd18":"加载节点信息失败","a285946d":"读取 moon 列表出错","b4b44266":"服务重载失败,请手动执行: /etc/init.d/luci-zerotier reload","d0e77240":"按名称、地址或 IP 筛选","9865b44d":"起始 IP","48150b7e":"网关","a62c536c":"常规设置","764447db":"全局设置","8686381d":"隐藏","24553b14":"远程管理的工作原理","42933f28":"接口信息","d08681fe":"IP 地址","da686b34":"IP 地址","ad69d4ef":"IP 分配","fbb92dcf":"IP 地址池","23603ee1":"IP 分配","cccbf9e4":"IP 必须为 IPv4 地址","7e11b65e":"标签","f2929f6f":"结束 IP","c6ce32d3":"延迟","22038f1d":"退出","4e72d9f1":"留空则保留已存储的密钥","2b1fb9a6":"正在退出 moon...","dee10879":"已退出 Moon","9c0e750d":"链路","10e6bd0b":"加载中...","c2b71057":"本地配置文件","b259d7d8":"管理","949429e8":"托管 IP 必须为 IPv4 地址,以逗号分隔","a31cd521":"托管路由","58695b7a":"成员详情","cdd88b7c":"成员 ID","cdc6d88f":"成员名称","16916c1d":"成员","9c03b236":"个成员","a0ddf226":"Moon","b7ca31c5":"Moon 已创建:","c2e07f85":"Moon 创建失败","bbf90d15":"Moon ID","1af43404":"Moon ID 必须是 10 位十六进制字符(例如 123456abcd)","119eb928":"Moon 种子节点","793bcbfa":"Moon 种子必须是该 moon 某个根节点的 10 位地址","2e8cf7ed":"本节点并未加入该 moon","9a9a1e82":"主机上的 moon.json","7ab90fdf":"Moon","ff21d2cf":"MTU","a59f3806":"多播限制","8d8bb35f":"我的控制器","d785d68e":"名称","1cbaf231":"网络 ID","fadba037":"网络 ID 必须是 16 位十六进制字符(0-9, a-f)","e4aff468":"网络","3eaa20dd":"Line truncated
Binary file not shown.
+24
View File
@@ -121,6 +121,9 @@ msgstr "无法连接到 ZeroTier 服务"
msgid "Cannot read moons (is the ZeroTier service running?)"
msgstr "无法读取 moon(ZeroTier 服务是否在运行?)"
msgid "Close"
msgstr "关闭"
msgid "Collecting data..."
msgstr "正在收集数据..."
@@ -253,6 +256,9 @@ msgstr "按名称、地址或 IP 筛选"
msgid "First IP"
msgstr "起始 IP"
msgid "Gateway"
msgstr "网关"
msgid "General options"
msgstr "常规设置"
@@ -271,12 +277,21 @@ msgstr "接口信息"
msgid "IP Address"
msgstr "IP 地址"
msgid "IP address"
msgstr "IP 地址"
msgid "IP assignment"
msgstr "IP 分配"
msgid "IP assignment pools"
msgstr "IP 地址池"
msgid "IP assignments"
msgstr "IP 分配"
msgid "IP must be an IPv4 address"
msgstr "IP 必须为 IPv4 地址"
msgid "Label"
msgstr "标签"
@@ -313,6 +328,12 @@ msgstr "管理"
msgid "Managed IPs must be IPv4 addresses, comma-separated"
msgstr "托管 IP 必须为 IPv4 地址,以逗号分隔"
msgid "Managed routes"
msgstr "托管路由"
msgid "Member detail"
msgstr "成员详情"
msgid "Member ID"
msgstr "成员 ID"
@@ -685,6 +706,9 @@ msgstr "是"
msgid "ZeroTier"
msgstr "ZeroTier"
msgid "ZeroTier address"
msgstr "ZeroTier 地址"
msgid "ZeroTier installed"
msgstr "ZeroTier 已安装"
@@ -850,13 +850,17 @@ case "$1" in
remote_moon_apply) set -- moon-apply "$_sect" "$_cf" ;;
esac
if [ ! -x /usr/bin/zerotier-remote.sh ]; then
json_init
json_add_int "code" 1
if [ ! -x /usr/bin/zerotier-remote.sh ]; then
json_init
json_add_int "code" 1
if [ -f /usr/bin/zerotier-remote.sh ]; then
json_add_string "error" "zerotier-remote.sh is not executable (installed 0644; it must be 0755)"
else
json_add_string "error" "zerotier-remote.sh is missing"
json_dump
return
fi
json_dump
return
fi
# "$@" not $_args: the body is free-form JSON from the browser,
# and a member or network name may contain spaces. Building a